diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..3ab8784078c0af970f261f7dcb46450e62e8cebe
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/bin-Debug/
+/bin-Release/
+/build-Debug/
+/build-Release/
diff --git a/CodeBackup_d.lst b/CodeBackup_d.lst
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/CodeBackup_r+.lst b/CodeBackup_r+.lst
new file mode 100644
index 0000000000000000000000000000000000000000..ca62e07b574857d67113ff6927aa5bf04786de7a
--- /dev/null
+++ b/CodeBackup_r+.lst
@@ -0,0 +1 @@
+Grinder\*.*
diff --git a/CodeBackup_r-.lst b/CodeBackup_r-.lst
new file mode 100644
index 0000000000000000000000000000000000000000..7019cabd842c903a8bb9b139e3ddfd0f66243102
--- /dev/null
+++ b/CodeBackup_r-.lst
@@ -0,0 +1 @@
+.\*.*
diff --git a/CreateCodeBackup.cmd b/CreateCodeBackup.cmd
new file mode 100644
index 0000000000000000000000000000000000000000..05afea587951d7364ce29207dd42a16d8864b3d3
--- /dev/null
+++ b/CreateCodeBackup.cmd
@@ -0,0 +1,4 @@
+del Q:\Downloaded\GrinderXXXX.rar
+
+E:\WinRar\WinRar.exe A -y -m5 -s -x@CodeBackup_d.lst Q:\Downloaded\GrinderXXXX.rar @CodeBackup_r-.lst
+E:\WinRar\WinRar.exe A -y -r -m5 -s -x@CodeBackup_d.lst Q:\Downloaded\GrinderXXXX.rar @CodeBackup_r+.lst
diff --git a/Grinder.smproj b/Grinder.smproj
new file mode 100644
index 0000000000000000000000000000000000000000..2db2df38f2f89894252cb564f2aa7d1ce2b08a05
Binary files /dev/null and b/Grinder.smproj differ
diff --git a/Grinder.tdl b/Grinder.tdl
new file mode 100644
index 0000000000000000000000000000000000000000..688c21872e8db99d4ff912f1ca7ce24e096a66e1
Binary files /dev/null and b/Grinder.tdl differ
diff --git a/Grinder/.gitignore b/Grinder/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..75c107bcc90038bcdd0d643b20eb4d33a13b4245
--- /dev/null
+++ b/Grinder/.gitignore
@@ -0,0 +1 @@
+*.pro.user
diff --git a/Grinder/Grinder.h b/Grinder/Grinder.h
new file mode 100644
index 0000000000000000000000000000000000000000..f5f04777c53e0b2f67e0918e0faa5850e990b3a7
--- /dev/null
+++ b/Grinder/Grinder.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: Grinder.h
+ * Date: 11.1.2018
+ *****************************************************************************/
+
+#ifndef GRINDER_H
+#define GRINDER_H
+
+// Global includes frequently needed
+#include <QtCore>
+#include <QtGui>
+#include <QtWidgets>
+
+#include <QDebug>
+#include <iostream>
+
+#include "core/GrinderExceptions.h"
+
+// Always use the application's namespace
+namespace grndr {}
+using namespace grndr;
+
+#endif
diff --git a/Grinder/Grinder.pro b/Grinder/Grinder.pro
new file mode 100644
index 0000000000000000000000000000000000000000..4841788786e77f0640974dc569ee559e8afeda8a
--- /dev/null
+++ b/Grinder/Grinder.pro
@@ -0,0 +1,417 @@
+## Project settings
+
+VERSION = 1.0.0
+
+QT += core gui widgets
+
+TARGET = Grinder
+TEMPLATE = app
+
+CONFIG += c++14
+DEFINES += QT_DEPRECATED_WARNINGS
+
+## Output directories
+
+Debug:DESTDIR = ../bin-Debug
+Release:DESTDIR = ../bin-Release
+
+## Libraries
+
+include(Libraries.pro)
+
+## Platform specifics
+
+win32 {
+	RC_FILE = res/Grinder.rc
+}
+
+## Project files
+
+SOURCES += \
+	main.cpp \
+	ui/mainwnd/GrinderWindow.cpp \
+    core/GrinderApplication.cpp \
+    Version.cpp \
+    util/StringUtils.cpp \
+    util/StringConv.cpp \
+    pipeline/PipelineItem.cpp \
+    pipeline/PipelineExceptions.cpp \
+    pipeline/Block.cpp \
+    pipeline/BlockType.cpp \
+    pipeline/Port.cpp \
+    pipeline/PortType.cpp \
+    pipeline/Connection.cpp \
+    pipeline/ConnectionVector.cpp \
+    pipeline/PortVector.cpp \
+    pipeline/BlockVector.cpp \
+    pipeline/Pipeline.cpp \
+    pipeline/PipelineManager.cpp \
+    pipeline/PipelineVector.cpp \
+    ui/graph/GraphView.cpp \
+    ui/graph/GraphScene.cpp \
+    ui/graph/GraphStyle.cpp \
+    ui/graph/GraphNode.cpp \
+    ui/graph/GraphBlockNode.cpp \
+    ui/graph/GraphNodeFactory.cpp \
+    ui/graph/GraphPortNode.cpp \
+    ui/graph/GraphConnectionNode.cpp \
+    ui/graph/GraphConnectionBase.cpp \
+    ui/graph/GraphConnectionBlueprint.cpp \
+    ui/graph/GraphConnectionMessage.cpp \
+    pipeline/BlockCatalog.cpp \
+    pipeline/BlockCategory.cpp \
+    util/UIUtils.cpp \
+    ui/StyleSheet.cpp \
+    controller/PipelineController.cpp \
+    ui/graph/GraphBlockList.cpp \
+    ui/graph/GraphWidget.cpp \
+	project/Label.cpp \
+	project/LabelVector.cpp \
+    project/Project.cpp \
+    project/ProjectExceptions.cpp \
+    controller/ProjectController.cpp \
+	ui/widget/ControlBar.cpp \
+	ui/mainwnd/LabelsListItem.cpp \
+	ui/mainwnd/LabelsListWidget.cpp \
+    controller/GenericController.cpp \
+	project/ImageReferenceVector.cpp \
+	project/ImageReference.cpp \
+    project/ProjectItem.cpp \
+	ui/mainwnd/ImageReferencesListItem.cpp \
+	ui/mainwnd/ImageReferencesListWidget.cpp \
+    util/ImageUtils.cpp \
+    util/FileUtils.cpp \
+    util/TemporaryStatusMessage.cpp \
+    engine/data/DataDescriptor.cpp \
+    util/DataUtils.cpp \
+    core/GrinderSettings.cpp \
+    engine/data/DataBlob.cpp \
+    util/CVUtils.cpp \
+    engine/data/DataExceptions.cpp \
+    pipeline/BlockHierarchy.cpp \
+    pipeline/blocks/BinaryThresholdBlock.cpp \
+    engine/Engine.cpp \
+    engine/EngineExceptions.cpp \
+    controller/EngineController.cpp \
+    engine/EngineExecutionContext.cpp \
+    engine/processors/BinaryThresholdProcessor.cpp \
+    engine/ProcessorBase.cpp \
+    pipeline/blocks/ConvertToGrayscaleBlock.cpp \
+    engine/processors/ConvertToGrayscaleProcessor.cpp \
+    ui/graph/GraphLayout.cpp \
+	core/GrinderExceptions.cpp \
+	project/serialization/SettingsContainer.cpp \
+	project/serialization/SerializationExceptions.cpp \
+	project/serialization/SettingsCodec.cpp \
+	project/serialization/JsonSettingsCodec.cpp \
+	project/serialization/ProjectSerializer.cpp \
+    project/serialization/SerializationContext.cpp \
+	project/serialization/DeserializationContext.cpp \
+    util/SerializationUtils.cpp \
+    common/MRUStringList.cpp \
+	ui/mainwnd/RecentProjectsMenu.cpp \
+    ui/dlg/OptionsDialog.cpp \
+    ui/property/PropertyTreeWidget.cpp \
+    ui/property/PropertyTreeItem.cpp \
+    ui/property/BlockPropertyTreeItem.cpp \
+    ui/property/PropertyTreeItemDelegate.cpp \
+    ui/property/ValuePropertyTreeItem.cpp \
+    ui/property/editors/BoolPropertyEditor.cpp \
+    ui/property/editors/TextPropertyEditor.cpp \
+    pipeline/blocks/OutputBlock.cpp \
+    pipeline/blocks/InputBlock.cpp \
+    image/ImageBuild.cpp \
+    image/ImageBuildVector.cpp \
+    image/ImageBuildPool.cpp \
+    image/ImageExceptions.cpp \
+    engine/processors/InputProcessor.cpp \
+    engine/processors/OutputProcessor.cpp \
+    ui/image/ImageEditorManager.cpp \
+    ui/image/ImageEditorDockWidget.cpp \
+    ui/visscene/VisualSceneView.cpp \
+    ui/visscene/VisualSceneStyle.cpp \
+    ui/image/ImageEditorView.cpp \
+    ui/image/ImageEditorScene.cpp \
+    controller/ImageEditorController.cpp \
+    ui/image/ImageEditorStyle.cpp \
+    image/ImageBuildItem.cpp \
+    image/Layer.cpp \
+    image/LayerVector.cpp \
+    ui/image/LayersListWidget.cpp \
+    ui/image/LayersListItem.cpp \
+    common/properties/BoolProperty.cpp \
+    common/properties/IntProperty.cpp \
+    common/properties/RealProperty.cpp \
+    common/properties/StringProperty.cpp \
+    common/properties/UIntProperty.cpp \
+    common/PropertyBase.cpp \
+    common/PropertyExceptions.cpp \
+    common/PropertyID.cpp \
+    common/PropertyVector.cpp \
+    image/DraftItemType.cpp \
+    image/DraftItem.cpp \
+    common/properties/PointProperty.cpp \
+    common/properties/ColorProperty.cpp \
+    image/DraftItemVector.cpp \
+    image/DraftItemCatalog.cpp \
+    image/draftitems/BoxDraftItem.cpp \
+    image/draftitems/LineDraftItem.cpp \
+    image/draftitems/BoxDraftItemRenderer.cpp \
+    image/draftitems/LineDraftItemRenderer.cpp \
+    image/DraftItemRendererBase.cpp \
+    common/properties/SizeProperty.cpp \
+    ui/visscene/VisualNode.cpp \
+    ui/image/ImageEditorNode.cpp \
+    ui/image/DraftItemNode.cpp \
+	ui/image/DraftItemNodeFactory.cpp \
+    ui/image/ImageEditorTool.cpp \
+	ui/image/tools/DraftItemTool.cpp \
+    ui/image/tools/DefaultImageEditorTool.cpp \
+    ui/image/tools/BoxDraftItemTool.cpp \
+    ui/image/tools/LineDraftItemTool.cpp \
+    ui/image/ImageEditorToolList.cpp \
+    ui/image/draftitems/BoxDraftItemNode.cpp \
+    ui/image/draftitems/LineDraftItemNode.cpp \
+    ui/image/ImageEditorPropertyWidget.cpp \
+    ui/property/editors/PointPropertyEditor.cpp \
+    ui/property/editors/SizePropertyEditor.cpp \
+    ui/widget/AutoFocusLineEdit.cpp \
+    ui/widget/ColorWidget.cpp \
+    ui/image/tools/ColorPickerTool.cpp \
+    ui/widget/GrinderDockWidget.cpp \
+    common/properties/AngleProperty.cpp \
+    ui/property/editors/AnglePropertyEditor.cpp \
+	ui/image/ImageEditorEnvironment.cpp \
+    util/MathUtils.cpp \
+    ui/image/ImageEditorWidget.cpp \
+    ui/image/ImageEditor.cpp \
+    ui/image/ImageEditorComponent.cpp \
+    ui/image/InPlaceEditorDragHandle.cpp \
+    ui/image/InPlaceEditor.cpp \
+    ui/image/editors/LinearInPlaceEditor.cpp \
+    common/PropertyObject.cpp \
+    ui/image/editors/RectangularInPlaceEditor.cpp \
+    ui/visscene/VisualSceneInputHandler.cpp \
+    ui/image/ColorPresetsWidget.cpp
+
+HEADERS += \        
+	ui/mainwnd/GrinderWindow.h \
+    core/GrinderApplication.h \
+    Grinder.h \
+    Version.h \
+    util/StringUtils.h \
+    util/StringConv.h \
+    pipeline/PipelineItem.h \
+    pipeline/PipelineExceptions.h \
+    pipeline/Block.h \
+    pipeline/BlockType.h \
+    pipeline/Port.h \
+    pipeline/PortType.h \
+    pipeline/Connection.h \
+    pipeline/ConnectionVector.h \
+    pipeline/PortVector.h \
+    pipeline/BlockVector.h \
+    pipeline/Pipeline.h \
+    pipeline/PipelineManager.h \
+    pipeline/PipelineVector.h \
+    ui/graph/GraphView.h \
+    ui/graph/GraphScene.h \
+    ui/graph/GraphStyle.h \
+    ui/graph/GraphNode.h \
+    ui/graph/GraphBlockNode.h \
+    ui/graph/GraphNodeFactory.h \
+    ui/graph/GraphPortNode.h \
+    ui/graph/GraphConnectionNode.h \
+    ui/graph/GraphConnectionBase.h \
+    ui/graph/GraphConnectionBlueprint.h \
+    ui/graph/GraphConnectionMessage.h \
+    pipeline/BlockCatalog.h \
+    pipeline/BlockCategory.h \
+    util/UIUtils.h \
+    ui/StyleSheet.h \
+    controller/PipelineController.h \
+    ui/graph/GraphBlockList.h \
+    ui/graph/GraphWidget.h \
+	common/ObjectVector.h \
+	common/ObjectVector.impl.h \
+	project/Label.h \
+	project/LabelVector.h \
+    project/Project.h \
+    project/ProjectExceptions.h \
+    controller/ProjectController.h \
+	ui/widget/ControlBar.h \
+	ui/mainwnd/LabelsListItem.h \
+	ui/mainwnd/LabelsListWidget.h \
+    controller/GenericController.h \
+    controller/GenericController.impl.h \
+	project/ImageReference.h \
+	project/ImageReferenceVector.h \
+    project/ProjectItem.h \
+	ui/widget/ObjectListItem.h \
+	ui/widget/ObjectListItem.impl.h \
+	ui/widget/ObjectListWidget.h \
+	ui/widget/ObjectListWidget.impl.h \
+	ui/mainwnd/ImageReferencesListItem.h \
+	ui/mainwnd/ImageReferencesListWidget.h \
+    util/ImageUtils.h \
+    util/FileUtils.h \
+    util/TemporaryStatusMessage.h \
+    engine/data/DataDescriptor.h \
+    util/DataUtils.h \
+    core/GrinderSettings.h \
+    engine/data/DataBlob.h \
+    engine/data/DataBlob.impl.h \
+    util/CVUtils.h \
+    util/CVUtils.impl.h \
+    engine/data/DataExceptions.h \
+    engine/data/DataDescriptor.impl.h \
+    pipeline/BlockHierarchy.h \
+    pipeline/blocks/BinaryThresholdBlock.h \
+    engine/Engine.h \
+    engine/EngineExceptions.h \
+    controller/EngineController.h \
+    engine/EngineExecutionContext.h \
+	engine/Processor.h \
+    engine/processors/BinaryThresholdProcessor.h \
+    engine/Processor.impl.h \
+    engine/ProcessorBase.h \
+    pipeline/blocks/ConvertToGrayscaleBlock.h \
+    engine/processors/ConvertToGrayscaleProcessor.h \
+    ui/graph/GraphLayout.h \
+	core/GrinderExceptions.h \
+	project/serialization/SettingsContainer.h \
+	project/serialization/SerializationExceptions.h \
+	project/serialization/SettingsContainer.impl.h \
+	project/serialization/SettingsCodec.h \
+	project/serialization/JsonSettingsCodec.h \
+	project/serialization/ProjectSerializer.h \
+    project/serialization/SerializationContext.h \
+	project/serialization/DeserializationContext.h \
+    util/SerializationUtils.h \
+    util/SerializationUtils.impl.h \
+    common/MRUStringList.h \
+    common/MRUStringList.impl.h \
+	ui/mainwnd/RecentProjectsMenu.h \
+    ui/dlg/OptionsDialog.h \
+    util/StringUtils.impl.h \
+    ui/property/PropertyTreeWidget.h \
+    ui/property/PropertyTreeItem.h \
+    ui/property/BlockPropertyTreeItem.h \
+    ui/property/PropertyTreeItemDelegate.h \
+    ui/property/ValuePropertyTreeItem.h \
+	ui/property/PropertyEditor.h \
+	ui/property/PropertyEditor.impl.h \
+    ui/property/editors/BoolPropertyEditor.h \
+    ui/property/editors/TextPropertyEditor.h \
+    res/Resources.h \
+    pipeline/blocks/OutputBlock.h \
+    pipeline/blocks/InputBlock.h \
+    image/ImageBuild.h \
+    project/serialization/SerializationContext.impl.h \
+    project/serialization/DeserializationContext.impl.h \
+    image/ImageBuildVector.h \
+    image/ImageBuildPool.h \
+    image/ImageExceptions.h \
+    engine/processors/InputProcessor.h \
+    engine/processors/OutputProcessor.h \
+    ui/image/ImageEditorManager.h \
+    ui/image/ImageEditorDockWidget.h \
+    common/AdjacentRange.h \
+    ui/visscene/VisualScene.h \
+    ui/visscene/VisualScene.impl.h \
+    ui/visscene/VisualSceneView.h \
+    ui/visscene/VisualSceneStyle.h \
+    ui/widget/MetaWidget.h \
+    ui/widget/MetaWidget.impl.h \
+    ui/image/ImageEditorView.h \
+    ui/image/ImageEditorScene.h \
+    controller/ImageEditorController.h \
+    ui/image/ImageEditorStyle.h \
+    image/ImageBuildItem.h \
+    image/Layer.h \
+    image/LayerVector.h \
+    ui/image/LayersListWidget.h \
+    ui/image/LayersListItem.h \
+    common/properties/BoolProperty.h \
+    common/properties/IntProperty.h \
+    common/properties/RangeConstraint.h \
+    common/properties/RangeConstraint.impl.h \
+    common/properties/RealProperty.h \
+    common/properties/StandardProperties.h \
+    common/properties/StringProperty.h \
+    common/properties/UIntProperty.h \
+    common/Property.h \
+    common/Property.impl.h \
+    common/PropertyBase.h \
+    common/PropertyConstraint.h \
+    common/PropertyConstraint.impl.h \
+    common/PropertyExceptions.h \
+    common/PropertyID.h \
+    common/PropertyVector.h \
+    common/PropertyVector.impl.h \
+    image/DraftItemType.h \
+    image/DraftItem.h \
+    common/properties/PointProperty.h \
+    common/properties/ColorProperty.h \
+    image/DraftItemVector.h \
+    image/DraftItemCatalog.h \
+    image/draftitems/BoxDraftItem.h \
+    image/draftitems/LineDraftItem.h \
+    image/DraftItemRenderer.h \
+    image/draftitems/BoxDraftItemRenderer.h \
+    image/draftitems/LineDraftItemRenderer.h \
+    image/DraftItemRenderer.impl.h \
+    image/DraftItemRendererBase.h \
+    common/properties/SizeProperty.h \
+    ui/visscene/VisualNode.h \
+    ui/image/ImageEditorNode.h \
+    ui/visscene/VisualNodeFactory.h \
+    ui/visscene/VisualNodeFactory.impl.h \
+    ui/image/DraftItemNode.h \
+	ui/image/DraftItemNodeFactory.h \
+    ui/image/ImageEditorScene.impl.h \
+    ui/image/ImageEditorTool.h \
+	ui/image/tools/DraftItemTool.h \
+    ui/image/tools/DefaultImageEditorTool.h \
+    ui/image/tools/BoxDraftItemTool.h \
+    ui/image/tools/LineDraftItemTool.h \
+    ui/image/ImageEditorToolList.h \
+    ui/image/ImageEditorToolList.impl.h \
+    ui/image/draftitems/BoxDraftItemNode.h \
+    ui/image/draftitems/LineDraftItemNode.h \
+    ui/image/ImageEditorPropertyWidget.h \
+    ui/property/editors/DualTextPropertyEditor.h \
+    ui/property/editors/PointPropertyEditor.h \
+    ui/property/editors/DualTextPropertyEditor.impl.h \
+    ui/property/editors/SizePropertyEditor.h \
+    ui/widget/AutoFocusLineEdit.h \
+    ui/widget/ColorWidget.h \
+    ui/image/tools/ColorPickerTool.h \
+    ui/widget/GrinderDockWidget.h \
+    common/properties/AngleProperty.h \
+    ui/property/editors/AnglePropertyEditor.h \
+	ui/image/ImageEditorEnvironment.h \
+    util/MathUtils.h \
+    ui/image/ImageEditorWidget.h \
+    ui/image/ImageEditor.h \
+    ui/image/ImageEditorComponent.h \
+    ui/image/InPlaceEditorDragHandle.h \
+    ui/image/InPlaceEditor.h \
+    ui/image/editors/LinearInPlaceEditor.h \
+    common/PropertyObject.h \
+    common/PropertyObject.impl.h \
+    ui/image/editors/RectangularInPlaceEditor.h \
+    ui/visscene/VisualSceneInputHandler.h \
+    ui/visscene/VisualSceneInputHandler.impl.h \
+    ui/image/ColorPresetsWidget.h
+
+FORMS += \        
+	ui/mainwnd/GrinderWindow.ui \
+    ui/dlg/OptionsDialog.ui \
+    ui/image/ImageEditorWidget.ui
+
+RESOURCES += \
+    res/Grinder.qrc
+
+OTHER_FILES += \
+	res/Grinder.rc
diff --git a/Grinder/Libraries.pro b/Grinder/Libraries.pro
new file mode 100644
index 0000000000000000000000000000000000000000..5bd9facc4913bdc2a53194bce421ee440387b181
--- /dev/null
+++ b/Grinder/Libraries.pro
@@ -0,0 +1,15 @@
+# Paths
+
+win32 {
+	INCLUDEPATH += F:\Lib\OpenCV\build-Release\install\include
+	DEPENDPATH += F:\Lib\OpenCV\build-Release\install\x86\mingw\lib
+	LIBS += -LF:\Lib\OpenCV\build-Release\install\x86\mingw\lib
+}
+
+# OpenCV
+
+LIBS += libopencv_core340
+LIBS += libopencv_imgcodecs340
+LIBS += libopencv_imgproc340
+LIBS += libopencv_features2d340
+LIBS += libopencv_highgui340
diff --git a/Grinder/Version.cpp b/Grinder/Version.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a05849f9c49b6e4c16527e94caf034a3bd9b3f43
--- /dev/null
+++ b/Grinder/Version.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: Version.cpp
+ * Date: 11.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Version.h"
+
+QString	grndr::GetVersionString(bool includeBuild)
+{
+	if (includeBuild)
+		return QString{"%1.%2.%3.%4"}.arg(GRNDR_VERSION_MAJOR).arg(GRNDR_VERSION_MINOR).arg(GRNDR_VERSION_REVISION).arg(GRNDR_VERSION_BUILD);
+	else
+		return QString{"%1.%2.%3"}.arg(GRNDR_VERSION_MAJOR).arg(GRNDR_VERSION_MINOR).arg(GRNDR_VERSION_REVISION);
+}
diff --git a/Grinder/Version.h b/Grinder/Version.h
new file mode 100644
index 0000000000000000000000000000000000000000..1ebf1c94cd8dd20b8e8c5f0228691d4b3bab65cf
--- /dev/null
+++ b/Grinder/Version.h
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: Version.cpp
+ * Date: 11.1.2018
+ *****************************************************************************/
+
+#ifndef VERSION_H
+#define VERSION_H
+
+#include <QString>
+
+#define GRNDR_INFO_TITLE		"Grinder"
+#define GRNDR_INFO_COPYRIGHT	"Copyright (c) WWU Muenster"
+#define GRNDR_INFO_DATE			"03.04.2018"
+#define GRNDR_INFO_COMPANY		"WWU Muenster"
+#define GRNDR_INFO_WEBSITE		"http://www.uni-muenster.de"
+
+#define GRNDR_VERSION_MAJOR		0
+#define GRNDR_VERSION_MINOR		2
+#define GRNDR_VERSION_REVISION	0
+#define GRNDR_VERSION_BUILD		125
+
+namespace grndr
+{
+	QString GetVersionString(bool includeBuild = false);
+}
+
+#endif
diff --git a/Grinder/common/AdjacentRange.h b/Grinder/common/AdjacentRange.h
new file mode 100644
index 0000000000000000000000000000000000000000..0a80c21de3926c03d40d66d2956b09c533ed38dc
--- /dev/null
+++ b/Grinder/common/AdjacentRange.h
@@ -0,0 +1,73 @@
+/******************************************************************************
+ * File: AdjacentRange.h
+ * Date: 13.3.2018
+ *****************************************************************************/
+
+#ifndef ADJACENTRANGE_H
+#define ADJACENTRANGE_H
+
+#include <iterator>
+#include <utility>
+
+namespace grndr
+{
+	template <typename FwdIt>
+	class adjacent_iterator
+	{
+	public:
+		adjacent_iterator(FwdIt first, FwdIt last) : m_first(first), m_next(first == last ? first : std::next(first)) { }
+
+		bool operator !=(const adjacent_iterator& other) const
+		{
+			return m_next != other.m_next;
+		}
+
+		adjacent_iterator& operator++()
+		{
+			++m_first;
+			++m_next;
+			return *this;
+		}
+
+		using Ref = typename std::iterator_traits<FwdIt>::reference;
+		using Pair = std::pair<Ref, Ref>;
+
+		Pair operator*() const
+		{
+			return Pair(*m_first, *m_next);
+		}
+
+	private:
+		FwdIt m_first;
+		FwdIt m_next;
+	};
+
+	template <typename FwdIt>
+	class adjacent_range
+	{
+	public:
+		adjacent_range(FwdIt first, FwdIt last) : m_first(first), m_last(last) { }
+
+		adjacent_iterator<FwdIt> begin() const
+		{
+			return adjacent_iterator<FwdIt>(m_first, m_last);
+		}
+
+		adjacent_iterator<FwdIt> end() const
+		{
+			return adjacent_iterator<FwdIt>(m_last, m_last);
+		}
+
+	private:
+		FwdIt m_first;
+		FwdIt m_last;
+	};
+
+	template <typename C>
+	auto make_adjacent_range(C& c) -> adjacent_range<decltype(c.begin())>
+	{
+		return adjacent_range<decltype(c.begin())>(c.begin(), c.end());
+	}
+}
+
+#endif
diff --git a/Grinder/common/MRUStringList.cpp b/Grinder/common/MRUStringList.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e2d2d2c9b85cef281e2aff78c2c644db369bfe48
--- /dev/null
+++ b/Grinder/common/MRUStringList.cpp
@@ -0,0 +1,50 @@
+/******************************************************************************
+ * File: MRUStringList.cpp
+ * Date: 01.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "MRUStringList.h"
+
+MRUStringList::MRUStringList(bool caseSensitive, bool pathList, unsigned int entryLimit) :
+	_isCaseSensitive{caseSensitive}, _isPathList{pathList}, _entryLimit{entryLimit}
+{
+
+}
+
+void MRUStringList::add(QString entry)
+{
+	auto it = find(entry);
+
+	if (it != _entries.begin() || empty())	// The entry is not at the front of the list
+	{
+		// Remove the item if it already exists
+		if (it != _entries.end())
+			_entries.erase(it);
+
+		_entries.push_front(entry);
+		purgeList();
+	}
+}
+
+void MRUStringList::remove(QString entry)
+{
+	std::remove(_entries.begin(), _entries.end(), entry);
+}
+
+QStringList::const_iterator MRUStringList::find(QString entry) const
+{
+	// Remove constness to be able to call find; safe since we will return a const_iterator
+	MRUStringList* list = const_cast<MRUStringList*>(this);
+	return list->find(entry, _entries.cbegin(), _entries.cend());
+}
+
+void MRUStringList::purgeList()
+{
+	// Remove excessive entries
+	if (_entryLimit > 0)
+	{
+		while (_entries.size() > static_cast<int>(_entryLimit))
+			_entries.pop_back();
+	}
+}
diff --git a/Grinder/common/MRUStringList.h b/Grinder/common/MRUStringList.h
new file mode 100644
index 0000000000000000000000000000000000000000..ac472aed03da7787d63629e058c4c6d89baee605
--- /dev/null
+++ b/Grinder/common/MRUStringList.h
@@ -0,0 +1,73 @@
+/******************************************************************************
+ * File: MRUStringList.h
+ * Date: 01.3.2018
+ *****************************************************************************/
+
+#ifndef MRUSTRINGLIST_H
+#define MRUSTRINGLIST_H
+
+#include <QStringList>
+
+namespace grndr
+{
+	class MRUStringList
+	{
+	public:
+		MRUStringList(bool caseSensitive = true, bool pathList = false, unsigned int entryLimit = 0);
+		MRUStringList(const MRUStringList& otherList) = default;
+		MRUStringList(MRUStringList&& otherList) = default;
+		MRUStringList(const QStringList& otherList) { _entries = otherList; purgeList(); }
+		MRUStringList(QStringList&& otherList) { _entries = std::move(otherList); purgeList(); }
+
+		MRUStringList& operator =(const MRUStringList& otherList) = default;
+		MRUStringList& operator =(MRUStringList&& otherList) = default;
+		MRUStringList& operator =(const QStringList& otherList) { _entries = otherList; purgeList(); return *this; }
+		MRUStringList& operator =(QStringList&& otherList) { _entries = std::move(otherList); purgeList(); return *this; }
+
+		operator QStringList() const { return _entries; }
+
+	public:
+		void add(QString entry);
+		void remove(QString entry);		
+
+		void operator +=(QString entry) { add(entry); }
+		void operator -=(QString entry) { remove(entry); }
+
+	public:
+		auto at(int index) { return _entries.at(index); }
+		bool contains(QString entry) const { return find(entry) != _entries.cend(); }
+
+		auto size() { return _entries.size(); }
+		auto empty() { return _entries.isEmpty(); }
+
+		auto begin() { return _entries.begin(); }
+		auto cbegin() const { return _entries.cbegin(); }
+		auto end() { return _entries.end(); }
+		auto cend() { return _entries.cend(); }
+
+	public:
+		void setCaseSensitive(bool caseSensitive = true) { _isCaseSensitive = caseSensitive; }
+		void setPathList(bool pathList = true) { _isPathList = pathList; }
+		void setEntryLimit(unsigned int limit) { _entryLimit = limit; purgeList(); }
+
+	private:
+		QStringList::iterator find(QString entry) { return find(entry, _entries.begin(), _entries.end()); }
+		QStringList::const_iterator find(QString entry) const;
+		template<typename ContainerType>
+		ContainerType find(QString entry, ContainerType begin, ContainerType end);
+
+		void purgeList();
+
+	private:
+		QStringList _entries;
+
+		bool _isCaseSensitive{true};
+		bool _isPathList{false};
+
+		unsigned int _entryLimit{0};
+	};
+}
+
+#include "MRUStringList.impl.h"
+
+#endif
diff --git a/Grinder/common/MRUStringList.impl.h b/Grinder/common/MRUStringList.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..c43c66411cb810b1932a86b8b17ca827b1696820
--- /dev/null
+++ b/Grinder/common/MRUStringList.impl.h
@@ -0,0 +1,28 @@
+/******************************************************************************
+ * File: MRUStringList.impl.h
+ * Date: 01.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "MRUStringList.h"
+
+template<typename ContainerType>
+ContainerType MRUStringList::find(QString entry, ContainerType begin, ContainerType end)
+{
+	// When comparing paths, convert backslashes to slashes first
+	if (_isPathList)
+		entry.replace('\\', '/');
+
+	for (auto it = begin; it != end; ++it)
+	{
+		QString listEntry = *it;
+
+		if (_isPathList)
+			listEntry.replace('\\', '/');
+
+		if (entry.compare(listEntry, _isCaseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive) == 0)
+			return it;
+	}
+
+	return end;
+}
diff --git a/Grinder/common/ObjectVector.h b/Grinder/common/ObjectVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..339f31bb8ece302fd4fcab0f863cd94c758bfe06
--- /dev/null
+++ b/Grinder/common/ObjectVector.h
@@ -0,0 +1,61 @@
+/******************************************************************************
+ * File: ObjectVector.h
+ * Date: 14.1.2018
+ *****************************************************************************/
+
+#ifndef OBJECTVECTOR_H
+#define OBJECTVECTOR_H
+
+#include <vector>
+#include <memory>
+
+#include "project/serialization/SerializationContext.h"
+#include "project/serialization/DeserializationContext.h"
+
+namespace grndr
+{
+	template<typename ObjType>
+	class ObjectVector : public std::vector<std::shared_ptr<ObjType>>
+	{
+	public:
+		using vector_type = ObjectVector<ObjType>;
+		using object_type = ObjType;
+		using object_vec_type = std::vector<ObjType>;
+		using pointer_type = std::shared_ptr<ObjType>;
+		using pointer_vec_type = std::vector<pointer_type>;
+		using size_type = typename std::vector<std::shared_ptr<ObjType>>::size_type;
+
+	public:
+		ObjectVector() { }
+		ObjectVector(const vector_type& src) = default;
+		ObjectVector(vector_type&& src) = default;
+
+		vector_type& operator =(const vector_type& src) = default;
+		vector_type& operator =(vector_type&& src) = default;
+
+		template<typename T = vector_type>
+		void deepCopy(const std::enable_if_t<std::is_copy_constructible<ObjType>::value, T>& src);
+
+	public:
+		pointer_type selectFirst(std::function<bool(const pointer_type&)> pred) const;
+		pointer_vec_type select(std::function<bool(const pointer_type&)> pred) const;
+
+		auto find(const pointer_type& obj) const;
+		auto find(const object_type* obj) const;
+		auto find(std::function<bool(const pointer_type&)> pred) const;
+		auto find(std::function<bool(const object_type*)> pred) const;
+
+		auto indexOf(const pointer_type& obj) const;
+		auto indexOf(const ObjType* obj) const;
+		bool contains(const pointer_type& obj) const { return indexOf(obj) != -1; }
+		bool contains(const ObjType* obj) const { return indexOf(obj) != -1; }
+
+	public:
+		void serialize(QString elemName, SerializationContext& ctx, std::function<bool(const pointer_type&)> predicate = nullptr) const;
+		void deserialize(QString elemName, DeserializationContext& ctx, std::function<pointer_type(const SettingsContainer&)> objCreator);
+	};
+}
+
+#include "ObjectVector.impl.h"
+
+#endif
diff --git a/Grinder/common/ObjectVector.impl.h b/Grinder/common/ObjectVector.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..a99334190043cd0524547b4098fef8fd59db3f19
--- /dev/null
+++ b/Grinder/common/ObjectVector.impl.h
@@ -0,0 +1,100 @@
+/******************************************************************************
+ * File: ObjectVector.impl.h
+ * Date: 14.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ObjectVector.h"
+#include "util/SerializationUtils.h"
+
+#include <algorithm>
+
+template<typename ObjType>
+template<typename T>
+void ObjectVector<ObjType>::deepCopy(const std::enable_if_t<std::is_copy_constructible<ObjType>::value, T>& src)
+{
+	this->clear();
+
+	for (const auto& elem : src)
+	{
+		if (elem.get())
+		{
+			ObjType* obj = new ObjType{*elem.get()};
+			this->emplace_back(obj);
+		}
+		else
+			this->emplace_back(nullptr);
+	}
+}
+
+template<typename ObjType>
+typename ObjectVector<ObjType>::pointer_type ObjectVector<ObjType>::selectFirst(std::function<bool(const pointer_type&)> pred) const
+{
+	auto it = std::find_if(this->cbegin(), this->cend(), pred);
+
+	if (it != this->cend())
+		return *it;
+
+	return pointer_type{};
+}
+
+template<typename ObjType>
+typename ObjectVector<ObjType>::pointer_vec_type ObjectVector<ObjType>::select(std::function<bool(const pointer_type&)> pred) const
+{
+	pointer_vec_type objects;
+	std::copy_if(this->cbegin(), this->cend(), std::back_inserter(objects), pred);
+	return objects;
+}
+
+template<typename ObjType>
+auto ObjectVector<ObjType>::find(const pointer_type& obj) const
+{
+	return std::find(this->cbegin(), this->cend(), obj);
+}
+
+template<typename ObjType>
+auto ObjectVector<ObjType>::find(const object_type* obj) const
+{
+	return std::find_if(this->cbegin(), this->cend(), [&obj](auto object) { return object.get() == obj; });
+}
+
+template<typename ObjType>
+auto ObjectVector<ObjType>::find(std::function<bool(const pointer_type&)> pred) const
+{
+	return std::find_if(this->cbegin(), this->cend(), pred);
+}
+
+template<typename ObjType>
+auto ObjectVector<ObjType>::find(std::function<bool(const object_type*)> pred) const
+{
+	return std::find_if(this->cbegin(), this->cend(), [&pred](auto object) { return pred(object.get()); });
+}
+
+template<typename ObjType>
+auto ObjectVector<ObjType>::indexOf(const ObjType* obj) const
+{
+	auto it = find(obj);
+
+	if (it == this->cend())
+		return -1;
+	else
+		return it - this->cbegin();
+}
+
+template<typename ObjType>
+auto ObjectVector<ObjType>::indexOf(const pointer_type& obj) const
+{
+	return find(obj.get());
+}
+
+template<typename ObjType>
+void ObjectVector<ObjType>::serialize(QString elemName, SerializationContext& ctx, std::function<bool(const pointer_type& object)> predicate) const
+{
+	SerializationUtils::serializeContainer(*this, elemName, ctx, predicate);
+}
+
+template<typename ObjType>
+void ObjectVector<ObjType>::deserialize(QString elemName, DeserializationContext& ctx, std::function<pointer_type(const SettingsContainer&)> objCreator)
+{
+	SerializationUtils::deserializeContainer<vector_type>(elemName, ctx, objCreator);
+}
diff --git a/Grinder/common/Property.h b/Grinder/common/Property.h
new file mode 100644
index 0000000000000000000000000000000000000000..6d1460b37c68c0387d616a816f7179e209336a1a
--- /dev/null
+++ b/Grinder/common/Property.h
@@ -0,0 +1,77 @@
+/******************************************************************************
+ * File: Property.h
+ * Date: 12.1.2018
+ *****************************************************************************/
+
+#ifndef PROPERTY_H
+#define PROPERTY_H
+
+#include <memory>
+
+#include "PropertyBase.h"
+#include "PropertyConstraint.h"
+#include "common/ObjectVector.h"
+
+namespace grndr
+{	
+	template<typename ValType>
+	class Property : public PropertyBase
+	{
+	public:
+		using property_type = Property<ValType>;
+		using value_type = ValType;
+
+	public:
+		Property(PropertyID id, QString name, ValType defValue = ValType(), Flags flags = Flag::None);
+		Property(const property_type& src) = default;
+		Property(property_type&& src) = default;
+		Property(const ValType& val);
+		Property(ValType&& val);
+
+		Property& operator =(const property_type& src) = default;
+		Property& operator =(property_type&& src) = default;
+		Property& operator =(const ValType& val);
+		Property& operator =(ValType&& val);
+
+		operator const ValType&() const { return _value; }
+
+		bool operator ==(const property_type& other) const;
+		bool operator ==(const ValType& val) const;
+
+	public:
+		const ValType& getValue() const { return _value; }
+		void setValue(const ValType& val);
+		void setValue(ValType&& val);
+
+	public:
+		template<template<typename> class ConstraintType, typename... Args>
+		void createConstraint(Args... args);
+		void removeConstraint(const PropertyConstraints::value_type& constraint);
+
+		const PropertyConstraints& constraints() { return _constraints; }	
+
+	public:
+		virtual void copyValue(const PropertyBase* property) override;
+
+		virtual QString toString() const override;
+		virtual void fromString(const QString& data) override;
+
+		virtual void serialize(SerializationContext& ctx) const override;
+		virtual void deserialize(DeserializationContext& ctx) override;	
+
+	private:
+		template<typename V, typename Setter>
+		void _setValue(V val, Setter setter);
+
+		void applyConstraints();
+
+	protected:
+		value_type _value{};
+
+		PropertyConstraints _constraints;
+	};
+}
+
+#include "Property.impl.h"
+
+#endif
diff --git a/Grinder/common/Property.impl.h b/Grinder/common/Property.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..2ab9818bbd06862a94ce4bc1cdbfbfaad233e024
--- /dev/null
+++ b/Grinder/common/Property.impl.h
@@ -0,0 +1,155 @@
+/******************************************************************************
+ * File: Property.impl.h
+ * Date: 12.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Property.h"
+#include "PropertyExceptions.h"
+#include "util/StringUtils.h"
+#include "util/StringConv.h"
+
+template<typename ValType>
+Property<ValType>::Property(PropertyID id, QString name, ValType defValue, Flags flags) : PropertyBase(id, name, flags),
+	_value{defValue}
+{
+
+}
+
+template<typename ValType>
+Property<ValType>::Property(const ValType& val) : PropertyBase(val),
+	_value{val}
+{
+
+}
+
+template<typename ValType>
+Property<ValType>::Property(ValType&& val) : PropertyBase(val),
+	_value{std::move(val)}
+{
+
+}
+
+template<typename ValType>
+Property<ValType>& Property<ValType>::operator =(const ValType& val)
+{
+	setValue(val);
+	return *this;
+}
+
+template<typename ValType>
+Property<ValType>& Property<ValType>::operator =(ValType&& val)
+{
+	setValue(val);
+	return *this;
+}
+
+template<typename ValType>
+bool Property<ValType>::operator ==(const property_type& other) const
+{
+	return _value == other._value;
+}
+
+template<typename ValType>
+bool Property<ValType>::operator ==(const ValType& val) const
+{
+	return _value == val;
+}
+
+template<typename ValType>
+void Property<ValType>::setValue(const ValType& val)
+{
+	_setValue(val, [&val, this]() { _value = val; });
+}
+
+template<typename ValType>
+void Property<ValType>::setValue(ValType&& val)
+{
+	_setValue(val, [&val, this]() { _value = std::move(val); });
+}
+
+template<typename ValType>
+template<template<typename> class ConstraintType, typename... Args>
+void Property<ValType>::createConstraint(Args... args)
+{
+	_constraints.emplace_back(new ConstraintType<value_type>(this, std::forward<Args>(args)...));
+}
+
+template<typename ValType>
+void Property<ValType>::removeConstraint(const PropertyConstraints::value_type& constraint)
+{
+	std::remove(_constraints.cbegin(), _constraints.cend(), constraint);
+}
+
+template<typename ValType>
+void Property<ValType>::copyValue(const PropertyBase* property)
+{
+	if (auto typedProperty = dynamic_cast<const property_type*>(property))
+		setValue(typedProperty->_value);
+}
+
+template<typename ValType>
+QString Property<ValType>::toString() const
+{
+	return StringUtils::encodeEscapeCharacters(StringConv::convertValue(getValue()));
+}
+
+template<typename ValType>
+void Property<ValType>::fromString(const QString& data)
+{
+	bool ok = false;
+	setValue(StringConv::convertString<ValType>(StringUtils::decodeEscapeCharacters(data), &ok));
+
+	if (!ok)
+		throw PropertyException{this, _EXCPT(QString{"Invalid string data passed ('%1')"}.arg(data))};
+}
+
+template<typename ValType>
+void Property<ValType>::serialize(SerializationContext& ctx) const
+{
+	PropertyBase::serialize(ctx);
+
+	// Serialize values
+	if (!hasFlag(Flag::ReadOnly))
+		ctx.settings()[Serialization_Value_Value] = toString();
+}
+
+template<typename ValType>
+void Property<ValType>::deserialize(DeserializationContext& ctx)
+{
+	PropertyBase::deserialize(ctx);
+
+	// Deserialize values
+	if (!hasFlag(Flag::ReadOnly))
+		fromString(ctx.settings()[Serialization_Value_Value].toString());
+}
+
+template<typename ValType>
+template<typename V, typename Setter>
+void Property<ValType>::_setValue(V val, Setter setter)
+{
+	if (hasFlag(Flag::ReadOnly))
+		throw PropertyException{this, _EXCPT("Trying to modify a read-only property")};
+
+	if (_value != val)
+	{
+		auto oldValue = _value;
+
+		setter();
+		applyConstraints();
+
+		if (_value != oldValue)
+			emit valueChanged();
+	}
+}
+
+template<typename ValType>
+void Property<ValType>::applyConstraints()
+{
+	for (const auto& constraint : _constraints)
+	{
+		// Cast the generic constraint to a constraint of the same value type as this property
+		if (auto valuedConstraint = dynamic_cast<PropertyConstraint<value_type>*>(constraint.get()))
+			valuedConstraint->applyConstraint(_value);	// Constraints will modify the passed value if needed
+	}
+}
diff --git a/Grinder/common/PropertyBase.cpp b/Grinder/common/PropertyBase.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..39294292582bb1c0f6be9fe0ec0c2a0a82018300
--- /dev/null
+++ b/Grinder/common/PropertyBase.cpp
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: PropertyBase.cpp
+ * Date: 13.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyBase.h"
+
+const char* PropertyBase::Serialization_Value_ID = "ID";
+const char* PropertyBase::Serialization_Value_Name = "Name";
+const char* PropertyBase::Serialization_Value_Value = "Value";
+
+PropertyBase::PropertyBase(PropertyID id, QString name, Flags flags) :
+	_id{id}, _name{name}, _flags{flags}
+{
+
+}
+
+void PropertyBase::serialize(SerializationContext& ctx) const
+{
+	// Serialize values
+	ctx.settings()[Serialization_Value_ID] = _id;
+	ctx.settings()[Serialization_Value_Name] = _name;
+}
+
+void PropertyBase::deserialize(DeserializationContext& ctx)
+{
+	// Deserialize values
+	_id = ctx.settings()[Serialization_Value_ID].toString();
+	_name = ctx.settings()[Serialization_Value_Name].toString();
+}
diff --git a/Grinder/common/PropertyBase.h b/Grinder/common/PropertyBase.h
new file mode 100644
index 0000000000000000000000000000000000000000..a0de5a065869bdcac1e59bb5524a47520d4ac449
--- /dev/null
+++ b/Grinder/common/PropertyBase.h
@@ -0,0 +1,81 @@
+/******************************************************************************
+ * File: PropertyBase.h
+ * Date: 13.1.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYBASE_H
+#define PROPERTYBASE_H
+
+#include "PropertyID.h"
+#include "project/serialization/SerializationContext.h"
+#include "project/serialization/DeserializationContext.h"
+
+namespace grndr
+{
+	class PropertyBase : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_ID;
+		static const char* Serialization_Value_Name;
+		static const char* Serialization_Value_Value;
+
+		enum class Flag : unsigned int
+		{
+			None = 0x0000,
+			ReadOnly = 0x0001,
+			Hidden = 0x0002,
+		};
+
+		Q_DECLARE_FLAGS(Flags, Flag)		
+
+	public:
+		PropertyBase(PropertyID id, QString name, Flags flags = Flag::None);
+		PropertyBase(const PropertyBase& src) = default;
+		PropertyBase(PropertyBase&& src) = default;
+
+		PropertyBase& operator =(const PropertyBase& src) = default;
+		PropertyBase& operator =(PropertyBase&& src) = default;
+
+	public:
+		PropertyID getID() const { return _id; }
+		void setID(PropertyID id) { _id = id; }
+
+		QString getName() const { return _name; }
+		void setName(QString name) { _name = name; }
+		QString getDescription() const { return _description; }
+		void setDescription(QString desc) { _description = desc; }
+
+		bool hasFlag(Flag flag) const { return _flags.testFlag(flag); }
+		void setFlag(Flag flag) { _flags |= flag; }
+		Flags getFlags() const { return _flags; }
+		void setFlags(Flags flags) { _flags = flags; }
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent = nullptr) { Q_UNUSED(parent); return nullptr; }
+
+	public:
+		virtual void copyValue(const PropertyBase* property) = 0;
+
+		virtual QString toString() const = 0;
+		virtual void fromString(const QString& data) = 0;
+
+		virtual void serialize(SerializationContext& ctx) const;
+		virtual void deserialize(DeserializationContext& ctx);
+
+	signals:
+		void valueChanged();
+
+	protected:
+		PropertyID _id{PropertyID::None};
+		QString _name{""};
+		QString _description{""};
+
+		Flags _flags{Flag::None};
+	};	
+}
+
+Q_DECLARE_OPERATORS_FOR_FLAGS(grndr::PropertyBase::Flags)
+
+#endif
diff --git a/Grinder/common/PropertyConstraint.h b/Grinder/common/PropertyConstraint.h
new file mode 100644
index 0000000000000000000000000000000000000000..1f618f34464036d6d4b24f61ad3424063312725c
--- /dev/null
+++ b/Grinder/common/PropertyConstraint.h
@@ -0,0 +1,43 @@
+/******************************************************************************
+ * File: PropertyConstraint.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYCONSTRAINT_H
+#define PROPERTYCONSTRAINT_H
+
+#include <vector>
+#include <memory>
+
+namespace grndr
+{
+	template<typename> class Property;
+
+	class PropertyConstraintBase
+	{
+	private:
+		virtual void _makePolymorphic() { }	// Needed to make this class polymorphic
+	};
+
+	template<typename ValType>
+	class PropertyConstraint : public PropertyConstraintBase
+	{
+	public:
+		using value_type = ValType;
+
+	public:
+		PropertyConstraint(Property<value_type>* property);
+
+	public:
+		virtual void applyConstraint(value_type& value) = 0;
+
+	protected:
+		Property<value_type>* _property{nullptr};
+	};
+
+	using PropertyConstraints = std::vector<std::unique_ptr<PropertyConstraintBase>>;
+}
+
+#include "PropertyConstraint.impl.h"
+
+#endif
diff --git a/Grinder/common/PropertyConstraint.impl.h b/Grinder/common/PropertyConstraint.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..30d04f4a96bdcbfbe455c5b3960b3c3d99f9d5d1
--- /dev/null
+++ b/Grinder/common/PropertyConstraint.impl.h
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: PropertyConstraint.impl.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyConstraint.h"
+
+template<typename ValType>
+PropertyConstraint<ValType>::PropertyConstraint(Property<value_type>* property) :
+	_property{property}
+{
+	if (!property)
+		throw std::invalid_argument{_EXCPT("property may not be null")};
+}
diff --git a/Grinder/common/PropertyExceptions.cpp b/Grinder/common/PropertyExceptions.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0477320dd96adad4cd090f29f8f691f1bc62a7e8
--- /dev/null
+++ b/Grinder/common/PropertyExceptions.cpp
@@ -0,0 +1,19 @@
+/******************************************************************************
+ * File: PropertyExceptions.cpp
+ * Date: 14.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyExceptions.h"
+
+PropertyException::PropertyException(const PropertyBase* prop, QString what) : GrinderException(what),
+	_property{prop}
+{
+
+}
+
+PropertyItemException::PropertyItemException(const PropertyObject* item, QString what) : GrinderException(what),
+	_propertyItem{item}
+{
+
+}
diff --git a/Grinder/common/PropertyExceptions.h b/Grinder/common/PropertyExceptions.h
new file mode 100644
index 0000000000000000000000000000000000000000..09ccfba02e1624ffc9341731389b8cd43949596c
--- /dev/null
+++ b/Grinder/common/PropertyExceptions.h
@@ -0,0 +1,43 @@
+/******************************************************************************
+ * File: PropertyExceptions.h
+ * Date: 14.1.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYEXCEPTIONS_H
+#define PROPERTYEXCEPTIONS_H
+
+#include <QString>
+
+#include "core/GrinderExceptions.h"
+
+namespace grndr
+{
+	class PropertyBase;
+	class PropertyObject;
+
+	class PropertyException : public GrinderException
+	{
+	public:
+		PropertyException(const PropertyBase* prop, QString what);
+
+	public:
+		const PropertyBase* property() const { return _property; }
+
+	protected:
+		const PropertyBase* _property{nullptr};
+	};
+
+	class PropertyItemException : public GrinderException
+	{
+	public:
+		PropertyItemException(const PropertyObject* item, QString what);
+
+	public:
+		const PropertyObject* propertyItem() const { return _propertyItem; }
+
+	protected:
+		const PropertyObject* _propertyItem{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/common/PropertyID.cpp b/Grinder/common/PropertyID.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e46f54f6c684f595aa0effa5cc1da1100accefc8
--- /dev/null
+++ b/Grinder/common/PropertyID.cpp
@@ -0,0 +1,22 @@
+/******************************************************************************
+ * File: PropertyID.cpp
+ * Date: 13.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyID.h"
+
+const char* PropertyID::None = "";
+const char* PropertyID::Default = "Default";
+
+const char* PropertyID::PrimaryColor = "PrimaryColor";
+const char* PropertyID::Position = "Position";
+const char* PropertyID::EndPosition = "EndPosition";
+const char* PropertyID::Size = "Size";
+const char* PropertyID::LineWidth = "LineWidth";
+const char* PropertyID::HasDirection = "HasDirection";
+const char* PropertyID::Direction = "Direction";
+
+const char* PropertyID::Threshold = "Threshold";
+const char* PropertyID::TargetValue = "TargetValue";
+const char* PropertyID::Invert = "Invert";
diff --git a/Grinder/common/PropertyID.h b/Grinder/common/PropertyID.h
new file mode 100644
index 0000000000000000000000000000000000000000..71abe80ce41f043322c79fc5014131cc3ed0343b
--- /dev/null
+++ b/Grinder/common/PropertyID.h
@@ -0,0 +1,45 @@
+/******************************************************************************
+ * File: PropertyID.h
+ * Date: 12.1.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYID_H
+#define PROPERTYID_H
+
+#include <QString>
+
+namespace grndr
+{
+	class PropertyID final : public QString
+	{
+	public:
+		static const char* None; /* Invalid property */
+		static const char* Default;
+
+		static const char* PrimaryColor;
+		static const char* Position;
+		static const char* EndPosition;
+		static const char* Size;
+		static const char* LineWidth;
+		static const char* HasDirection;
+		static const char* Direction;
+
+		static const char* Threshold;
+		static const char* TargetValue;
+		static const char* Invert;
+
+	public:
+		using QString::QString;
+
+		PropertyID() = default;
+		PropertyID(const PropertyID& other) = default;
+		PropertyID(PropertyID&& other) = default;
+		PropertyID(const QString& str) { *static_cast<QString*>(this) = str; }
+
+		PropertyID& operator =(const PropertyID& other) = default;
+		PropertyID& operator =(PropertyID&& other) = default;
+		PropertyID& operator =(const QString& str) { *static_cast<QString*>(this) = str; return *this; }
+	};
+}
+
+#endif
diff --git a/Grinder/common/PropertyObject.cpp b/Grinder/common/PropertyObject.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c912f32d884d72205a7c6203b2fffd1ff9000077
--- /dev/null
+++ b/Grinder/common/PropertyObject.cpp
@@ -0,0 +1,63 @@
+/******************************************************************************
+ * File: PropertyObject.cpp
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyObject.h"
+#include "PropertyExceptions.h"
+
+void PropertyObject::serialize(SerializationContext& ctx) const
+{
+	// Serialize all properties
+	ctx.beginGroup(PropertyVector::Serialization_Group, true);
+	_properties.serialize(PropertyVector::Serialization_Element, ctx, [](const std::shared_ptr<PropertyBase> property) {
+		return !property->hasFlag(PropertyBase::Flag::ReadOnly);
+	});
+	ctx.endGroup();
+}
+
+void PropertyObject::deserialize(DeserializationContext& ctx)
+{
+	// Deserialize all properties
+	if (ctx.beginGroup(PropertyVector::Serialization_Group))
+	{
+		_properties.deserialize(PropertyVector::Serialization_Element, ctx, [this](const SettingsContainer& settings) -> std::shared_ptr<PropertyBase> {
+			PropertyID id = settings[PropertyBase::Serialization_Value_ID].toString();
+			auto property = _properties.selectByID(id);
+
+			if (property && !property->hasFlag(PropertyBase::Flag::ReadOnly))
+				return property;
+			else
+				return nullptr;
+		});
+
+		ctx.endGroup();
+	}
+}
+
+void PropertyObject::removeProperty(PropertyID id)
+{
+	if (id != PropertyID::None)
+	{
+		auto prop = _properties.selectByID(id);
+
+		if (prop)
+			removeProperty(prop.get());
+		else
+			throw PropertyItemException{this, _EXCPT(QString{"Tried to remove a non-existing property (ID: %1)"}.arg(id))};
+	}
+}
+
+void PropertyObject::removeProperty(const PropertyBase* prop)
+{
+	if (prop)
+	{
+		auto it = _properties.find(prop);
+
+		if (it != _properties.cend())
+			_properties.erase(it);
+		else
+			throw PropertyItemException{this, _EXCPT("Tried to remove a property not belonging to this item")};
+	}
+}
diff --git a/Grinder/common/PropertyObject.h b/Grinder/common/PropertyObject.h
new file mode 100644
index 0000000000000000000000000000000000000000..bce1687c0936457fde56df15ffc197aa15feb30a
--- /dev/null
+++ b/Grinder/common/PropertyObject.h
@@ -0,0 +1,43 @@
+/******************************************************************************
+ * File: PropertyObject.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYOBJECT_H
+#define PROPERTYOBJECT_H
+
+#include <QObject>
+
+#include "common/PropertyVector.h"
+#include "common/properties/StandardProperties.h"
+
+namespace grndr
+{
+	class PropertyObject : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		PropertyVector& properties() { return _properties; }
+		const PropertyVector& properties() const { return _properties; }
+
+	public:
+		virtual void serialize(SerializationContext& ctx) const;
+		virtual void deserialize(DeserializationContext& ctx);
+
+	protected:
+		virtual void createProperties() { }
+
+		template<typename PropType>
+		std::shared_ptr<PropertyBase> createProperty(PropertyID id, QString name, typename PropType::value_type defValue = typename PropType::value_type(), PropertyBase::Flags flags = PropertyBase::Flag::None);
+		void removeProperty(PropertyID id);
+		void removeProperty(const PropertyBase* prop);
+
+	protected:
+		PropertyVector _properties;
+	};
+}
+
+#include "PropertyObject.impl.h"
+
+#endif
diff --git a/Grinder/common/PropertyObject.impl.h b/Grinder/common/PropertyObject.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..bfa920d82c08e9f60abf9309648919c56791a35e
--- /dev/null
+++ b/Grinder/common/PropertyObject.impl.h
@@ -0,0 +1,22 @@
+/******************************************************************************
+ * File: PropertyObject.impl.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyObject.h"
+#include "PropertyExceptions.h"
+
+template<typename PropType>
+std::shared_ptr<PropertyBase> PropertyObject::createProperty(PropertyID id, QString name, typename PropType::value_type defValue, PropertyBase::Flags flags)
+{
+	if (id == PropertyID::None)
+		throw std::invalid_argument{_EXCPT("id may not be empty")};
+
+	if (_properties.selectByID(id))
+		throw PropertyItemException{this, _EXCPT(QString{"A property with ID '%1' already exists"}.arg(id))};
+
+	auto prop = std::make_shared<PropType>(id, name, defValue, flags);
+	_properties.push_back(prop);
+	return prop;
+}
diff --git a/Grinder/common/PropertyVector.cpp b/Grinder/common/PropertyVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..6a61ceb36f168cd514f852d6456459a5dead0d26
--- /dev/null
+++ b/Grinder/common/PropertyVector.cpp
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: PropertyVector.cpp
+ * Date: 14.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyVector.h"
+
+const char* PropertyVector::Serialization_Group = "Properties";
+const char* PropertyVector::Serialization_Element = "Property";
+
+void PropertyVector::copyValues(const PropertyVector& properties)
+{
+	// Copy values of properties which also exist in this vector
+	for (const auto& property : properties)
+	{
+		if (auto exProperty = selectByID(property->getID()))
+			exProperty->copyValue(property.get());
+	}
+}
+
+PropertyVector::pointer_type PropertyVector::selectByID(PropertyID id) const
+{
+	return selectFirst([id](auto prop) { return prop->getID() == id; });
+}
diff --git a/Grinder/common/PropertyVector.h b/Grinder/common/PropertyVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..efd0e0ccdaa6cf6ce18af534e6ef79e6e4912842
--- /dev/null
+++ b/Grinder/common/PropertyVector.h
@@ -0,0 +1,34 @@
+/******************************************************************************
+ * File: PropertyVector.h
+ * Date: 14.1.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYVECTOR_H
+#define PROPERTYVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "Property.h"
+
+namespace grndr
+{
+	class PropertyVector : public ObjectVector<PropertyBase>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		void copyValues(const PropertyVector& properties);
+
+		template<typename PropType>
+		PropType* property(typename vector_type::size_type pos) const;
+		template<typename PropType>
+		PropType* property(PropertyID id) const;
+
+		pointer_type selectByID(PropertyID id) const;
+	};
+}
+
+#include "PropertyVector.impl.h"
+
+#endif
diff --git a/Grinder/common/PropertyVector.impl.h b/Grinder/common/PropertyVector.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..0be2c0118c16e083c243e87e48cb045c8fa512d1
--- /dev/null
+++ b/Grinder/common/PropertyVector.impl.h
@@ -0,0 +1,19 @@
+/******************************************************************************
+ * File: PropertyVector.impl.h
+ * Date: 14.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyVector.h"
+
+template<typename PropType>
+PropType* PropertyVector::property(typename vector_type::size_type pos) const
+{
+	return dynamic_cast<PropType*>(at(pos).get());
+}
+
+template<typename PropType>
+PropType* PropertyVector::property(PropertyID id) const
+{
+	return dynamic_cast<PropType*>(selectFirst([id](auto prop) { return prop->getID() == id; }));
+}
diff --git a/Grinder/common/properties/AngleProperty.cpp b/Grinder/common/properties/AngleProperty.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a86aef29100d24e8ce7cb94ad4ab6c59ebde4f89
--- /dev/null
+++ b/Grinder/common/properties/AngleProperty.cpp
@@ -0,0 +1,14 @@
+/******************************************************************************
+ * File: AngleProperty.cpp
+ * Date: 26.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "AngleProperty.h"
+#include "RangeConstraint.h"
+#include "ui/property/editors/AnglePropertyEditor.h"
+
+QWidget* AngleProperty::createEditor(QWidget* parent)
+{
+	return new AnglePropertyEditor{this, parent};
+}
diff --git a/Grinder/common/properties/AngleProperty.h b/Grinder/common/properties/AngleProperty.h
new file mode 100644
index 0000000000000000000000000000000000000000..698d2aad8f319d53b309635355d66699e3445579
--- /dev/null
+++ b/Grinder/common/properties/AngleProperty.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: AngleProperty.h
+ * Date: 26.3.2018
+ *****************************************************************************/
+
+#ifndef ANGLEPROPERTY_H
+#define ANGLEPROPERTY_H
+
+#include "common/Property.h"
+
+namespace grndr
+{
+	class AngleProperty : public Property<double>
+	{
+	public:
+		using Property<value_type>::Property;
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent) override;
+	};
+}
+
+#endif
diff --git a/Grinder/common/properties/BoolProperty.cpp b/Grinder/common/properties/BoolProperty.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..447e2e6656f89ca7cfe6b54378785453f2773102
--- /dev/null
+++ b/Grinder/common/properties/BoolProperty.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: BoolProperty.cpp
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BoolProperty.h"
+#include "ui/property/editors/BoolPropertyEditor.h"
+
+QWidget* BoolProperty::createEditor(QWidget* parent)
+{
+	return new BoolPropertyEditor{this, parent};
+}
diff --git a/Grinder/common/properties/BoolProperty.h b/Grinder/common/properties/BoolProperty.h
new file mode 100644
index 0000000000000000000000000000000000000000..c7597f5d63d24d652bf90ec932ead5f4010b913a
--- /dev/null
+++ b/Grinder/common/properties/BoolProperty.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: BoolProperty.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#ifndef BOOLPROPERTY_H
+#define BOOLPROPERTY_H
+
+#include "common/Property.h"
+
+namespace grndr
+{
+	class BoolProperty : public Property<bool>
+	{
+	public:
+		using Property<value_type>::Property;
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent) override;
+	};
+}
+
+#endif
diff --git a/Grinder/common/properties/ColorProperty.cpp b/Grinder/common/properties/ColorProperty.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..afae6920791be85a3f70326ac5b324ff5c4b10ab
--- /dev/null
+++ b/Grinder/common/properties/ColorProperty.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: ColorProperty.cpp
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ColorProperty.h"
+
+QWidget* ColorProperty::createEditor(QWidget* parent)
+{
+	// TODO:
+	return nullptr;
+}
diff --git a/Grinder/common/properties/ColorProperty.h b/Grinder/common/properties/ColorProperty.h
new file mode 100644
index 0000000000000000000000000000000000000000..270533d0b6a63d96063b1a047dfefca190dadb04
--- /dev/null
+++ b/Grinder/common/properties/ColorProperty.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: ColorProperty.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#ifndef COLORPROPERTY_H
+#define COLORPROPERTY_H
+
+#include "common/Property.h"
+
+namespace grndr
+{
+	class ColorProperty : public Property<QColor>
+	{
+	public:
+		using Property<value_type>::Property;
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent) override;
+	};
+}
+
+#endif
diff --git a/Grinder/common/properties/IntProperty.cpp b/Grinder/common/properties/IntProperty.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..b55576de56c49bfae358caf686b010d9d0b9f7d1
--- /dev/null
+++ b/Grinder/common/properties/IntProperty.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: IntProperty.cpp
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "IntProperty.h"
+#include "ui/property/editors/TextPropertyEditor.h"
+
+QWidget* IntProperty::createEditor(QWidget* parent)
+{
+	return new TextPropertyEditor{this, "-?\\d+", parent};
+}
diff --git a/Grinder/common/properties/IntProperty.h b/Grinder/common/properties/IntProperty.h
new file mode 100644
index 0000000000000000000000000000000000000000..b3c1b393d7bf77f25ff44ba876fffa467e8c1c58
--- /dev/null
+++ b/Grinder/common/properties/IntProperty.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: IntProperty.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#ifndef INTPROPERTY_H
+#define INTPROPERTY_H
+
+#include "common/Property.h"
+
+namespace grndr
+{
+	class IntProperty : public Property<int>
+	{
+	public:
+		using Property<value_type>::Property;
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent) override;
+	};
+}
+
+#endif
diff --git a/Grinder/common/properties/PointProperty.cpp b/Grinder/common/properties/PointProperty.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..2e8eabe53407440d152b5803090412c45f8e3167
--- /dev/null
+++ b/Grinder/common/properties/PointProperty.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: PointProperty.cpp
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PointProperty.h"
+#include "ui/property/editors/PointPropertyEditor.h"
+
+QWidget* PointProperty::createEditor(QWidget* parent)
+{
+	return new PointPropertyEditor{this, parent};
+}
diff --git a/Grinder/common/properties/PointProperty.h b/Grinder/common/properties/PointProperty.h
new file mode 100644
index 0000000000000000000000000000000000000000..58eba874b6f79d023332d37cfb8bd642d7cb7879
--- /dev/null
+++ b/Grinder/common/properties/PointProperty.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: PointProperty.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#ifndef POINTPROPERTY_H
+#define POINTPROPERTY_H
+
+#include "common/Property.h"
+
+namespace grndr
+{
+	class PointProperty : public Property<QPoint>
+	{
+	public:
+		using Property<value_type>::Property;
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent) override;
+	};
+}
+
+#endif
diff --git a/Grinder/common/properties/RangeConstraint.h b/Grinder/common/properties/RangeConstraint.h
new file mode 100644
index 0000000000000000000000000000000000000000..9175bb880173b59954e3894e296cf8ccdd74d8a8
--- /dev/null
+++ b/Grinder/common/properties/RangeConstraint.h
@@ -0,0 +1,39 @@
+/******************************************************************************
+ * File: RangeConstraint.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#ifndef RANGECONSTRAINT_H
+#define RANGECONSTRAINT_H
+
+#include "common/PropertyConstraint.h"
+
+namespace grndr
+{
+	template<typename ValType>
+	class RangeConstraint : public PropertyConstraint<ValType>
+	{
+	public:
+		using value_type = typename PropertyConstraint<ValType>::value_type;
+
+	public:
+		RangeConstraint(Property<value_type>* property, const value_type& minValue, const value_type& maxValue);
+
+	public:
+		virtual void applyConstraint(value_type& value);
+
+	public:
+		value_type getMinValue() const { return _minValue; }
+		void setMinValue(value_type value) { _minValue = value; }
+		value_type getMaxValue() const { return _maxValue; }
+		void setMaxValue(value_type value) { _maxValue = value; }
+
+	private:
+		value_type _minValue{};
+		value_type _maxValue{};
+	};
+}
+
+#include "RangeConstraint.impl.h"
+
+#endif
diff --git a/Grinder/common/properties/RangeConstraint.impl.h b/Grinder/common/properties/RangeConstraint.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..9c5f3eb6c6c3d57a274172dc276717306509a05c
--- /dev/null
+++ b/Grinder/common/properties/RangeConstraint.impl.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: RangeConstraint.impl.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "RangeConstraint.h"
+
+template<typename ValType>
+RangeConstraint<ValType>::RangeConstraint(Property<value_type>* property, const value_type& minValue, const value_type& maxValue) : PropertyConstraint<ValType>(property),
+	_minValue{minValue}, _maxValue{maxValue}
+{
+
+}
+
+template<typename ValType>
+void RangeConstraint<ValType>::applyConstraint(value_type& value)
+{
+	if (value < _minValue)
+		value = _minValue;
+
+	if (value > _maxValue)
+		value = _maxValue;
+}
diff --git a/Grinder/common/properties/RealProperty.cpp b/Grinder/common/properties/RealProperty.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f8f49fa96f1ccbba65f87c1d3cbdfb577eb1086e
--- /dev/null
+++ b/Grinder/common/properties/RealProperty.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: RealProperty.cpp
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "RealProperty.h"
+#include "ui/property/editors/TextPropertyEditor.h"
+
+QWidget* RealProperty::createEditor(QWidget* parent)
+{
+	return new TextPropertyEditor{this, "-?\\d+(.?\\d+)?", parent};
+}
diff --git a/Grinder/common/properties/RealProperty.h b/Grinder/common/properties/RealProperty.h
new file mode 100644
index 0000000000000000000000000000000000000000..749581bd72085dbbe51d2887b92e7ded41e8a65f
--- /dev/null
+++ b/Grinder/common/properties/RealProperty.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: RealProperty.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#ifndef REALPROPERTY_H
+#define REALPROPERTY_H
+
+#include "common/Property.h"
+
+namespace grndr
+{
+	class RealProperty : public Property<double>
+	{
+	public:
+		using Property<value_type>::Property;
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent) override;
+	};
+}
+
+#endif
diff --git a/Grinder/common/properties/SizeProperty.cpp b/Grinder/common/properties/SizeProperty.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c535cdb92327d6f313f40f8d5bc77876547b379e
--- /dev/null
+++ b/Grinder/common/properties/SizeProperty.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: SizeProperty.cpp
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SizeProperty.h"
+#include "ui/property/editors/SizePropertyEditor.h"
+
+QWidget* SizeProperty::createEditor(QWidget* parent)
+{
+	return new SizePropertyEditor{this, parent};
+}
diff --git a/Grinder/common/properties/SizeProperty.h b/Grinder/common/properties/SizeProperty.h
new file mode 100644
index 0000000000000000000000000000000000000000..27604fd3a9f6b4e6cf20a560b805359694272a28
--- /dev/null
+++ b/Grinder/common/properties/SizeProperty.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: SizeProperty.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef SIZEPROPERTY_H
+#define SIZEPROPERTY_H
+
+#include "common/Property.h"
+
+namespace grndr
+{
+	class SizeProperty : public Property<QSize>
+	{
+	public:
+		using Property<value_type>::Property;
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent) override;
+	};
+}
+
+#endif
diff --git a/Grinder/common/properties/StandardProperties.h b/Grinder/common/properties/StandardProperties.h
new file mode 100644
index 0000000000000000000000000000000000000000..4dd7d4078fc7b9e0149810fa86895506898fac38
--- /dev/null
+++ b/Grinder/common/properties/StandardProperties.h
@@ -0,0 +1,19 @@
+/******************************************************************************
+ * File: StandardProperties.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#ifndef STANDARDPROPERTIES_H
+#define STANDARDPROPERTIES_H
+
+#include "BoolProperty.h"
+#include "IntProperty.h"
+#include "RealProperty.h"
+#include "AngleProperty.h"
+#include "StringProperty.h"
+#include "UIntProperty.h"
+#include "PointProperty.h"
+#include "SizeProperty.h"
+#include "ColorProperty.h"
+
+#endif
diff --git a/Grinder/common/properties/StringProperty.cpp b/Grinder/common/properties/StringProperty.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..7c320659f1e94be3fccaafef9a83b82e8a39dd5b
--- /dev/null
+++ b/Grinder/common/properties/StringProperty.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: StringProperty.cpp
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "StringProperty.h"
+#include "ui/property/editors/TextPropertyEditor.h"
+
+QWidget* StringProperty::createEditor(QWidget* parent)
+{
+	return new TextPropertyEditor{this, "", parent};
+}
diff --git a/Grinder/common/properties/StringProperty.h b/Grinder/common/properties/StringProperty.h
new file mode 100644
index 0000000000000000000000000000000000000000..510d840a22da8bca1b3a6df706c46958cace3b03
--- /dev/null
+++ b/Grinder/common/properties/StringProperty.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: StringProperty.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#ifndef STRINGPROPERTY_H
+#define STRINGPROPERTY_H
+
+#include "common/Property.h"
+
+namespace grndr
+{
+	class StringProperty : public Property<QString>
+	{
+	public:
+		using Property<value_type>::Property;
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent) override;
+	};
+}
+
+#endif
diff --git a/Grinder/common/properties/UIntProperty.cpp b/Grinder/common/properties/UIntProperty.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..1395c1fc8920c08984bfd878ae7c76f4dea8236d
--- /dev/null
+++ b/Grinder/common/properties/UIntProperty.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: UIntProperty.cpp
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "UIntProperty.h"
+#include "ui/property/editors/TextPropertyEditor.h"
+
+QWidget* UIntProperty::createEditor(QWidget* parent)
+{
+	return new TextPropertyEditor{this, "\\d+", parent};
+}
diff --git a/Grinder/common/properties/UIntProperty.h b/Grinder/common/properties/UIntProperty.h
new file mode 100644
index 0000000000000000000000000000000000000000..e7ad8fb08ca6befe1fa9052c5fd0ba7a89dcb3e3
--- /dev/null
+++ b/Grinder/common/properties/UIntProperty.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: UIntProperty.h
+ * Date: 05.3.2018
+ *****************************************************************************/
+
+#ifndef UINTPROPERTY_H
+#define UINTPROPERTY_H
+
+#include "common/Property.h"
+
+namespace grndr
+{
+	class UIntProperty : public Property<unsigned int>
+	{
+	public:
+		using Property<value_type>::Property;
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent) override;
+	};
+}
+
+#endif
diff --git a/Grinder/controller/EngineController.cpp b/Grinder/controller/EngineController.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..56d16fb2161d15f66cfe4eeb70f9a557521c797c
--- /dev/null
+++ b/Grinder/controller/EngineController.cpp
@@ -0,0 +1,57 @@
+/******************************************************************************
+ * File: EngineController.cpp
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "EngineController.h"
+#include "project/Label.h"
+#include "engine/Engine.h"
+#include "engine/EngineExceptions.h"
+#include "util/TemporaryStatusMessage.h"
+#include "util/UIUtils.h"
+
+EngineController::EngineController(Engine* engine) : GenericController("Engine"),
+	_engine{engine}
+{
+	if (!engine)
+		throw std::invalid_argument{_EXCPT("engine may not be null")};
+}
+
+void EngineController::executeLabel(Label* label, Engine::ExecutionMode mode) const
+{	
+	if (label)
+	{		
+		TemporaryStatusMessage tmpStatusMessage{QString{"Executing label '%1'..."}.arg(label->getName())};
+
+		callControllerFunction(QString{"Executing label '%1'"}.arg(label->getName()), [this](Label* label, Engine::ExecutionMode mode) {
+			_engine->executeLabel(label, mode);
+			return true;
+		}, label, mode);
+	}
+}
+
+cv::Mat EngineController::executeLabelEx(Label* label, const Port* port, Engine::ExecutionMode mode) const
+{
+	if (label && port)
+	{		
+		TemporaryStatusMessage tmpStatusMessage{QString{"Executing label '%1'..."}.arg(label->getName())};
+
+		return callControllerFunction(QString{"Executing label '%1'"}.arg(label->getName()), [this](Label* label, const Port* port, Engine::ExecutionMode mode) {
+			// If the port is an in-port, get data from the connected out-port
+			if (port->isIn())
+			{
+				auto cons = port->getConnections(Port::Direction::In);
+
+				if (cons.size() == 1)
+					port = cons.front()->sourcePort();
+				else if (cons.size() > 1)
+					throw EngineException{_engine, _EXCPT(QString{"Port '%1' has multiple inputs"}.arg(port->getFormattedName()))};
+			}
+
+			return _engine->executeLabelEx(label, port, mode);
+		}, label, port, mode);
+	}
+
+	return cv::Mat{};
+}
diff --git a/Grinder/controller/EngineController.h b/Grinder/controller/EngineController.h
new file mode 100644
index 0000000000000000000000000000000000000000..8fffcd78ee519e34f526571b7471a9529c10919a
--- /dev/null
+++ b/Grinder/controller/EngineController.h
@@ -0,0 +1,37 @@
+/******************************************************************************
+ * File: EngineController.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#ifndef ENGINECONTROLLER_H
+#define ENGINECONTROLLER_H
+
+#include "GenericController.h"
+#include "engine/EngineExecutionContext.h"
+
+#include <opencv2/core.hpp>
+
+namespace grndr
+{
+	class Engine;
+	class Label;
+	class Block;
+	class Port;
+
+	class EngineController : public GenericController
+	{
+		Q_OBJECT
+
+	public:
+		EngineController(Engine* engine);
+
+	public:
+		void executeLabel(Label* label, Engine::ExecutionMode mode) const;
+		cv::Mat executeLabelEx(Label* label, const Port* port, Engine::ExecutionMode mode) const;
+
+	private:
+		Engine* _engine{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/controller/GenericController.cpp b/Grinder/controller/GenericController.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8b70dd397de534561691ac8275742a6f80fffcad
--- /dev/null
+++ b/Grinder/controller/GenericController.cpp
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: GenericController.cpp
+ * Date: 08.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GenericController.h"
+
+GenericController::GenericController(QString typeName) :
+	_controllerTypeName{typeName}
+{
+	if (typeName.isEmpty())
+		throw std::invalid_argument{_EXCPT("typeName may not be empty")};
+}
+
+void GenericController::showControllerError(QString action, QString error) const
+{
+	if (!_suppressErrors)
+	{
+		QString str = !error.isEmpty() ? GetExceptionMessage(error) : "Unknown exception";
+		QMessageBox::warning(nullptr, _controllerTypeName + " error", QString{"<b>%2 failed:</b><br>%1"}.arg(str).arg(action));
+	}
+}
diff --git a/Grinder/controller/GenericController.h b/Grinder/controller/GenericController.h
new file mode 100644
index 0000000000000000000000000000000000000000..6d3349e44d638b5df3284ee1d7c3cb03f2f35e56
--- /dev/null
+++ b/Grinder/controller/GenericController.h
@@ -0,0 +1,39 @@
+/******************************************************************************
+ * File: GenericController.h
+ * Date: 08.2.2018
+ *****************************************************************************/
+
+#ifndef GENERICCONTROLLER_H
+#define GENERICCONTROLLER_H
+
+#include <QObject>
+
+namespace grndr
+{
+	class GenericController : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		GenericController(QString typeName);
+
+	protected:
+		template<typename Functor, typename... Args>
+		auto callControllerFunction(QString action, Functor fnc, Args... args) const -> decltype(fnc(args...));
+
+		virtual bool canCallControllerFunction() const { return true; }
+		virtual void controllerFunctionCalled() const { }
+
+		void showControllerError(QString action, QString error) const;
+
+	protected:
+		bool _suppressErrors{false};
+
+	private:
+		QString _controllerTypeName{""};
+	};
+}
+
+#include "GenericController.impl.h"
+
+#endif
diff --git a/Grinder/controller/GenericController.impl.h b/Grinder/controller/GenericController.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..9fe6e6a88213c7c008abc4970748625d1877cd4e
--- /dev/null
+++ b/Grinder/controller/GenericController.impl.h
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: GenericController.impl.h
+ * Date: 08.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GenericController.h"
+
+template<typename Functor, typename... Args>
+auto GenericController::callControllerFunction(QString action, Functor fnc, Args... args) const -> decltype(fnc(args...))
+{
+	if (canCallControllerFunction())
+	{
+		try {
+			auto result = fnc(std::forward<Args>(args)...);
+			controllerFunctionCalled();
+			return result;
+		} catch (std::exception& e) {
+			showControllerError(action, e.what());
+		}
+	}
+
+	using retType = decltype(fnc(args...));
+	return retType{};
+}
diff --git a/Grinder/controller/ImageEditorController.cpp b/Grinder/controller/ImageEditorController.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ce5c25d19818ac6614431b3b70f951a01ac27ae5
--- /dev/null
+++ b/Grinder/controller/ImageEditorController.cpp
@@ -0,0 +1,316 @@
+/******************************************************************************
+ * File: ImageEditorController.cpp
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorController.h"
+#include "core/GrinderApplication.h"
+#include "image/ImageExceptions.h"
+#include "ui/image/ImageEditor.h"
+#include "ui/image/LayersListWidget.h"
+
+ImageEditorController::ImageEditorController(ImageEditor* imageEditor) : GenericController("Image editor"), ImageEditorComponent(imageEditor)
+{
+	if (!imageEditor)
+		throw std::invalid_argument{_EXCPT("imageEditor may not be null")};
+}
+
+void ImageEditorController::assignUiComponents(ImageEditorView* view, LayersListWidget* layersList)
+{
+	if (!view)
+		throw std::invalid_argument{_EXCPT("view may not be null")};
+
+	if (!layersList)
+		throw std::invalid_argument{_EXCPT("layersList may not be null")};
+
+	_view = view;
+	_layersList = layersList;
+}
+
+void ImageEditorController::showImageBuild(ImageBuild* build)
+{
+	if (build == _activeImageBuild)
+		return;
+
+	if (!_view)
+		throw std::runtime_error{_EXCPT("No view has been set")};
+
+	if (_activeImageBuild)	// No longer listen for events from the previous image build
+	{
+		disconnect(_activeImageBuild, nullptr, this, nullptr);
+		connectLayerSignals(_activeImageBuild, false);
+	}
+
+	if (_activeImageBuild)
+		emit imageBuildSwitching(_activeImageBuild);
+
+	_activeImageBuild = build;
+
+	// Unset any currently active scene
+	_view->setEditorScene(nullptr);
+	_activeScene.reset(nullptr);
+
+	// Unset any currently active layer
+	switchLayer(nullptr);
+
+	if (build)	// Create a new scene for the given image build and show it
+	{
+		_activeScene = std::make_unique<ImageEditorScene>(_imageEditor, _activeImageBuild, _view);
+		_activeScene->buildScene();
+
+		_view->setEditorScene(_activeScene.get());
+
+		// Listen for image build events
+		connect(_activeImageBuild, &ImageBuild::imageDataChanged, this, &ImageEditorController::refreshEditorImage);
+		connect(_activeImageBuild, &ImageBuild::layerCreated, this, &ImageEditorController::layerCreated);
+		connect(_activeImageBuild, &ImageBuild::layerRemoved, this, &ImageEditorController::layerRemoved);
+		connect(_activeImageBuild, &ImageBuild::layerMoved, this, &ImageEditorController::layerMoved);
+
+		// Listen for signals from all existing layers of the image build
+		connectLayerSignals(_activeImageBuild);
+
+		// Show the new editor image and fit it to its window if the corresponding option is turned on
+		refreshEditorImage();
+
+		if (grinder()->settings().imageEditorOptions().autoFitToWindow)
+			_activeScene->fitViewToImage();
+	}
+
+	emit imageBuildSwitched(_activeImageBuild);
+}
+
+void ImageEditorController::switchLayer(Layer* layer)
+{
+	if (layer == _activeLayer)
+		return;
+
+	if (_activeLayer)
+		emit layerSwitching(_activeLayer);
+
+	_activeLayer = layer;
+	emit layerSwitched(_activeLayer);
+}
+
+std::shared_ptr<Layer> ImageEditorController::createLayer(QString name) const
+{
+	return callControllerFunction("Creating a new layer", [this](QString name) {
+		return _activeImageBuild->createLayer(name);
+	}, name);
+}
+
+void ImageEditorController::removeLayer(const Layer* layer)
+{
+	if (layer)
+	{
+		// If removing the active layer, unset it first
+		if (_activeLayer == layer)
+			switchLayer(nullptr);
+
+		callControllerFunction("Removing a layer", [this](const Layer* layer) {
+			_activeImageBuild->removeLayer(layer);
+			return true;
+		}, layer);
+	}
+}
+
+void ImageEditorController::removeAllLayers()
+{
+	if (_activeImageBuild)
+	{
+		auto layers = _activeImageBuild->layers();
+
+		for (const auto& layer : layers)
+			removeLayer(layer.get());
+	}
+}
+
+std::shared_ptr<DraftItem> ImageEditorController::createDraftItem(DraftItemType type, Layer* layer) const
+{
+	if (!layer)
+		layer = _activeLayer;
+
+	if (layer)
+	{
+		return callControllerFunction("Creating a draft item", [this](DraftItemType type, Layer* layer) {
+			return layer->createDraftItem(type);
+		}, type, layer);
+	}
+	else
+		return nullptr;
+}
+
+void ImageEditorController::removeDraftItem(const DraftItem* item) const
+{
+	auto layer = const_cast<Layer*>(item->layer());	// We don't modify the item itself but its parent layer, so remove constness
+
+	if (item)
+	{
+		callControllerFunction("Removing a draft item", [this](const DraftItem* item, Layer* layer) {
+			layer->removeDraftItem(item);
+			return true;
+		}, item, layer);
+	}
+}
+
+void ImageEditorController::setLayerVisibility(Layer* layer, bool visible) const
+{
+	layer->setVisible(visible);
+}
+
+void ImageEditorController::moveLayer(Layer* layer, bool up) const
+{
+	callControllerFunction("Moving a layer", [this](Layer* layer, bool up) {
+		_activeImageBuild->moveLayer(layer, up);
+		return true;
+	}, layer, up);
+}
+
+void ImageEditorController::renameLayer(Layer* layer, QString newName) const
+{
+	callControllerFunction("Renaming a layer", [this](Layer* layer, QString newName) {
+		validateLayerName(newName, layer);
+		renameImageBuildItem(layer, newName);
+		return true;
+	}, layer, newName);
+}
+
+void ImageEditorController::renameImageBuildItem(ImageBuildItem* item, QString newName) const
+{
+	item->setName(newName);
+}
+
+void ImageEditorController::setNodesPrimaryColor(QColor color, const std::vector<DraftItemNode*>& nodes) const
+{
+	if (_activeScene)
+	{
+		for (const auto& node : nodes)
+		{
+			if (auto item = node->draftItem().lock())
+				item->primaryColor()->setValue(color);
+		}
+	}
+}
+
+void ImageEditorController::removeSelectedNodes() const
+{
+	if (_activeScene)
+	{
+		// Remove all selected draft items
+		auto selItems = _activeScene->getNodes<DraftItemNode>(true);
+
+		for (const auto& node : selItems)
+		{
+			if (auto item = node->draftItem().lock())
+				removeDraftItem(item.get());
+		}
+	}
+}
+
+void ImageEditorController::controllerFunctionCalled() const
+{
+	// Redraw the scene after calling a controller function
+	if (_activeScene)
+		_activeScene->update();
+}
+
+void ImageEditorController::validateLayerName(QString name, const Layer* layer) const
+{
+	auto imageBuild = layer ? layer->imageBuild() : _activeImageBuild;
+
+	// Name must be non-empty and unique
+	if (!name.isEmpty())
+	{
+		auto existingLayer = imageBuild->layers().selectByName(name).get();
+
+		if (existingLayer && existingLayer != layer)
+			throw ImageBuildException{imageBuild, _EXCPT(QString{"A layer with the name '%1' already exists"}.arg(name))};
+	}
+	else
+		throw ImageBuildException{imageBuild, _EXCPT("The layer name may not be empty")};
+}
+
+void ImageEditorController::connectLayerSignals(ImageBuild* imageBuild, bool connectSignals) const
+{
+	// Listen for/stop listening for events from all layers
+	for (auto& layer : imageBuild->layers())
+		connectLayerSignals(layer.get(), connectSignals);
+}
+
+void ImageEditorController::connectLayerSignals(Layer* layer, bool connectSignals) const
+{
+	if (connectSignals)
+	{
+		// Get notified about layer and draft item events
+		connect(layer, &Layer::draftItemCreated, this, &ImageEditorController::draftItemCreated);
+		connect(layer, &Layer::draftItemRemoved, this, &ImageEditorController::draftItemRemoved);
+		connect(layer, &Layer::layerShown, this, &ImageEditorController::layerVisibilityChanged);
+		connect(layer, &Layer::layerHidden, this, &ImageEditorController::layerVisibilityChanged);
+	}
+	else
+		disconnect(layer, nullptr, this, nullptr);
+}
+
+void ImageEditorController::refreshEditorImage()
+{
+	callControllerFunction("Refreshing editor image", [this]() {
+		if (_activeScene)
+			_activeScene->refreshImage(true);
+
+		return true;
+	});
+}
+
+void ImageEditorController::layerCreated(const std::shared_ptr<Layer>& layer) const
+{
+	connectLayerSignals(layer.get());
+
+	if (_layersList)
+		_layersList->addObject(layer.get(), true, true);
+}
+
+void ImageEditorController::layerRemoved(const std::shared_ptr<Layer>& layer) const
+{
+	connectLayerSignals(layer.get(), false);
+
+	// Remove all draft item nodes that belong to the removed layer
+	if (_activeScene)
+	{
+		for (const auto& item : layer->draftItems())
+			_activeScene->removeDraftItemNode(item);
+	}
+
+	if (_layersList)
+		_layersList->removeObject(layer.get());
+}
+
+void ImageEditorController::layerMoved(const std::shared_ptr<Layer>& layer, int indexOld, int indexNew) const
+{
+	Q_UNUSED(layer);
+
+	// Reflect the new z-order in the scene
+	if (_activeScene)
+		_activeScene->updateDraftItemsOrder();
+
+	if (_layersList)
+		_layersList->swapLayers(indexOld, indexNew);
+}
+
+void ImageEditorController::layerVisibilityChanged() const
+{
+	// Update the visibility of all draft items in the layer
+	if (_activeScene)
+		_activeScene->updateDraftItemsVisibility(dynamic_cast<Layer*>(sender()));
+}
+
+void ImageEditorController::draftItemCreated(const std::shared_ptr<DraftItem>& item) const
+{
+	if (_activeScene)
+		_activeScene->createDraftItemNode(item);
+}
+
+void ImageEditorController::draftItemRemoved(const std::shared_ptr<DraftItem>& item) const
+{
+	if (_activeScene)
+		_activeScene->removeDraftItemNode(item);
+}
diff --git a/Grinder/controller/ImageEditorController.h b/Grinder/controller/ImageEditorController.h
new file mode 100644
index 0000000000000000000000000000000000000000..73ae110dacf7db95c84f5f557b7a6a455e8aea73
--- /dev/null
+++ b/Grinder/controller/ImageEditorController.h
@@ -0,0 +1,98 @@
+/******************************************************************************
+ * File: ImageEditorController.h
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORCONTROLLER_H
+#define IMAGEEDITORCONTROLLER_H
+
+#include "GenericController.h"
+#include "ui/image/ImageEditorComponent.h"
+#include "ui/image/ImageEditorScene.h"
+
+namespace grndr
+{
+	class ImageEditor;
+	class ImageEditorView;
+	class ImageBuild;
+	class Layer;
+	class LayersListWidget;
+
+	class ImageEditorController : public GenericController, public ImageEditorComponent
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorController(ImageEditor* imageEditor);
+
+	public:
+		void assignUiComponents(ImageEditorView* view, LayersListWidget* layersList);
+
+	public:
+		void showImageBuild(ImageBuild* build);
+		void hideImageBuild() { showImageBuild(nullptr); }
+		ImageBuild* activeImageBuild() { return _activeImageBuild; }
+		const ImageBuild* activeImageBuild() const { return _activeImageBuild; }
+
+		ImageEditorScene* activeScene() { return _activeScene.get(); }
+		const ImageEditorScene* activeScene() const { return _activeScene.get(); }
+
+		void switchLayer(Layer* layer);
+		Layer* activeLayer() { return _activeLayer; }
+		const Layer* activeLayer() const { return _activeLayer; }		
+
+	public:
+		std::shared_ptr<Layer> createLayer(QString name = "") const;
+		void removeLayer(const Layer* layer);
+		void removeAllLayers();
+
+		std::shared_ptr<DraftItem> createDraftItem(DraftItemType type, Layer* layer = nullptr) const;
+		void removeDraftItem(const DraftItem* item) const;
+
+		void setLayerVisibility(Layer* layer, bool visible) const;		
+		void moveLayer(Layer* layer, bool up) const;
+		void renameLayer(Layer* layer, QString newName) const;
+		void renameImageBuildItem(ImageBuildItem* item, QString newName) const;
+
+		void setNodesPrimaryColor(QColor color, const std::vector<DraftItemNode*>& nodes) const;
+
+		void removeSelectedNodes() const;
+
+	signals:
+		void imageBuildSwitching(ImageBuild*);
+		void imageBuildSwitched(ImageBuild*);
+		void layerSwitching(Layer*);
+		void layerSwitched(Layer*);
+
+	protected:
+		virtual bool canCallControllerFunction() const override { return _activeImageBuild != nullptr; }
+		virtual void controllerFunctionCalled() const override;
+
+	private:
+		void validateLayerName(QString name, const Layer* layer = nullptr) const;
+
+		void connectLayerSignals(ImageBuild* imageBuild, bool connectSignals = true) const;
+		void connectLayerSignals(Layer* layer, bool connectSignals = true) const;
+
+	private slots:
+		void refreshEditorImage();
+
+		void layerCreated(const std::shared_ptr<Layer>& layer) const;
+		void layerRemoved(const std::shared_ptr<Layer>& layer) const;
+		void layerMoved(const std::shared_ptr<Layer>& layer, int indexOld, int indexNew) const;
+		void layerVisibilityChanged() const;
+
+		void draftItemCreated(const std::shared_ptr<DraftItem>& item) const;
+		void draftItemRemoved(const std::shared_ptr<DraftItem>& item) const;
+
+	private:
+		ImageEditorView* _view{nullptr};
+		LayersListWidget* _layersList{nullptr};		
+
+		ImageBuild* _activeImageBuild{nullptr};
+		std::unique_ptr<ImageEditorScene> _activeScene;
+		Layer* _activeLayer{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/controller/PipelineController.cpp b/Grinder/controller/PipelineController.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f45999e90bf00110e48cc5c0e70e4eb8c16c8cb0
--- /dev/null
+++ b/Grinder/controller/PipelineController.cpp
@@ -0,0 +1,221 @@
+/******************************************************************************
+ * File: PipelineController.cpp
+ * Date: 18.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PipelineController.h"
+#include "ui/graph/GraphView.h"
+#include "ui/graph/GraphBlockNode.h"
+#include "ui/graph/GraphConnectionNode.h"
+#include "ui/graph/GraphStyle.h"
+#include "pipeline/Pipeline.h"
+#include "pipeline/PipelineItem.h"
+#include "pipeline/PipelineExceptions.h"
+#include "util/UIUtils.h"
+
+PipelineController::PipelineController() : GenericController("Pipeline")
+{
+
+}
+
+void PipelineController::assignUiComponents(GraphView* view)
+{
+	if (!view)
+		throw std::invalid_argument{_EXCPT("view may not be null")};
+
+	_view = view;
+}
+
+void PipelineController::showPipeline(Pipeline* pipeline)
+{	
+	if (pipeline == _activePipeline)
+		return;
+
+	if (!_view)
+		throw std::runtime_error{_EXCPT("No view has been set")};
+
+	if (_activePipeline)	// No longer listen for events from the previous pipeline
+		disconnect(_activePipeline, nullptr, this, nullptr);
+
+	if (_activePipeline)
+		emit pipelineSwitching(_activePipeline);
+
+	_activePipeline = pipeline;
+
+	// Unset any currently active scene
+	_view->setGraphScene(nullptr);
+	_activeScene.reset(nullptr);
+
+	if (pipeline)	// Create a new scene for the given pipeline and show it
+	{
+		_activeScene = std::make_unique<GraphScene>(_activePipeline, _view, _view->sceneStyle().getViewStyle().defaultSceneSize);		
+		_activeScene->buildScene();
+
+		_view->setGraphScene(_activeScene.get());
+
+		// Listen for pipeline events
+		connect(_activePipeline, &Pipeline::blockCreated, this, &PipelineController::blockCreated);
+		connect(_activePipeline, &Pipeline::blockRemoved, this, &PipelineController::blockRemoved);
+		connect(_activePipeline, &Pipeline::connectionCreated, this, &PipelineController::connectionCreated);
+		connect(_activePipeline, &Pipeline::connectionRemoved, this, &PipelineController::connectionRemoved);
+	}
+
+	emit pipelineSwitched(_activePipeline);
+}
+
+std::shared_ptr<Block> PipelineController::createBlock(BlockType type, QString name) const
+{
+	return callControllerFunction("Creating a new block", [this](BlockType type, QString name) {
+		return _activePipeline->createBlock(type, name);
+	}, type, name);
+}
+
+void PipelineController::removeBlock(const Block* block) const
+{
+	if (block)
+	{
+		callControllerFunction("Removing a block", [this](const Block* block) {
+			_activePipeline->removeBlock(block);
+			return true;
+		}, block);
+	}
+}
+
+void PipelineController::disconnectPortFromAll(Port* port) const
+{
+	if (!port)
+		throw std::invalid_argument{_EXCPT("port may not be null")};
+
+	callControllerFunction("Disconnecting a port", [](Port* port) {
+		port->disconnectFromAll();
+		return true;
+	}, port);
+}
+
+void PipelineController::validateConnection(const Port* sourcePort, const Port* destPort) const
+{
+	if (sourcePort && destPort)	// Connection is made between an outgoing and an incoming port at least
+		sourcePort->verifyConnection(destPort);	// Check if the connection is valid
+	else
+		throw std::invalid_argument{_EXCPT("Must connect an outgoing to an incoming port")};
+}
+
+std::shared_ptr<Connection> PipelineController::createConnection(Port* sourcePort, Port* destPort) const
+{
+	if (!sourcePort || !destPort)
+		throw std::invalid_argument{_EXCPT("sourcePort and destPort may not be null")};
+
+	return callControllerFunction("Creating a new connection", [](Port* sourcePort, Port* destPort) {
+		return sourcePort->connectTo(destPort);
+	}, sourcePort, destPort);
+}
+
+void PipelineController::removeConnection(const Connection* connection) const
+{
+	if (connection)
+	{
+		callControllerFunction("Removing a connection", [](const Connection* connection) {
+			auto sourcePort = const_cast<Port*>(connection->sourcePort());	// Need to be able to modify the source port of the connection
+			sourcePort->removeConnection(connection);
+			return true;
+		}, connection);
+	}
+}
+
+void PipelineController::renameBlock(Block* block, QString newName) const
+{
+	callControllerFunction("Renaming a block", [this](Block* block, QString newName) {
+		validateBlockName(newName, block);
+		renamePipelineItem(block, newName);
+		return true;
+	}, block, newName);
+}
+
+void PipelineController::renamePipelineItem(PipelineItem* item, QString newName) const
+{
+	item->setName(newName);
+}
+
+void PipelineController::removeSelectedNodes() const
+{
+	if (_activeScene)
+	{
+		// Remove all selected connections first
+		auto selConnections = _activeScene->getNodes<GraphConnectionNode>(true);
+
+		for (const auto& node : selConnections)
+		{
+			if (auto connection = node->connection().lock())
+				removeConnection(connection.get());
+		}
+
+		// Remove all selected blocks next
+		auto selBlocks = _activeScene->getNodes<GraphBlockNode>(true);
+
+		for (const auto& node : selBlocks)
+		{
+			if (auto block = node->block().lock())
+				removeBlock(block.get());
+		}
+	}
+}
+
+BlockHierarchy PipelineController::createBlockHierarchy() const
+{		
+	if (_activePipeline)
+	{
+		return callControllerFunction("Calculating the graph layout", [this]() {
+			return BlockHierarchy{_activePipeline};
+		});
+	}
+	else
+		return BlockHierarchy{};
+}
+
+void PipelineController::controllerFunctionCalled() const
+{
+	// Redraw the scene after calling a controller function
+	if (_activeScene)
+		_activeScene->update();
+}
+
+void PipelineController::validateBlockName(QString name, const Block* block) const
+{
+	auto pipeline = block ? block->pipeline() : _activePipeline;
+
+	// Name must be non-empty and unique
+	if (!name.isEmpty())
+	{
+		auto existingBlock = pipeline->blocks().selectByName(name).get();
+
+		if (existingBlock && existingBlock != block)
+			throw PipelineException{pipeline, _EXCPT(QString{"A block with the name '%1' already exists"}.arg(name))};
+	}
+	else
+		throw PipelineException{pipeline, _EXCPT("The block name may not be empty")};
+}
+
+void PipelineController::blockCreated(const std::shared_ptr<Block>& block) const
+{
+	if (_activeScene)
+		_activeScene->createBlockNode(block);
+}
+
+void PipelineController::blockRemoved(const std::shared_ptr<Block>& block) const
+{
+	if (_activeScene)
+		_activeScene->removeBlockNode(block);
+}
+
+void PipelineController::connectionCreated(const std::shared_ptr<Connection>& connection) const
+{
+	if (_activeScene)
+		_activeScene->createConnectionNode(connection);
+}
+
+void PipelineController::connectionRemoved(const std::shared_ptr<Connection>& connection) const
+{
+	if (_activeScene)
+		_activeScene->removeConnectionNode(connection);
+}
diff --git a/Grinder/controller/PipelineController.h b/Grinder/controller/PipelineController.h
new file mode 100644
index 0000000000000000000000000000000000000000..98c36ffa0b69be4a4bb6eca4eb72d58e00145127
--- /dev/null
+++ b/Grinder/controller/PipelineController.h
@@ -0,0 +1,88 @@
+/******************************************************************************
+ * File: PipelineController.h
+ * Date: 18.1.2018
+ *****************************************************************************/
+
+#ifndef PIPELINECONTROLLER_H
+#define PIPELINECONTROLLER_H
+
+#include <memory>
+
+#include "GenericController.h"
+#include "pipeline/BlockType.h"
+#include "pipeline/BlockHierarchy.h"
+#include "ui/graph/GraphScene.h"
+
+namespace grndr
+{
+	class Pipeline;
+	class PipelineItem;
+	class Block;
+	class Connection;
+	class Port;
+	class GraphView;
+	class GraphScene;
+
+	class PipelineController : public GenericController
+	{
+		Q_OBJECT
+
+	public:
+		PipelineController();
+
+	public:
+		void assignUiComponents(GraphView* view);
+
+	public:
+		void showPipeline(Pipeline* pipeline);
+		void hidePipeline() { showPipeline(nullptr); }
+		Pipeline* activePipeline() { return _activePipeline; }
+		const Pipeline* activePipeline() const { return _activePipeline; }
+
+		GraphScene* activeScene() { return _activeScene.get(); }
+		const GraphScene* activeScene() const { return _activeScene.get(); }
+
+	public:
+		std::shared_ptr<Block> createBlock(BlockType type, QString name = "") const;
+		void removeBlock(const Block* block) const;
+
+		void disconnectPortFromAll(Port* port) const;
+
+		void validateConnection(const Port* sourcePort, const Port* destPort) const;
+		std::shared_ptr<Connection> createConnection(Port* sourcePort, Port* destPort) const;
+		void removeConnection(const Connection* connection) const;
+
+		void renameBlock(Block* block, QString newName) const;
+		void renamePipelineItem(PipelineItem* item, QString newName) const;
+
+		void removeSelectedNodes() const;
+
+	public:
+		BlockHierarchy createBlockHierarchy() const;
+
+	signals:
+		void pipelineSwitching(Pipeline*);
+		void pipelineSwitched(Pipeline*);
+
+	protected:
+		virtual bool canCallControllerFunction() const override { return _activePipeline != nullptr; }
+		virtual void controllerFunctionCalled() const override;
+
+	private:
+		void validateBlockName(QString name, const Block* block = nullptr) const;
+
+	private slots:
+		void blockCreated(const std::shared_ptr<Block>& block) const;
+		void blockRemoved(const std::shared_ptr<Block>& block) const;
+		void connectionCreated(const std::shared_ptr<Connection>& connection) const;
+		void connectionRemoved(const std::shared_ptr<Connection>& connection) const;
+
+	private:
+		GraphView* _view{nullptr};
+
+		Pipeline* _activePipeline{nullptr};
+		std::unique_ptr<GraphScene> _activeScene;
+	};
+}
+
+#endif
diff --git a/Grinder/controller/ProjectController.cpp b/Grinder/controller/ProjectController.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f8efed3f663b65658a36819c0249cd608796b33d
--- /dev/null
+++ b/Grinder/controller/ProjectController.cpp
@@ -0,0 +1,323 @@
+/******************************************************************************
+ * File: ProjectController.cpp
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ProjectController.h"
+#include "PipelineController.h"
+#include "core/GrinderApplication.h"
+#include "project/Project.h"
+#include "project/ProjectExceptions.h"
+#include "project/serialization/JsonSettingsCodec.h"
+#include "project/serialization/ProjectSerializer.h"
+#include "ui/mainwnd/LabelsListWidget.h"
+#include "ui/mainwnd/ImageReferencesListWidget.h"
+#include "util/ImageUtils.h"
+#include "util/TemporaryStatusMessage.h"
+#include "util/UIUtils.h"
+
+ProjectController::ProjectController(Project* project) : GenericController("Project"),
+	_project{project}
+{
+	if (!project)
+		throw std::invalid_argument{_EXCPT("project may not be null")};
+
+	// Listen for project events
+	connect(_project, &Project::labelCreated, this, &ProjectController::labelCreated);
+	connect(_project, &Project::labelRemoved, this, &ProjectController::labelRemoved);
+	connect(_project, &Project::imageReferenceCreated, this, &ProjectController::imageReferenceCreated);
+	connect(_project, &Project::imageReferenceRemoved, this, &ProjectController::imageReferenceRemoved);
+}
+
+void ProjectController::assignUiComponents(LabelsListWidget* labelsList, ImageReferencesListWidget* imageRefsList)
+{
+	if (!labelsList)
+		throw std::invalid_argument{_EXCPT("labelsList may not be null")};
+
+	if (!imageRefsList)
+		throw std::invalid_argument{_EXCPT("imageRefsList may not be null")};
+
+	_labelsList = labelsList;
+	_imageReferencesList = imageRefsList;
+}
+
+void ProjectController::clearProject(bool createDefaultLabel)
+{
+	emit projectClearing();
+
+	_suppressErrors = true;
+
+	// Before clearing the project, make sure that no label or image reference is active
+	switchLabel(nullptr);
+	switchImageReference(nullptr);
+
+	_project->clear();
+
+	if (createDefaultLabel)
+	{
+		if (auto label = grinder()->projectController().createLabel("Default label"))
+			switchLabel(label.get());
+	}
+
+	_suppressErrors = false;
+
+	setCurrentProjectFile("");
+	emit projectCleared();
+}
+
+void ProjectController::loadProject(QString fileName)
+{
+	emit projectLoading(fileName);
+
+	bool result = callControllerFunction("Loading a project", [this](QString fileName) {
+		TemporaryStatusMessage tmpStatusMessage{QString{"Loading project '%1'..."}.arg(fileName)};
+
+		clearProject();
+
+		JsonSettingsCodec codec;
+		SettingsContainer projectSettings;
+		codec.loadContainer(projectSettings, fileName);
+
+		ProjectSerializer serializer{_project};
+		serializer.deserializeProject(projectSettings);
+
+		return true;
+	}, fileName);
+
+	if (result)
+	{
+		setCurrentProjectFile(fileName);
+		emit projectLoaded(fileName);
+
+		// If no label is active, activate the first one after loading
+		if (!_activeLabel && !_project->labels().empty())
+			switchLabel(_project->labels().front().get());
+
+		// If no image reference is active, activate the first one after loading
+		if (!_activeImageReference && !_project->imageReferences().empty())
+			switchImageReference(_project->imageReferences().front().get());
+	}
+	else
+	{
+		clearProject();
+		throw ProjectException{_project, _EXCPT(QString{"Failed to load project '%1'"}.arg(fileName))};
+	}
+}
+
+void ProjectController::saveProject(QString fileName)
+{
+	emit projectSaving(fileName);
+
+	bool result = callControllerFunction("Saving the current project", [this](QString fileName) {
+		TemporaryStatusMessage tmpStatusMessage{QString{"Saving project to '%1'..."}.arg(fileName)};
+
+		ProjectSerializer serializer{_project};
+		auto projectSettings = serializer.serializeProject();
+
+		JsonSettingsCodec codec;
+		codec.saveContainer(projectSettings, fileName);
+
+		return true;
+	}, fileName);
+
+	if (result)
+	{
+		setCurrentProjectFile(fileName);
+		emit projectSaved(fileName);
+	}
+	else
+		throw ProjectException{_project, _EXCPT(QString{"Failed to save the current project to '%1'"}.arg(fileName))};
+}
+
+bool ProjectController::isProjectDirty() const
+{
+	// If there are no current settings, the application has just been started
+	if (_currentProjectSettings.isEmpty())
+		return false;
+
+	// Compare the current project data to the saved data; if they differ, the project has been changed
+	ProjectSerializer serializer{_project};
+	auto projectSettings = serializer.serializeProject();
+
+	return projectSettings != _currentProjectSettings;
+}
+
+void ProjectController::switchLabel(Label* label)
+{
+	if (label == _activeLabel)
+		return;
+
+	if (_activeLabel)
+		emit labelSwitching(_activeLabel);
+
+	_activeLabel = label;
+
+	// Unset any currently active pipeline
+	grinder()->pipelineController().hidePipeline();
+
+	if (label)	// Show the pipeline associated with the label
+		grinder()->pipelineController().showPipeline(label->pipeline());
+
+	emit labelSwitched(_activeLabel);
+}
+
+void ProjectController::switchImageReference(ImageReference* imageRef)
+{
+	if (imageRef == _activeImageReference)
+		return;
+
+	if (_activeImageReference)
+		emit imageReferenceSwitching(_activeImageReference);
+
+	_activeImageReference = imageRef;
+	emit imageReferenceSwitched(_activeImageReference);
+}
+
+std::shared_ptr<Label> ProjectController::createLabel(QString name) const
+{
+	return callControllerFunction("Creating a new label", [this](QString name) {
+		validateLabelName(name);
+		return _project->createLabel(name);
+	}, name);
+}
+
+void ProjectController::removeLabel(const Label* label)
+{
+	if (label)
+	{
+		// If removing the active label, hide it first
+		if (_activeLabel == label)
+			switchLabel(nullptr);
+
+		callControllerFunction("Removing a label", [this](const Label* label) {
+			_project->removeLabel(label);
+			return true;
+		}, label);
+	}
+}
+
+void ProjectController::removeAllLabels()
+{
+	auto labels = _project->labels();
+
+	for (const auto& label : labels)
+		removeLabel(label.get());
+}
+
+void ProjectController::renameLabel(Label* label, QString newName) const
+{
+	callControllerFunction("Renaming a label", [this](Label* label, QString newName) {
+		validateLabelName(newName, label);
+		label->setName(newName);
+		return true;
+	}, label, newName);
+}
+
+std::shared_ptr<ImageReference> ProjectController::createImageReference(QString imagePath) const
+{
+	TemporaryStatusMessage tmpStatusMessage;
+
+	return callControllerFunction("Creating an image reference", [this, &tmpStatusMessage](QString imagePath) {
+		tmpStatusMessage.setMessage(QString{"Adding image '%1'..."}.arg(imagePath));
+		return _project->createImageReference(imagePath);
+	}, imagePath);
+}
+
+std::vector<std::shared_ptr<ImageReference>> ProjectController::createImageReferences(QStringList imagePaths, bool ignoreUnknownFiletypes) const
+{	
+	static auto imageFormats = ImageUtils::getSupportedFormats();
+
+	std::vector<std::shared_ptr<ImageReference>> images;
+
+	for (auto imagePath : imagePaths)
+	{
+		if (ignoreUnknownFiletypes)
+		{
+			QFileInfo fileInfo{imagePath};
+
+			if (!imageFormats.contains(fileInfo.suffix(), Qt::CaseInsensitive))
+				continue;
+		}
+
+		images.push_back(createImageReference(imagePath));
+	}
+
+	return images;
+}
+
+void ProjectController::removeImageReference(const ImageReference* imageRef)
+{
+	if (imageRef)
+	{
+		// If removing the active image reference, unset it first
+		if (_activeImageReference == imageRef)
+			switchImageReference(nullptr);
+
+		callControllerFunction("Removing an image reference", [this](const ImageReference* imageRef) {
+			_project->removeImageReference(imageRef);
+			return true;
+		}, imageRef);
+	}
+}
+
+void ProjectController::removeAllImageReferences()
+{
+	auto imageRefs = _project->imageReferences();
+
+	for (const auto& imageRef : imageRefs)
+		removeImageReference(imageRef.get());
+}
+
+void ProjectController::validateLabelName(QString name, const Label* label) const
+{
+	// Name must be non-empty and unique
+	if (!name.isEmpty())
+	{
+		auto existingLabel = _project->labels().selectByName(name).get();
+
+		if (existingLabel && existingLabel != label)
+			throw ProjectException{_project, _EXCPT(QString{"A label with the name '%1' already exists"}.arg(name))};
+	}
+	else
+		throw ProjectException{_project, _EXCPT("The label name may not be empty")};
+}
+
+void ProjectController::updateCurrentProjectData()
+{
+	ProjectSerializer serializer{_project};
+	_currentProjectSettings = serializer.serializeProject();
+}
+
+void ProjectController::labelCreated(const std::shared_ptr<Label>& label) const
+{
+	if (_labelsList)
+		_labelsList->addObject(label.get());
+}
+
+void ProjectController::labelRemoved(const std::shared_ptr<Label>& label) const
+{
+	if (_labelsList)
+		_labelsList->removeObject(label.get());
+}
+
+void ProjectController::imageReferenceCreated(const std::shared_ptr<ImageReference>& imageRef) const
+{
+	if (_imageReferencesList)
+		_imageReferencesList->addObject(imageRef.get());
+}
+
+void ProjectController::imageReferenceRemoved(const std::shared_ptr<ImageReference>& imageRef) const
+{
+	if (_imageReferencesList)
+		_imageReferencesList->removeObject(imageRef.get());
+}
+
+void ProjectController::setCurrentProjectFile(QString fileName)
+{
+	_currentProjectFile = fileName;
+	emit currentProjectFileChanged(fileName);
+
+	// Whenever the project file has changed, the current project data needs to be updated, so do it here
+	updateCurrentProjectData();
+}
diff --git a/Grinder/controller/ProjectController.h b/Grinder/controller/ProjectController.h
new file mode 100644
index 0000000000000000000000000000000000000000..e3cad83cd2f01ed3c3e04150b648ddf8f1bc110e
--- /dev/null
+++ b/Grinder/controller/ProjectController.h
@@ -0,0 +1,102 @@
+/******************************************************************************
+ * File: ProjectController.h
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#ifndef PROJECTCONTROLLER_H
+#define PROJECTCONTROLLER_H
+
+#include <memory>
+
+#include "GenericController.h"
+#include "project/serialization/SettingsContainer.h"
+
+namespace grndr
+{
+	class Project;
+	class PipelineController;
+	class Label;
+	class LabelsListWidget;
+	class ImageReference;
+	class ImageReferencesListWidget;
+
+	class ProjectController : public GenericController
+	{
+		Q_OBJECT
+
+	public:
+		ProjectController(Project* project);
+
+	public:
+		void assignUiComponents(LabelsListWidget* labelsList, ImageReferencesListWidget* imageRefsList);
+
+	public:
+		void clearProject(bool createDefaultLabel = false);
+		void loadProject(QString fileName);
+		void saveProject(QString fileName);
+
+		QString getCurrentProjectFile() const { return _currentProjectFile; }
+		bool isProjectDirty() const;
+
+	public:
+		void switchLabel(Label* label);
+		Label* activeLabel() { return _activeLabel; }
+		const Label* activeLabel() const { return _activeLabel; }
+
+		void switchImageReference(ImageReference* imageRef);
+		ImageReference* activeImageReference() { return _activeImageReference; }
+		const ImageReference* activeImageReference() const { return _activeImageReference; }
+
+	public:
+		std::shared_ptr<Label> createLabel(QString name = "") const;
+		void removeLabel(const Label* label);
+		void removeAllLabels();
+		void renameLabel(Label* label, QString newName) const;
+
+		std::shared_ptr<ImageReference> createImageReference(QString imagePath) const;
+		std::vector<std::shared_ptr<ImageReference>> createImageReferences(QStringList imagePaths, bool ignoreUnknownFiletypes = true) const;
+		void removeImageReference(const ImageReference* imageRef);
+		void removeAllImageReferences();
+
+	signals:
+		void projectClearing();
+		void projectCleared();
+		void projectLoading(QString);
+		void projectLoaded(QString);
+		void projectSaving(QString);
+		void projectSaved(QString);
+
+		void currentProjectFileChanged(QString fileName);
+
+		void labelSwitching(Label*);
+		void labelSwitched(Label*);
+		void imageReferenceSwitching(ImageReference*);
+		void imageReferenceSwitched(ImageReference*);
+
+	private:
+		void validateLabelName(QString name, const Label* label = nullptr) const;
+
+		void setCurrentProjectFile(QString fileName);
+		void updateCurrentProjectData();
+
+	private slots:
+		void labelCreated(const std::shared_ptr<Label>& label) const;
+		void labelRemoved(const std::shared_ptr<Label>& label) const;
+
+		void imageReferenceCreated(const std::shared_ptr<ImageReference>& imageRef) const;
+		void imageReferenceRemoved(const std::shared_ptr<ImageReference>& imageRef) const;
+
+	private:
+		Project* _project{nullptr};
+		QString _currentProjectFile{""};
+		SettingsContainer _currentProjectSettings;
+
+		LabelsListWidget* _labelsList{nullptr};
+		ImageReferencesListWidget* _imageReferencesList{nullptr};
+
+		Label* _activeLabel{nullptr};
+		ImageReference* _activeImageReference{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/core/GrinderApplication.cpp b/Grinder/core/GrinderApplication.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9099ea5a4422239f344576406ed7b67c5e66725f
--- /dev/null
+++ b/Grinder/core/GrinderApplication.cpp
@@ -0,0 +1,80 @@
+/******************************************************************************
+ * File: GrinderApplication.cpp
+ * Date: 11.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GrinderApplication.h"
+#include "Version.h"
+#include "pipeline/BlockCatalog.h"
+#include "image/DraftItemCatalog.h"
+#include "ui/StyleSheet.h"
+#include "res/Resources.h"
+
+GrinderApplication* GrinderApplication::s_appInstance = nullptr;
+
+QFont GrinderApplication::boldFont(QWidget* widget)
+{
+	QFont font;
+
+	if (widget)
+		font = widget->font();
+
+	font.setBold(true);
+	return font;
+}
+
+GrinderApplication::GrinderApplication(int& argc, char** argv, int flags) : QApplication(argc, argv, flags),
+	_settings{GRNDR_INFO_COMPANY, GRNDR_INFO_TITLE}, _projectController{&_project}, _engineController{&_engine}, _imageEditorManager{&_pipelineManager}
+{
+	s_appInstance = this;
+
+	// Basic app information
+	setApplicationName(GRNDR_INFO_TITLE);
+	setApplicationVersion(GetVersionString(true));
+	setOrganizationName(GRNDR_INFO_COMPANY);
+	setOrganizationDomain(GRNDR_INFO_WEBSITE);
+
+	setWindowIcon(QIcon{FILE_ICON_MAIN});
+
+	// Apply font quality fixes for some OSes
+#if defined(Q_OS_LINUX)
+	QFont fnt = font();
+
+	fnt.setStyleStrategy(QFont::StyleStrategy(QFont::PreferAntialias|QFont::PreferQuality));
+	setFont(fnt);
+#else
+	fixFontQuality("QMessageBox");
+	fixFontQuality("QMenuBar");
+	fixFontQuality("QMenu");
+	fixFontQuality("QStatusBar");
+#endif	
+
+	// Apply global stylesheet
+	setStyleSheet(StyleSheet::loadStyleSheet(FILE_STYLESHEET_GLOBAL));
+
+	// Register standard types in all catalogs
+	BlockCatalog::registerStandardBlocks();
+	DraftItemCatalog::registerStandardDraftItems();
+}
+
+GrinderApplication::~GrinderApplication()
+{
+	s_appInstance = nullptr;
+}
+
+int GrinderApplication::run()
+{
+	_mainWindow = std::make_unique<GrinderWindow>();	
+	_mainWindow->show();
+
+	return exec();
+}
+
+void GrinderApplication::fixFontQuality(const char* ctrlClass)
+{
+	QFont fnt = font(ctrlClass);
+
+	fnt.setStyleStrategy(QFont::StyleStrategy(QFont::PreferAntialias|QFont::PreferQuality));
+	setFont(fnt, ctrlClass);
+}
diff --git a/Grinder/core/GrinderApplication.h b/Grinder/core/GrinderApplication.h
new file mode 100644
index 0000000000000000000000000000000000000000..0ecb9519cfadfb0e966baf0bfa21f3e683be240d
--- /dev/null
+++ b/Grinder/core/GrinderApplication.h
@@ -0,0 +1,89 @@
+/******************************************************************************
+ * File: GrinderApplication.h
+ * Date: 11.1.2018
+ *****************************************************************************/
+
+#ifndef GRINDERAPPLICATION_H
+#define GRINDERAPPLICATION_H
+
+#include <QApplication>
+
+#include "GrinderSettings.h"
+#include "pipeline/PipelineManager.h"
+#include "project/Project.h"
+#include "engine/Engine.h"
+#include "controller/PipelineController.h"
+#include "controller/ProjectController.h"
+#include "controller/EngineController.h"
+#include "ui/mainwnd/GrinderWindow.h"
+#include "ui/image/ImageEditorManager.h"
+
+namespace grndr
+{
+	class PipelineManager;
+
+	class GrinderApplication : public QApplication
+	{
+		static GrinderApplication* s_appInstance;
+
+	public:
+		static GrinderApplication* instance() { Q_ASSERT(s_appInstance != nullptr); return s_appInstance; }
+
+		static QFont boldFont(QWidget* widget = nullptr);
+
+	public:
+		GrinderApplication(int &argc, char **argv, int flags = ApplicationFlags);
+		virtual ~GrinderApplication();
+
+	public:
+		int run();
+
+	public:		
+		GrinderSettings& settings() { return _settings; }
+		const GrinderSettings& settings() const { return _settings; }
+
+		PipelineManager& pipelineManager() { return _pipelineManager; }
+		const PipelineManager& pipelineManager() const { return _pipelineManager; }
+		PipelineController& pipelineController() { return _pipelineController; }
+		const PipelineController& pipelineController() const { return _pipelineController; }
+
+		Project& project() { return _project; }
+		const Project& project() const { return _project; }
+		ProjectController& projectController() { return _projectController; }
+		const ProjectController& projectController() const { return _projectController; }
+
+		Engine& engine() { return _engine; }
+		const Engine& engine() const { return _engine; }
+		EngineController& engineController() { return _engineController; }
+		const EngineController& engineController() const { return _engineController; }
+
+		GrinderWindow* mainWindow() { return _mainWindow.get(); }
+		const GrinderWindow* mainWindow() const { return _mainWindow.get(); }
+
+		ImageEditorManager& imageEditorManager() { return _imageEditorManager; }
+		const ImageEditorManager& imageEditorManager() const { return _imageEditorManager; }
+
+	private:
+		void fixFontQuality(const char* ctrlClass);
+
+	private:
+		GrinderSettings _settings;
+
+		PipelineManager _pipelineManager;
+		PipelineController _pipelineController;
+
+		Project _project;
+		ProjectController _projectController;
+
+		Engine _engine;
+		EngineController _engineController;
+
+		std::unique_ptr<GrinderWindow> _mainWindow;
+
+		ImageEditorManager _imageEditorManager;
+	};
+
+	inline GrinderApplication* grinder() { return GrinderApplication::instance(); }
+}
+
+#endif
diff --git a/Grinder/core/GrinderExceptions.cpp b/Grinder/core/GrinderExceptions.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..1037a3ad767044aeb7a4faa27bb0a4759cb6f276
--- /dev/null
+++ b/Grinder/core/GrinderExceptions.cpp
@@ -0,0 +1,39 @@
+/******************************************************************************
+ * File: ExceptionHandling.cpp
+ * Date: 17.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GrinderExceptions.h"
+
+QString grndr::GetExceptionMessage(QString what, QString* func, QString* file, QString* line)
+{
+	QStringList tokens = what.split("|");
+
+	if (func && tokens.size() >= 1)
+		*func = tokens[0];
+
+	if (file && tokens.size() >= 2)
+		*file = tokens[1];
+
+	if (line && tokens.size() >= 3)
+		*line = tokens[2];
+
+	return tokens.last();
+}
+
+void grndr::ShowExceptionMessage(QString what)
+{
+	QString str;
+
+	if (!what.isEmpty())
+	{
+		QString func, file, line;
+		what = GetExceptionMessage(what, &func, &file, &line);
+		str = QString{"%1<br><em>%2 (%3:%4)</em>"}.arg(what).arg(func).arg(file).arg(line);
+	}
+	else
+		str = "Unknown exception";
+
+	QMessageBox::critical(nullptr, "Uncaught exception", QString{"<b>An unhandled exception occurred:</b><br><br>%1<br><br>The application will now close."}.arg(str));
+}
diff --git a/Grinder/core/GrinderExceptions.h b/Grinder/core/GrinderExceptions.h
new file mode 100644
index 0000000000000000000000000000000000000000..a85bb8bee675c465b47749a3bbe95715e3da5ef1
--- /dev/null
+++ b/Grinder/core/GrinderExceptions.h
@@ -0,0 +1,26 @@
+/******************************************************************************
+ * File: ExceptionHandling.h
+ * Date: 17.1.2018
+ *****************************************************************************/
+
+#ifndef EXCEPTIONHANDLING_H
+#define EXCEPTIONHANDLING_H
+
+#include <QString>
+#include <stdexcept>
+
+namespace grndr
+{
+	class GrinderException : public std::runtime_error
+	{
+	public:
+		GrinderException(QString what) : std::runtime_error{what.toStdString()} { }
+	};
+
+	QString GetExceptionMessage(QString what, QString* func = nullptr, QString* file = nullptr, QString* line = nullptr);
+	void ShowExceptionMessage(QString what);
+}
+
+#define _EXCPT(what)	QString{"%1|%2|%3|%4"}.arg(__func__).arg(__FILE__).arg(__LINE__).arg(what).toLatin1().data()
+
+#endif
diff --git a/Grinder/core/GrinderSettings.cpp b/Grinder/core/GrinderSettings.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a484dbee4cf0bd6ea27d90d6c6bc1a7d9d5535ad
--- /dev/null
+++ b/Grinder/core/GrinderSettings.cpp
@@ -0,0 +1,145 @@
+/******************************************************************************
+ * File: GrinderSettings.cpp
+ * Date: 16.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GrinderSettings.h"
+
+GrinderSettings::GrinderSettings(QString organization, QString application) : QSettings(organization, application)
+{
+	loadSettings();
+}
+
+void GrinderSettings::loadSettings()
+{
+	loadGeneralOptions();
+	loadStartupOptions();
+	loadProjectFilesMRUList();
+}
+
+void GrinderSettings::saveSettings(bool notify)
+{
+	saveGeneralOptions();
+	saveStartupOptions();
+	saveProjectFilesMRUList();
+
+	if (notify)
+		emit settingsChanged();
+}
+
+void GrinderSettings::getWindowState(QString name, QMainWindow* window)
+{
+	beginGroup("WindowStates");
+	beginGroup(name);
+	window->restoreGeometry(value("Geometry").toByteArray());
+	window->restoreState(value("State").toByteArray());
+	endGroup();
+	endGroup();
+}
+
+void GrinderSettings::setWindowState(QString name, const QMainWindow* window)
+{
+	beginGroup("WindowStates");
+	beginGroup(name);
+	setValue("Geometry", window->saveGeometry());
+	setValue("State", window->saveState());
+	endGroup();
+	endGroup();
+}
+
+void GrinderSettings::getSplitterState(QString name, QSplitter* splitter)
+{
+	beginGroup("SplitterStates");
+	beginGroup(name);
+	splitter->restoreState(value("State").toByteArray());
+	endGroup();
+	endGroup();
+}
+
+void GrinderSettings::setSplitterState(QString name, const QSplitter* splitter)
+{
+	beginGroup("SplitterStates");
+	beginGroup(name);
+	setValue("State", splitter->saveState());
+	endGroup();
+	endGroup();
+}
+
+QString GrinderSettings::getFileDialogDir(QString dlgName)
+{
+	beginGroup("FileDialogDirs");
+	QString dir = value(dlgName, "").toString();
+	endGroup();
+
+	return dir;
+}
+
+void GrinderSettings::setFileDialogDir(QString dlgName, QString dir)
+{
+	beginGroup("FileDialogDirs");
+	setValue(dlgName, dir);
+	endGroup();
+}
+
+void GrinderSettings::loadGeneralOptions()
+{
+	beginGroup("GeneralOptions");
+	_generalOptions.askOnUnsavedChanges = value("AskOnUnsavedChanges", true).toBool();
+	endGroup();
+}
+
+void GrinderSettings::saveGeneralOptions()
+{
+	beginGroup("GeneralOptions");
+	setValue("AskOnUnsavedChanges", _generalOptions.askOnUnsavedChanges);
+	endGroup();
+}
+
+void GrinderSettings::loadStartupOptions()
+{
+	beginGroup("StartupOptions");
+	_startupOptions.loadLastProject = value("LoadLastProject", false).toBool();
+	_startupOptions.lastProjectFile = value("LastProjectFile", "").toString();
+	endGroup();
+}
+
+void GrinderSettings::saveStartupOptions()
+{
+	beginGroup("StartupOptions");
+	setValue("LoadLastProject", _startupOptions.loadLastProject);
+	setValue("LastProjectFile", _startupOptions.lastProjectFile);
+	endGroup();
+}
+
+void GrinderSettings::loadImageEditorOptions()
+{
+	beginGroup("StartupOptions");
+	_imageEditorOptions.groupIntoTabs = value("GroupIntoTabs", true).toBool();
+	_imageEditorOptions.startUndocked = value("StartUndocked", false).toBool();
+	_imageEditorOptions.autoFitToWindow = value("AutoFitToWindow", false).toBool();
+	endGroup();
+}
+
+void GrinderSettings::saveImageEditorOptions()
+{
+	beginGroup("StartupOptions");
+	setValue("GroupIntoTabs", _imageEditorOptions.groupIntoTabs);
+	setValue("StartUndocked", _imageEditorOptions.startUndocked );
+	setValue("AutoFitToWindow", _imageEditorOptions.autoFitToWindow);
+	endGroup();
+}
+
+void GrinderSettings::loadProjectFilesMRUList()
+{
+	beginGroup("MRULists");
+	_recentProjects = value("ProjectFiles").toStringList();
+	endGroup();
+}
+
+void GrinderSettings::saveProjectFilesMRUList()
+{
+	beginGroup("MRULists");
+	setValue("ProjectFiles", static_cast<QStringList>(_recentProjects));
+	endGroup();
+}
diff --git a/Grinder/core/GrinderSettings.h b/Grinder/core/GrinderSettings.h
new file mode 100644
index 0000000000000000000000000000000000000000..45b843b02e9f2498d669fd46353e8431e3bec29a
--- /dev/null
+++ b/Grinder/core/GrinderSettings.h
@@ -0,0 +1,95 @@
+/******************************************************************************
+ * File: GrinderSettings.h
+ * Date: 16.2.2018
+ *****************************************************************************/
+
+#ifndef GRINDERSETTINGS_H
+#define GRINDERSETTINGS_H
+
+#include <QSettings>
+
+#include "common/MRUStringList.h"
+
+namespace grndr
+{
+	class GrinderSettings : public QSettings
+	{
+		Q_OBJECT
+
+	public:
+		struct GeneralOptions
+		{
+			bool askOnUnsavedChanges{true};
+		};
+
+		struct StartupOptions
+		{
+			bool loadLastProject{false};
+			QString lastProjectFile{""};
+		};
+
+		struct ImageEditorOptions
+		{
+			bool groupIntoTabs{true};
+			bool startUndocked{false};
+
+			bool autoFitToWindow{false};
+		};
+
+	public:
+		GrinderSettings(QString organization, QString application);
+		virtual ~GrinderSettings() { saveSettings(false); }
+
+	public:
+		void loadSettings();
+		void saveSettings(bool notify = true);
+
+	public:
+		GeneralOptions& generalOptions() { return _generalOptions; }
+		const GeneralOptions& generalOptions() const { return _generalOptions; }
+
+		StartupOptions& startupOptions() { return _startupOptions; }
+		const StartupOptions& startupOptions() const { return _startupOptions; }
+
+		ImageEditorOptions& imageEditorOptions() { return _imageEditorOptions; }
+		const ImageEditorOptions& imageEditorOptions() const { return _imageEditorOptions; }
+
+		MRUStringList& recentProjects() { return _recentProjects; }
+		const MRUStringList& recentProjects() const { return _recentProjects; }
+
+	public:
+		void getWindowState(QString name, QMainWindow* window);
+		void setWindowState(QString name, const QMainWindow* window);
+
+		void getSplitterState(QString name, QSplitter* splitter);
+		void setSplitterState(QString name, const QSplitter* splitter);
+
+		QString getFileDialogDir(QString dlgName);
+		void setFileDialogDir(QString dlgName, QString dir);
+
+	signals:
+		void settingsChanged();
+
+	private:
+		void loadGeneralOptions();
+		void saveGeneralOptions();
+
+		void loadStartupOptions();
+		void saveStartupOptions();
+
+		void loadImageEditorOptions();
+		void saveImageEditorOptions();
+
+		void loadProjectFilesMRUList();
+		void saveProjectFilesMRUList();
+
+	private:
+		GeneralOptions _generalOptions;
+		StartupOptions _startupOptions;
+		ImageEditorOptions _imageEditorOptions;
+
+		MRUStringList _recentProjects{false, true, 8};
+	};
+}
+
+#endif
diff --git a/Grinder/engine/Engine.cpp b/Grinder/engine/Engine.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..b2578f4254703e747c76bf82a5d4c4ee62763f23
--- /dev/null
+++ b/Grinder/engine/Engine.cpp
@@ -0,0 +1,73 @@
+/******************************************************************************
+ * File: Engine.cpp
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Engine.h"
+#include "EngineExecutionContext.h"
+#include "EngineExceptions.h"
+#include "Processor.h"
+#include "pipeline/Pipeline.h"
+#include "project/Label.h"
+
+void Engine::executeLabel(Label* label, ExecutionMode mode) const
+{
+	executeLabelEx(label, nullptr, mode);
+}
+
+cv::Mat Engine::executeLabelEx(Label* label, const Port* requestPort, ExecutionMode mode) const
+{
+	cv::Mat portData;
+
+	try {
+		// We first need a block hierarchy to execute
+		BlockHierarchy blockHierarchy{label->pipeline()};
+
+		if (!blockHierarchy.empty())
+		{
+			EngineExecutionContext ctx{this, label, mode};
+
+			for (unsigned int currentLevel = 0; currentLevel < blockHierarchy.size(); ++currentLevel)
+			{
+				executeHierarchyLevel(blockHierarchy.at(currentLevel), ctx, requestPort ? requestPort->block() : nullptr);
+
+				// Clean up unused data in the context
+				ctx.purge(blockHierarchy, currentLevel);
+
+				// If data at a specific port was requested, copy it to portData
+				if (requestPort)
+				{
+					if (auto dataBlob = ctx.contextEntry(requestPort))
+						portData = dataBlob->getMatrix();
+				}
+
+				if (ctx.wasAborted())
+					break;
+			}
+		}
+	} catch (std::exception& e) {
+		// Forward any exceptions as an EngineException
+		throw EngineException{this, _EXCPT(QString{"Executing '%2' failed: %1"}.arg(e.what()).arg(label->getName()))};
+	}
+
+	return portData;
+}
+
+void Engine::executeHierarchyLevel(const BlockHierarchy::HierarchyLevel& level, EngineExecutionContext& ctx, const Block* finalBlock) const
+{
+	for (const auto& block : level)
+	{
+		// Create a processor for the block and execute it
+		if (auto processor = block->createProcessor())
+			processor->execute(ctx);
+		else
+			throw EngineException{this, _EXCPT(QString{"No processor could be created for block '%1'"}.arg(block->getName()))};
+
+		if (block == finalBlock)
+		{
+			ctx.abortProcessing();
+			break;
+		}
+	}
+}
diff --git a/Grinder/engine/Engine.h b/Grinder/engine/Engine.h
new file mode 100644
index 0000000000000000000000000000000000000000..6d00af259296af8578cc6d8616ab07a908f2af71
--- /dev/null
+++ b/Grinder/engine/Engine.h
@@ -0,0 +1,42 @@
+/******************************************************************************
+ * File: Engine.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#ifndef ENGINE_H
+#define ENGINE_H
+
+#include <QObject>
+#include <opencv2/core.hpp>
+
+#include "pipeline/BlockHierarchy.h"
+
+namespace grndr
+{
+	class EngineExecutionContext;
+	class Label;
+	class Block;
+	class Port;
+
+	class Engine : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		enum class ExecutionMode
+		{
+			Execute,
+			View,
+			Write,
+		};
+
+	public:
+		void executeLabel(Label* label, ExecutionMode mode) const;
+		cv::Mat executeLabelEx(Label* label, const Port* requestPort, ExecutionMode mode) const;
+
+	private:
+		void executeHierarchyLevel(const BlockHierarchy::HierarchyLevel& level, EngineExecutionContext& ctx, const Block* finalBlock) const;
+	};
+}
+
+#endif
diff --git a/Grinder/engine/EngineExceptions.cpp b/Grinder/engine/EngineExceptions.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..7218f21d3353121dd57aaa314c59a2efa25f5941
--- /dev/null
+++ b/Grinder/engine/EngineExceptions.cpp
@@ -0,0 +1,20 @@
+/******************************************************************************
+ * File: EngineExceptions.cpp
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "EngineExceptions.h"
+#include "Processor.h"
+
+EngineException::EngineException(const Engine* engine, QString what) : GrinderException(what),
+	_engine{engine}
+{
+
+}
+
+ProcessorException::ProcessorException(const ProcessorBase* processor, QString what) : EngineException(processor ? processor->engine() : nullptr, what),
+  _processor{processor}
+{
+
+}
diff --git a/Grinder/engine/EngineExceptions.h b/Grinder/engine/EngineExceptions.h
new file mode 100644
index 0000000000000000000000000000000000000000..eaeeb7548e2545d121bf3c18bfebe1b2fe01d978
--- /dev/null
+++ b/Grinder/engine/EngineExceptions.h
@@ -0,0 +1,43 @@
+/******************************************************************************
+ * File: EngineExceptions.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#ifndef ENGINEEXCEPTIONS_H
+#define ENGINEEXCEPTIONS_H
+
+#include <QString>
+
+#include "core/GrinderExceptions.h"
+
+namespace grndr
+{
+	class Engine;
+	class ProcessorBase;
+
+	class EngineException : public GrinderException
+	{
+	public:
+		EngineException(const Engine* engine, QString what);
+
+	public:
+		const Engine* engine() const { return _engine; }
+
+	protected:
+		const Engine* _engine{nullptr};
+	};
+
+	class ProcessorException : public EngineException
+	{
+	public:
+		ProcessorException(const ProcessorBase* processor, QString what);
+
+	public:
+		const ProcessorBase* processor() const { return _processor; }
+
+	protected:
+		const ProcessorBase* _processor{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/engine/EngineExecutionContext.cpp b/Grinder/engine/EngineExecutionContext.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d2545a40d79e84f7fa3f7c6ae3c9ba5b334be81c
--- /dev/null
+++ b/Grinder/engine/EngineExecutionContext.cpp
@@ -0,0 +1,81 @@
+/******************************************************************************
+ * File: EngineExecutionContext.cpp
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "EngineExecutionContext.h"
+#include "pipeline/Block.h"
+#include "pipeline/Port.h"
+
+EngineExecutionContext::EngineExecutionContext(const Engine* engine, Label* label, Engine::ExecutionMode mode) :
+	_engine{engine}, _label{label}, _executionMode{mode}
+{
+	if (!engine)
+		throw std::invalid_argument{_EXCPT("engine may not be null")};
+
+	if (!label)
+		throw std::invalid_argument{_EXCPT("label may not be null")};
+}
+
+void EngineExecutionContext::purge(const BlockHierarchy& blockHierarchy, unsigned int reachedLevel)
+{
+	// Iterate over all levels lower than the reachedLevel and remove entries that are no longer needed
+	for (unsigned int level = 0; level < reachedLevel; ++level)
+	{
+		auto levelBlocks = blockHierarchy.at(level);
+
+		for (const auto& block : levelBlocks)
+		{
+			for (const auto& port : block->ports().selectByDirection(Port::Direction::Out))
+			{
+				auto it = _contextEntries.find(port.get());
+
+				if (it != _contextEntries.cend())
+				{
+					if (canPurge(port.get(), blockHierarchy, reachedLevel))
+						_contextEntries.erase(it);
+				}
+			}
+		}
+	}
+}
+
+DataBlob* EngineExecutionContext::contextEntry(const Port* port)
+{
+	if (!port->isOut())
+		throw std::invalid_argument{_EXCPT("Data can only be retrieved for out-ports")};
+
+	if (contains(port))
+		return &_contextEntries.at(port);
+	else
+		return nullptr;
+}
+
+void EngineExecutionContext::setContextEntry(const Port* port, const DataBlob& data)
+{
+	if (!port->isOut())
+		throw std::invalid_argument{_EXCPT("Data can only be set for out-ports")};
+
+	_contextEntries.emplace(port, data);
+}
+
+void EngineExecutionContext::setContextEntry(const Port* port, DataBlob&& data)
+{
+	if (!port->isOut())
+		throw std::invalid_argument{_EXCPT("Data can only be set for out-ports")};
+
+	_contextEntries.emplace(port, std::move(data));
+}
+
+bool EngineExecutionContext::canPurge(const Port* port, const BlockHierarchy& blockHierarchy, unsigned int reachedLevel) const
+{
+	// Search for outgoing connections to blocks at a level higher than reachedLevel; if one exists, the port-data cannot be purged
+	for (const auto& con : port->connections())
+	{
+		if (blockHierarchy.getBlockLevel(con->dest()) > static_cast<int>(reachedLevel))
+			return false;
+	}
+
+	return true;
+}
diff --git a/Grinder/engine/EngineExecutionContext.h b/Grinder/engine/EngineExecutionContext.h
new file mode 100644
index 0000000000000000000000000000000000000000..0485434fa7b9ac6641aafcdd3ba7b0c4ff39e9d0
--- /dev/null
+++ b/Grinder/engine/EngineExecutionContext.h
@@ -0,0 +1,60 @@
+/******************************************************************************
+ * File: EngineExecutionContext.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#ifndef ENGINEEXECUTIONCONTEXT_H
+#define ENGINEEXECUTIONCONTEXT_H
+
+#include <map>
+
+#include "Engine.h"
+#include "pipeline/BlockHierarchy.h"
+#include "data/DataBlob.h"
+
+namespace grndr
+{
+	class Engine;
+	class Label;
+	class Port;
+
+	class EngineExecutionContext
+	{	
+	public:
+		EngineExecutionContext(const Engine* engine, Label* label, Engine::ExecutionMode mode);
+
+	public:
+		void purge(const BlockHierarchy& blockHierarchy, unsigned int reachedLevel);
+
+	public:
+		const Engine* engine() const { return _engine; }
+		Label* label() { return _label; }
+		const Label* label() const { return _label; }
+
+		DataBlob* contextEntry(const Port* port);
+		void setContextEntry(const Port* port, const DataBlob& data);
+		void setContextEntry(const Port* port, DataBlob&& data);
+
+		bool contains(const Port* port) const { return _contextEntries.find(port) != _contextEntries.end(); }
+
+		Engine::ExecutionMode getExecutionMode() const { return _executionMode; }
+		void setExecutionMode(Engine::ExecutionMode mode) { _executionMode = mode; }
+
+		bool wasAborted() const { return _abortProcessing; }
+		void abortProcessing() { _abortProcessing = true; }
+
+	private:
+		bool canPurge(const Port* port, const BlockHierarchy& blockHierarchy, unsigned int reachedLevel) const;
+
+	private:
+		const Engine* _engine{nullptr};
+		Label* _label{nullptr};
+
+		std::map<const Port*, DataBlob> _contextEntries;
+
+		Engine::ExecutionMode _executionMode{Engine::ExecutionMode::Execute};
+		bool _abortProcessing{false};
+	};
+}
+
+#endif
diff --git a/Grinder/engine/Processor.h b/Grinder/engine/Processor.h
new file mode 100644
index 0000000000000000000000000000000000000000..c44ba54c7fcdf8c8a5de7c1300f051b1a87e47f6
--- /dev/null
+++ b/Grinder/engine/Processor.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: Processor.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#ifndef PROCESSOR_H
+#define PROCESSOR_H
+
+#include "ProcessorBase.h"
+
+namespace grndr
+{
+	class Block;
+
+	template<typename BlockType>
+	class Processor : public ProcessorBase
+	{
+	public:
+		Processor(const Block* block);
+
+	public:
+		const BlockType* block() const { return _block; }
+
+	protected:
+		const BlockType* _block{nullptr};
+	};
+}
+
+#include "Processor.impl.h"
+
+#endif
diff --git a/Grinder/engine/Processor.impl.h b/Grinder/engine/Processor.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..f5cf12618f45b832763515b1615e07892914532c
--- /dev/null
+++ b/Grinder/engine/Processor.impl.h
@@ -0,0 +1,17 @@
+/******************************************************************************
+ * File: Processor.impl.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Processor.h"
+#include "EngineExceptions.h"
+#include "pipeline/Block.h"
+
+template<typename BlockType>
+Processor<BlockType>::Processor(const Block* block) : ProcessorBase(block),
+	_block{dynamic_cast<const BlockType*>(block)}
+{
+	if (!_block)
+		throw std::invalid_argument{_EXCPT("An invalid block type was requested")};
+}
diff --git a/Grinder/engine/ProcessorBase.cpp b/Grinder/engine/ProcessorBase.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..6f03458ec46e75fa68297ca29663c4b8aa0751d8
--- /dev/null
+++ b/Grinder/engine/ProcessorBase.cpp
@@ -0,0 +1,85 @@
+/******************************************************************************
+ * File: ProcessorBase.cpp
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ProcessorBase.h"
+#include "EngineExceptions.h"
+#include "pipeline/Block.h"
+
+ProcessorBase::ProcessorBase(const Block* block) :
+	_block{block}
+{
+	if (!block)
+		throw std::invalid_argument{_EXCPT("block may not be null")};
+}
+
+void ProcessorBase::execute(EngineExecutionContext& ctx)
+{
+	_engine = ctx.engine();
+
+	if (!_engine)
+		throw std::invalid_argument{_EXCPT("Invalid execution context")};
+}
+
+DataDescriptor ProcessorBase::getPortDataDescriptor(const Port* port, unsigned int index) const
+{
+	return port->dataDescriptors().at(index);
+}
+
+DataDescriptor ProcessorBase::getBestDataDescriptor(const DataDescriptor& dataDesc, const DataDescriptors& targetDescriptors) const
+{
+	for (const auto& targetDescriptor : targetDescriptors)
+	{
+		DataDescriptor targetDescAdjusted;
+
+		if (dataDesc.match(targetDescriptor, &targetDescAdjusted))	// Found a perfect match <3
+			return targetDescAdjusted;
+		else if (dataDesc.canConvertTo(targetDescriptor))	// Not perfect, but convertible
+			return targetDescriptor;
+	}
+
+	// No valid descriptor found, throw an exception
+	throwProcessorException(_EXCPT("The input data is not compatible with the expected data type"));
+	return DataDescriptor{};
+}
+
+DataBlob* ProcessorBase::portData(EngineExecutionContext& ctx, const Port* port, bool convert, bool required) const
+{
+	auto dataPort = port;
+
+	// If the given port is an in-port, find the out-port it is connected to
+	if (port->isIn())
+	{
+		dataPort = nullptr;
+
+		auto connections = port->getConnections(Port::Direction::In);
+
+		if (connections.size() == 1)
+			dataPort = connections.at(0)->sourcePort();
+		else if (connections.size() == 0 && required)
+			throwProcessorException(QString{"Port '%1' is not connected to a source"}.arg(port->getName()));
+		else if (connections.size() > 1)
+			throwProcessorException(QString{"Port '%1' is connected to more than one source"}.arg(port->getName()));	// Should never happen
+	}
+
+	DataBlob* data = dataPort ? ctx.contextEntry(dataPort) : nullptr;
+
+	if (!data && required)
+		throwProcessorException(QString{"No data could be retrieved for port '%1'"}.arg(port->getName()));
+
+	if (convert)
+	{
+		// Get the best matching target data descriptor; if none can be found, an exception will be thrown
+		auto targetDataDesc = getBestDataDescriptor(data->dataDescriptor(), port->dataDescriptors());
+		data->convertTo(targetDataDesc);
+	}
+
+	return data;
+}
+
+void ProcessorBase::throwProcessorException(QString what) const
+{
+	throw ProcessorException{this, _EXCPT(QString{"%1: %2"}.arg(_block->getName()).arg(what))};
+}
diff --git a/Grinder/engine/ProcessorBase.h b/Grinder/engine/ProcessorBase.h
new file mode 100644
index 0000000000000000000000000000000000000000..65c2e40eeeee3dcd14733b7b6ae105a453c14f52
--- /dev/null
+++ b/Grinder/engine/ProcessorBase.h
@@ -0,0 +1,42 @@
+/******************************************************************************
+ * File: ProcessorBase.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#ifndef PROCESSORBASE_H
+#define PROCESSORBASE_H
+
+#include "engine/EngineExecutionContext.h"
+
+namespace grndr
+{
+	class Engine;
+
+	class ProcessorBase
+	{
+	public:
+		ProcessorBase(const Block* block);
+
+	public:
+		const Engine* engine() const { return _engine; }
+		const Block* block() const { return _block; }
+
+	public:
+		virtual void execute(EngineExecutionContext& ctx);
+
+	protected:
+		DataDescriptor getPortDataDescriptor(const Port* port, unsigned int index = 0) const;
+		DataDescriptor getBestDataDescriptor(const DataDescriptor& dataDesc, const DataDescriptors& targetDescriptors) const;
+
+		DataBlob* portData(EngineExecutionContext& ctx, const Port* port, bool convert = true, bool required = true) const;
+
+	protected:
+		void throwProcessorException(QString what) const;
+
+	protected:
+		const Engine* _engine{nullptr};
+		const Block* _block{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/engine/data/DataBlob.cpp b/Grinder/engine/data/DataBlob.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ad808f98ececcda72c921939667c5bc146567349
--- /dev/null
+++ b/Grinder/engine/data/DataBlob.cpp
@@ -0,0 +1,81 @@
+/******************************************************************************
+ * File: DataBlob.cpp
+ * Date: 19.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DataBlob.h"
+#include "DataExceptions.h"
+
+#include <opencv2/imgproc.hpp>
+
+DataBlob::DataBlob(const DataDescriptor& dataDesc) :
+	_dataDescriptor{dataDesc}
+{
+	if (dataDesc.isArbitrary() || dataDesc.isAdaptive())
+		throw DataException{_EXCPT("dataDesc may not be arbitrary or adaptive")};
+}
+
+void DataBlob::set(const cv::Mat& data)
+{
+	if (!data.empty())
+		data.copyTo(_data);
+	else
+		clear();
+}
+
+void DataBlob::set(cv::Mat&& data)
+{
+	_data = std::move(data);
+}
+
+void DataBlob::convertTo(const DataDescriptor& dataDesc, bool normalize, double minValue, double maxValue)
+{
+	if (!_dataDescriptor.canConvertTo(dataDesc) || dataDesc.isAdaptive())
+		throw DataException{_EXCPT("Invalid conversion")};
+
+	DataDescriptor dataDescNew = _dataDescriptor;
+
+	int channels, depth;
+	dataDesc.getCVMatrixType(&channels, &depth);
+
+	try {
+		// First, convert the value type if necessary
+		if (dataDesc.getValueType() != DataDescriptor::ValueType::Any && depth != _data.depth())
+		{
+			if (normalize && dataDesc.getValueType() < _dataDescriptor.getValueType())	// Normalize only if the new type is smaller than the current one
+				cv::normalize(_data, _data, maxValue, minValue, cv::NORM_MINMAX);
+
+			_data.convertTo(_data, depth);
+
+			// Update the new data descriptor to match the new value type
+			dataDescNew = DataDescriptor{dataDescNew.getName(), dataDescNew.getStructureType(), dataDescNew.getFieldType(), dataDesc.getValueType()};
+		}
+
+		// Next, convert image colors count if necessary
+		if (dataDesc.getFieldType() != DataDescriptor::FieldType::Any && channels != _data.channels())
+		{
+			// Check if a color <-> grayscale conversion can be done
+			if (_dataDescriptor.canConvertToColor(dataDesc))
+			{
+				cv::cvtColor(_data, _data, cv::COLOR_GRAY2BGR);
+
+				// Update the new data descriptor to match the new field type
+				dataDescNew = DataDescriptor{dataDescNew.getName(), dataDescNew.getStructureType(), DataDescriptor::FieldType::Color, dataDescNew.getValueType()};
+			}
+			else if (_dataDescriptor.canConvertToGrayscale(dataDesc))
+			{
+				cv::cvtColor(_data, _data, cv::COLOR_BGR2GRAY);
+
+				// Update the new data descriptor to match the new field type
+				dataDescNew = DataDescriptor{dataDescNew.getName(), dataDescNew.getStructureType(), DataDescriptor::FieldType::Basic, dataDescNew.getValueType()};
+			}
+		}
+
+		// Set the new data descriptor to match the converted type
+		_dataDescriptor = dataDescNew;
+	} catch (std::exception& e) {
+		// Forward exceptions from OpenCV as a DataException
+		throw DataException{_EXCPT(e.what())};
+	}
+}
diff --git a/Grinder/engine/data/DataBlob.h b/Grinder/engine/data/DataBlob.h
new file mode 100644
index 0000000000000000000000000000000000000000..173e64db502e8e64b897c4d9b3c6803a6339bac0
--- /dev/null
+++ b/Grinder/engine/data/DataBlob.h
@@ -0,0 +1,78 @@
+/******************************************************************************
+ * File: DataBlob.h
+ * Date: 19.2.2018
+ *****************************************************************************/
+
+#ifndef DATABLOB_H
+#define DATABLOB_H
+
+#include "DataDescriptor.h"
+
+#include <opencv2/core.hpp>
+
+namespace grndr
+{
+	class DataBlob
+	{
+	public:
+		DataBlob(const DataDescriptor& dataDesc);
+		template<typename DataType>
+		DataBlob(const DataDescriptor& dataDesc, const DataType& data);
+		template<typename DataType>
+		DataBlob(const DataDescriptor& dataDesc, DataType&& data);
+		DataBlob(const DataBlob& blob) = default;
+		DataBlob(DataBlob&& blob) = default;
+
+		DataBlob& operator =(const DataBlob& blob) = default;
+		DataBlob& operator =(DataBlob&& blob) = default;
+
+		DataBlob& operator =(const cv::Mat& data) { set(data); return *this; }
+		template<typename DataType>
+		DataBlob& operator =(const std::vector<DataType>& data) { set(data); return *this; }
+		template<typename DataType>
+		DataBlob& operator =(const DataType& data) { set(data); return *this; }
+
+		operator cv::Mat() const { return getMatrix(); }
+		template<typename DataType>
+		operator std::vector<DataType>() const { return getVector<DataType>(); }
+		template<typename DataType>
+		operator DataType() const { return getScalar<DataType>(); }
+
+	public:
+		void set(const cv::Mat& data);
+		void set(cv::Mat&& data);
+		template<typename DataType>
+		void set(const std::vector<DataType>& data);
+		template<typename DataType>
+		void set(const DataType& data);
+
+		cv::Mat getMatrix() const { return _data; }
+		template<typename DataType>
+		std::vector<DataType> getVector() const;
+		template<typename DataType>
+		DataType getScalar() const;
+
+		void convertTo(const DataDescriptor& dataDesc, bool normalize = false, double minValue = 0.0, double maxValue = 1.0);
+		template<typename TargetType>
+		void convertTo(const DataDescriptor& dataDesc);
+
+		void clear() { _data.release(); }
+
+	public:
+		const DataDescriptor& dataDescriptor() { return _dataDescriptor; }
+
+		cv::Mat& data() { return _data; }
+		const cv::Mat& data() const { return _data; }
+
+		bool empty() const { return _data.empty(); }
+
+	private:
+		DataDescriptor _dataDescriptor;
+
+		cv::Mat _data;
+	};
+}
+
+#include "DataBlob.impl.h"
+
+#endif
diff --git a/Grinder/engine/data/DataBlob.impl.h b/Grinder/engine/data/DataBlob.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..807cbe7b2ed023dc7509c72a40013b54d9d0144e
--- /dev/null
+++ b/Grinder/engine/data/DataBlob.impl.h
@@ -0,0 +1,76 @@
+/******************************************************************************
+ * File: DataBlob.impl.h
+ * Date: 19.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DataBlob.h"
+
+#include <limits>
+
+template<typename DataType>
+DataBlob::DataBlob(const DataDescriptor& dataDesc, const DataType& data) : DataBlob(dataDesc)
+{
+	set(data);
+}
+
+template<typename DataType>
+DataBlob::DataBlob(const DataDescriptor& dataDesc, DataType&& data) : DataBlob(dataDesc)
+{
+	set(std::move(data));
+}
+
+template<typename DataType>
+void DataBlob::set(const std::vector<DataType>& data)
+{
+	if (data.size() > 0)
+	{
+		// Create a matrix with a single row that holds the vector data
+		_data.create(1, data.size(), _dataDescriptor.getCVMatrixType());
+
+		// Perform a direct memory copy of the data
+		DataType* matrixData = _data.ptr<DataType>(0);
+		std::memcpy(matrixData, data.data(), data.size() * sizeof(DataType));
+	}
+	else
+		clear();
+}
+
+template<typename DataType>
+void DataBlob::set(const DataType& data)
+{
+	// Create a matrix with a single row and a single column that holds the value
+	_data.create(1, 1, _dataDescriptor.getCVMatrixType());
+	_data.at<DataType>(0, 0) = data;
+}
+template<typename DataType>
+std::vector<DataType> DataBlob::getVector() const
+{
+	if (!_data.empty())
+	{
+		std::vector<DataType> vec(_data.cols);
+
+		// Perform a direct memory copy of the data
+		DataType* vecData = vec.data();
+		std::memcpy(vecData, _data.ptr<DataType>(0), _data.cols * sizeof(DataType));
+
+		return vec;
+	}
+	else
+		return {};
+}
+
+template<typename DataType>
+DataType DataBlob::getScalar() const
+{
+	if (!_data.empty())
+		return _data.at<DataType>(0, 0);
+	else
+		return DataType{};
+}
+
+template<typename TargetType>
+void DataBlob::convertTo(const DataDescriptor& dataDesc)
+{
+	return convertTo(dataDesc, true, std::numeric_limits<TargetType>::min(), std::numeric_limits<TargetType>::max());
+}
diff --git a/Grinder/engine/data/DataDescriptor.cpp b/Grinder/engine/data/DataDescriptor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..5d5dc93dd1a0cee952609598b50c06804e561b0e
--- /dev/null
+++ b/Grinder/engine/data/DataDescriptor.cpp
@@ -0,0 +1,209 @@
+/******************************************************************************
+ * File: DataDescriptor.cpp
+ * Date: 15.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DataDescriptor.h"
+
+#include <opencv2/core.hpp>
+
+DataDescriptor DataDescriptor::arbitraryDescriptor(QString name)
+{
+	return DataDescriptor{name, StructureType::Any, FieldType::Any, ValueType::Any};
+}
+
+DataDescriptor DataDescriptor::imageDescriptor(bool colorImage, ValueType valueType)
+{
+	return DataDescriptor{colorImage ? "Color image" : "Grayscale image", StructureType::Matrix, colorImage ? FieldType::Color : FieldType::Basic, valueType};
+}
+
+DataDescriptor DataDescriptor::adaptiveOutputDescriptor(QString name, DataDescriptor::StructureType structType)
+{
+	return DataDescriptor{name, structType, FieldType::Adaptive, ValueType::Adaptive};
+}
+
+DataDescriptor::DataDescriptor(QString name, DataDescriptor::StructureType structureType, DataDescriptor::FieldType fieldType, DataDescriptor::ValueType valueType) :
+	_name{name}, _structureType{structureType}, _fieldType{fieldType}, _valueType{valueType}
+{
+
+}
+
+bool DataDescriptor::canConvertTo(const DataDescriptor& dataDesc) const
+{
+	// All valid structure type conversions
+	static const std::vector<std::pair<DataDescriptor::StructureType, DataDescriptor::StructureType>> validStructureTypeConversions = {
+		{DataDescriptor::StructureType::Scalar, DataDescriptor::StructureType::Vector},
+		{DataDescriptor::StructureType::Scalar, DataDescriptor::StructureType::Matrix},
+		{DataDescriptor::StructureType::Vector, DataDescriptor::StructureType::Matrix},
+	};
+
+	// All valid field type conversions
+	static const std::vector<std::pair<DataDescriptor::FieldType, DataDescriptor::FieldType>> validFieldTypeConversions = {
+		{DataDescriptor::FieldType::Basic, DataDescriptor::FieldType::Color},
+		{DataDescriptor::FieldType::Basic, DataDescriptor::FieldType::Point3D},
+		{DataDescriptor::FieldType::Color, DataDescriptor::FieldType::Basic},
+		{DataDescriptor::FieldType::Color, DataDescriptor::FieldType::Point3D},
+		{DataDescriptor::FieldType::Point3D, DataDescriptor::FieldType::Basic},
+		{DataDescriptor::FieldType::Point3D, DataDescriptor::FieldType::Color},
+	};
+
+	if (match(dataDesc))	// No conversion needed
+		return true;
+
+	auto structTypeFrom = _structureType;
+	auto structTypeTo = dataDesc._structureType;
+
+	if (structTypeFrom == DataDescriptor::StructureType::Adaptive || structTypeTo == DataDescriptor::StructureType::Any || canConvertTypes(validStructureTypeConversions, structTypeFrom, structTypeTo))
+	{
+		auto fieldTypeFrom = _fieldType;
+		auto fieldTypeTo = dataDesc._fieldType;
+
+		if (fieldTypeFrom == DataDescriptor::FieldType::Adaptive || fieldTypeTo == DataDescriptor::FieldType::Any || canConvertTypes(validFieldTypeConversions, fieldTypeFrom, fieldTypeTo))
+		{
+			// Value type: Everything can be converted to everything, so no further checks needed
+			return true;
+		}
+	}
+
+	// Conversion isn't possible
+	return false;
+}
+
+bool DataDescriptor::canConvertToColor(const DataDescriptor& dataDesc) const
+{
+	// All grayscale to color conversions
+	static const std::vector<std::pair<DataDescriptor::FieldType, DataDescriptor::FieldType>> grayscaleToColorConversions = {
+		{DataDescriptor::FieldType::Basic, DataDescriptor::FieldType::Color},
+		{DataDescriptor::FieldType::Basic, DataDescriptor::FieldType::Point3D},
+	};
+
+	return canConvertTypes(grayscaleToColorConversions, _fieldType, dataDesc._fieldType);
+}
+
+bool DataDescriptor::canConvertToGrayscale(const DataDescriptor& dataDesc) const
+{
+	// All color to grayscale conversions
+	static const std::vector<std::pair<DataDescriptor::FieldType, DataDescriptor::FieldType>> colorToGrayscaleConversions = {
+		{DataDescriptor::FieldType::Color, DataDescriptor::FieldType::Basic},
+		{DataDescriptor::FieldType::Point3D, DataDescriptor::FieldType::Basic},
+	};
+
+	return canConvertTypes(colorToGrayscaleConversions, _fieldType, dataDesc._fieldType);
+}
+
+bool DataDescriptor::match(const DataDescriptor& dataDesc, DataDescriptor* adjustedDataDesc) const
+{
+	DataDescriptor adjusted = *this;
+
+	auto checkType = [&dataDesc](auto type, auto typeOther, auto adaptiveVal, auto anyVal, auto adaptiveAssigner) {
+		if (type == adaptiveVal)	// Handle adaptive type
+		{
+			if (typeOther != anyVal)
+				adaptiveAssigner(typeOther);
+			else
+				return false;
+		}
+		else if (type != typeOther && typeOther != anyVal)	// Types do not match
+			return false;
+
+		return true;
+	};
+
+	bool result = true;
+
+	// Structure type
+	if (!checkType(_structureType, dataDesc._structureType, StructureType::Adaptive, StructureType::Any, [&adjusted](auto newVal) { adjusted._structureType = newVal; }))
+		result = false;
+
+	// Field type
+	if (!checkType(_fieldType, dataDesc._fieldType, FieldType::Adaptive, FieldType::Any, [&adjusted](auto newVal) { adjusted._fieldType = newVal; }))
+		result = false;
+
+	// Value type
+	if (!checkType(_valueType, dataDesc._valueType, ValueType::Adaptive, ValueType::Any, [&adjusted](auto newVal) { adjusted._valueType = newVal; }))
+		result = false;
+
+	if (adjustedDataDesc)
+		*adjustedDataDesc = adjusted;
+
+	return result;
+}
+
+int DataDescriptor::getCVMatrixType(int* chan, int* dep) const
+{
+	int channels = 0;
+	int depth = 0;
+
+	switch (_fieldType)
+	{
+	case FieldType::Basic:
+		channels = 1;
+		break;
+
+	case FieldType::Point2D:
+		channels = 2;
+		break;
+
+	case FieldType::Color:
+	case FieldType::Point3D:
+		channels = 3;
+		break;
+
+	default:
+		break;
+	}
+
+	if (chan)
+		*chan = channels;
+
+	switch (_valueType)
+	{
+	case ValueType::Int8:
+		depth = CV_8S;
+		break;
+
+	case ValueType::UInt8:
+		depth = CV_8U;
+		break;
+
+	case ValueType::Int16:
+		depth = CV_16S;
+		break;
+
+	case ValueType::UInt16:
+		depth = CV_16U;
+		break;
+
+	case ValueType::Int32:
+	case ValueType::UInt32:
+		depth = CV_32S;
+		break;
+
+	case ValueType::Float:
+		depth = CV_32F;
+		break;
+
+	case ValueType::Double:
+		depth = CV_64F;
+		break;
+
+	default:
+		break;
+	}
+
+	if (dep)
+		*dep = depth;
+
+	return CV_MAKETYPE(depth, channels);
+}
+
+bool DataDescriptor::isArbitrary() const
+{
+	return _structureType == StructureType::Any || _fieldType == FieldType::Any || _valueType == ValueType::Any;
+}
+
+bool DataDescriptor::isAdaptive() const
+{
+	return _structureType == StructureType::Adaptive || _fieldType == FieldType::Adaptive || _valueType == ValueType::Adaptive;
+}
diff --git a/Grinder/engine/data/DataDescriptor.h b/Grinder/engine/data/DataDescriptor.h
new file mode 100644
index 0000000000000000000000000000000000000000..583afb3191cd34bcb1889b95721048977528a543
--- /dev/null
+++ b/Grinder/engine/data/DataDescriptor.h
@@ -0,0 +1,97 @@
+/******************************************************************************
+ * File: DataDescriptor.h
+ * Date: 15.2.2018
+ *****************************************************************************/
+
+#ifndef DATADESCRIPTOR_H
+#define DATADESCRIPTOR_H
+
+#include <QString>
+
+namespace grndr
+{
+	class DataDescriptor
+	{
+	public:
+		enum class StructureType
+		{
+			Any,	/* Arbitrary type */
+			Adaptive,	/* Data adapts to its input */
+
+			Scalar,
+			Vector,
+			Matrix,
+		};
+
+		enum class FieldType
+		{
+			Any,	/* Arbitrary type */
+			Adaptive,	/* Data adapts to its input */
+
+			Basic,
+			Color,
+			Point2D,
+			Point3D,
+		};
+
+		enum class ValueType
+		{
+			Any,	/* Arbitrary type */
+			Adaptive,	/* Data adapts to its input */
+
+			Int8,
+			UInt8,
+			Int16,
+			UInt16,
+			Int32,
+			UInt32,
+			Float,
+			Double,
+		};
+
+	public:
+		static DataDescriptor arbitraryDescriptor(QString name = "Arbitrary data");
+		static DataDescriptor imageDescriptor(bool colorImage = true, ValueType valueType = ValueType::UInt8);
+		static DataDescriptor adaptiveOutputDescriptor(QString name, StructureType structType = StructureType::Matrix);
+
+	public:
+		DataDescriptor() { }
+		DataDescriptor(QString name, StructureType structureType, FieldType fieldType, ValueType valueType);
+
+	public:
+		bool canConvertTo(const DataDescriptor& dataDesc) const;
+		bool canConvertToColor(const DataDescriptor& dataDesc) const;
+		bool canConvertToGrayscale(const DataDescriptor& dataDesc) const;
+
+		bool match(const DataDescriptor& dataDesc, DataDescriptor* adjustedDataDesc = nullptr) const;
+
+		int getCVMatrixType(int* chan = nullptr, int* dep = nullptr) const;
+
+	public:
+		QString getName() const { return _name; }
+
+		StructureType getStructureType() const { return _structureType; }
+		FieldType getFieldType() const { return _fieldType; }
+		ValueType getValueType() const { return _valueType; }
+
+		bool isArbitrary() const;
+		bool isAdaptive() const;
+
+	private:
+		template<typename Type>
+		bool canConvertTypes(const std::vector<std::pair<Type, Type>> validConversions, Type typeFrom, Type typeTo) const;
+
+	private:
+		QString _name{""};
+
+		StructureType _structureType{StructureType::Any};
+		FieldType _fieldType{FieldType::Any};
+		ValueType _valueType{ValueType::Any};
+	};
+
+	using DataDescriptors = std::vector<DataDescriptor>;
+}
+
+#include "DataDescriptor.impl.h"
+
+#endif
diff --git a/Grinder/engine/data/DataDescriptor.impl.h b/Grinder/engine/data/DataDescriptor.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..d65535ca2e55a31671c93d4d8bf7b99f85d8ac47
--- /dev/null
+++ b/Grinder/engine/data/DataDescriptor.impl.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: DataDescriptor.impl.h
+ * Date: 19.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DataDescriptor.h"
+
+template<typename Type>
+bool DataDescriptor::canConvertTypes(const std::vector<std::pair<Type, Type>> validConversions, Type typeFrom, Type typeTo) const
+{
+	if (typeFrom != typeTo)
+	{
+		for (auto conv : validConversions)
+		{
+			if (typeFrom == conv.first && typeTo == conv.second)
+				return true;
+		}
+
+		return false;
+	}
+	else
+		return true;
+}
diff --git a/Grinder/engine/data/DataExceptions.cpp b/Grinder/engine/data/DataExceptions.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..930e0fcd17e04eac636a9345262f3a21ccdf8a2f
--- /dev/null
+++ b/Grinder/engine/data/DataExceptions.cpp
@@ -0,0 +1,12 @@
+/******************************************************************************
+ * File: DataExceptions.cpp
+ * Date: 19.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DataExceptions.h"
+
+DataException::DataException(QString what) : GrinderException(what)
+{
+
+}
diff --git a/Grinder/engine/data/DataExceptions.h b/Grinder/engine/data/DataExceptions.h
new file mode 100644
index 0000000000000000000000000000000000000000..9c85b83b9d758985b803dcad5af4c5536d910c1d
--- /dev/null
+++ b/Grinder/engine/data/DataExceptions.h
@@ -0,0 +1,22 @@
+/******************************************************************************
+ * File: DataExceptions.h
+ * Date: 19.2.2018
+ *****************************************************************************/
+
+#ifndef DATAEXCEPTIONS_H
+#define DATAEXCEPTIONS_H
+
+#include <QString>
+
+#include "core/GrinderExceptions.h"
+
+namespace grndr
+{
+	class DataException : public GrinderException
+	{
+	public:
+		DataException(QString what);
+	};
+}
+
+#endif
diff --git a/Grinder/engine/processors/BinaryThresholdProcessor.cpp b/Grinder/engine/processors/BinaryThresholdProcessor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..78e0e7d5792f5c21456578a9f74898f966451118
--- /dev/null
+++ b/Grinder/engine/processors/BinaryThresholdProcessor.cpp
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: BinaryThresholdProcessor.cpp
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BinaryThresholdProcessor.h"
+
+#include <opencv2/imgproc.hpp>
+
+BinaryThresholdProcessor::BinaryThresholdProcessor(const Block* block) : Processor(block)
+{
+
+}
+
+void BinaryThresholdProcessor::execute(EngineExecutionContext& ctx)
+{
+	Processor::execute(ctx);
+
+	if (auto dataBlob = portData(ctx, _block->inPort()))
+	{
+		cv::Mat processedImage;
+		cv::threshold(dataBlob->getMatrix(), processedImage, *_block->threshold(), *_block->targetValue(), *_block->invert() ? cv::THRESH_BINARY_INV : cv::THRESH_BINARY);
+
+		ctx.setContextEntry(_block->outPort(), DataBlob{dataBlob->dataDescriptor(), std::move(processedImage)});
+	}
+}
diff --git a/Grinder/engine/processors/BinaryThresholdProcessor.h b/Grinder/engine/processors/BinaryThresholdProcessor.h
new file mode 100644
index 0000000000000000000000000000000000000000..3e36f894137360afda4ef273fe5bf0de68271572
--- /dev/null
+++ b/Grinder/engine/processors/BinaryThresholdProcessor.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: BinaryThresholdProcessor.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#ifndef BINARYTHRESHOLDPROCESSOR_H
+#define BINARYTHRESHOLDPROCESSOR_H
+
+#include "engine/Processor.h"
+#include "pipeline/blocks/BinaryThresholdBlock.h"
+
+namespace grndr
+{
+	class BinaryThresholdProcessor : public Processor<BinaryThresholdBlock>
+	{
+	public:
+		BinaryThresholdProcessor(const Block* block);
+
+	public:
+		virtual void execute(EngineExecutionContext& ctx) override;
+	};
+}
+
+#endif
diff --git a/Grinder/engine/processors/ConvertToGrayscaleProcessor.cpp b/Grinder/engine/processors/ConvertToGrayscaleProcessor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e4f82e670bfdaae6e560e93fe8b9588501c0244a
--- /dev/null
+++ b/Grinder/engine/processors/ConvertToGrayscaleProcessor.cpp
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: ConvertToGrayscaleProcessor.cpp
+ * Date: 22.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ConvertToGrayscaleProcessor.h"
+
+#include <opencv2/imgproc.hpp>
+
+ConvertToGrayscaleProcessor::ConvertToGrayscaleProcessor(const Block* block) : Processor(block)
+{
+
+}
+
+void ConvertToGrayscaleProcessor::execute(EngineExecutionContext& ctx)
+{
+	Processor::execute(ctx);
+
+	if (auto dataBlob = portData(ctx, _block->inPort()))
+	{
+		cv::Mat processedImage;
+		cv::cvtColor(dataBlob->getMatrix(), processedImage, cv::COLOR_BGR2GRAY);
+
+		ctx.setContextEntry(_block->outPort(), DataBlob{dataBlob->dataDescriptor(), std::move(processedImage)});
+	}
+}
diff --git a/Grinder/engine/processors/ConvertToGrayscaleProcessor.h b/Grinder/engine/processors/ConvertToGrayscaleProcessor.h
new file mode 100644
index 0000000000000000000000000000000000000000..77f6a5a62d321d08ceb79546e92eabc2b756bc27
--- /dev/null
+++ b/Grinder/engine/processors/ConvertToGrayscaleProcessor.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: ConvertToGrayscaleProcessor.h
+ * Date: 22.2.2018
+ *****************************************************************************/
+
+#ifndef CONVERTTOGRAYSCALEPROCESSOR_H
+#define CONVERTTOGRAYSCALEPROCESSOR_H
+
+#include "engine/Processor.h"
+#include "pipeline/blocks/ConvertToGrayscaleBlock.h"
+
+namespace grndr
+{
+	class ConvertToGrayscaleProcessor : public Processor<ConvertToGrayscaleBlock>
+	{
+	public:
+		ConvertToGrayscaleProcessor(const Block* block);
+
+	public:
+		virtual void execute(EngineExecutionContext& ctx) override;
+	};
+}
+
+#endif
diff --git a/Grinder/engine/processors/InputProcessor.cpp b/Grinder/engine/processors/InputProcessor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..3a8532264a08cb7a15c95e11b156c36aacbcd61a
--- /dev/null
+++ b/Grinder/engine/processors/InputProcessor.cpp
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: InputProcessor.cpp
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "InputProcessor.h"
+#include "engine/EngineExceptions.h"
+#include "core/GrinderApplication.h"
+#include "controller/ProjectController.h"
+
+InputProcessor::InputProcessor(const Block* block) : Processor(block)
+{
+
+}
+
+void InputProcessor::execute(EngineExecutionContext& ctx)
+{
+	Processor::execute(ctx);
+
+	if (auto activeImage = grinder()->projectController().activeImageReference())
+		ctx.setContextEntry(_block->outPort(), DataBlob{getPortDataDescriptor(_block->outPort()), activeImage->loadImage()});
+	else
+		throwProcessorException("There currently is no active image");
+}
diff --git a/Grinder/engine/processors/InputProcessor.h b/Grinder/engine/processors/InputProcessor.h
new file mode 100644
index 0000000000000000000000000000000000000000..65cbb9d4eacc78c011fce5ba5cfe17a73d0ebcc7
--- /dev/null
+++ b/Grinder/engine/processors/InputProcessor.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: InputProcessor.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#ifndef INTPUTPROCESSOR_H
+#define INTPUTPROCESSOR_H
+
+#include "engine/Processor.h"
+#include "pipeline/blocks/InputBlock.h"
+
+namespace grndr
+{
+	class InputProcessor : public Processor<InputBlock>
+	{
+	public:
+		InputProcessor(const Block* block);
+
+	public:
+		virtual void execute(EngineExecutionContext& ctx) override;
+	};
+}
+
+#endif
diff --git a/Grinder/engine/processors/OutputProcessor.cpp b/Grinder/engine/processors/OutputProcessor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..87fe3e392e940c633630884f4f2205c34618179a
--- /dev/null
+++ b/Grinder/engine/processors/OutputProcessor.cpp
@@ -0,0 +1,30 @@
+/******************************************************************************
+ * File: OutputProcessor.cpp
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "OutputProcessor.h"
+#include "project/Label.h"
+#include "core/GrinderApplication.h"
+
+OutputProcessor::OutputProcessor(const Block* block) : Processor(block)
+{
+
+}
+
+void OutputProcessor::execute(EngineExecutionContext& ctx)
+{
+	Processor::execute(ctx);
+
+	if (auto imageBlob = portData(ctx, _block->inPort()))
+	{
+		if (auto imageBuild = ctx.label()->imageBuildPool().imageBuild(_block, grinder()->projectController().activeImageReference()))	// TODO: ImageRef flexibler
+		{
+			imageBuild->setImageData(imageBlob->getMatrix());
+
+			if (ctx.getExecutionMode() == Engine::ExecutionMode::View)
+				grinder()->imageEditorManager().showEditor(_block, imageBuild);
+		}
+	}
+}
diff --git a/Grinder/engine/processors/OutputProcessor.h b/Grinder/engine/processors/OutputProcessor.h
new file mode 100644
index 0000000000000000000000000000000000000000..0110fec778a93771bdb731534a0741a8faae223f
--- /dev/null
+++ b/Grinder/engine/processors/OutputProcessor.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: OutputProcessor.h
+ * Date: 21.2.2018
+ *****************************************************************************/
+
+#ifndef OUTPUTPROCESSOR_H
+#define OUTPUTPROCESSOR_H
+
+#include "engine/Processor.h"
+#include "pipeline/blocks/OutputBlock.h"
+
+namespace grndr
+{
+	class OutputProcessor : public Processor<OutputBlock>
+	{
+	public:
+		OutputProcessor(const Block* block);
+
+	public:
+		virtual void execute(EngineExecutionContext& ctx) override;
+	};
+}
+
+#endif
diff --git a/Grinder/image/DraftItem.cpp b/Grinder/image/DraftItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..73e8eea6c5f5be7142cca41b9dd9e4dbbd720cd5
--- /dev/null
+++ b/Grinder/image/DraftItem.cpp
@@ -0,0 +1,63 @@
+/******************************************************************************
+ * File: DraftItem.cpp
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DraftItem.h"
+#include "Layer.h"
+#include "common/properties/RangeConstraint.h"
+
+const char* DraftItem::Serialization_Value_Type = "Type";
+
+DraftItem::DraftItem(Layer* layer, DraftItemType type) :
+	_layer{layer}, _type{type}
+{
+	if (!layer)
+		throw std::invalid_argument{_EXCPT("layer may not be null")};
+}
+
+void DraftItem::initDraftItem()
+{
+	createProperties();
+}
+
+int DraftItem::getZOrder() const
+{
+	return _layer->getZOrder();
+}
+
+void DraftItem::serialize(SerializationContext& ctx) const
+{
+	PropertyObject::serialize(ctx);
+
+	// Serialize values
+	ctx.settings()[Serialization_Value_Type] = _type;
+}
+
+void DraftItem::deserialize(DeserializationContext& ctx)
+{
+	PropertyObject::deserialize(ctx);
+
+	// Deserialize values
+	_type = ctx.settings()[Serialization_Value_Type].toString();
+}
+
+void DraftItem::createProperties()
+{
+	PropertyObject::createProperties();
+
+	// Create standard properties
+	_primaryColor = createProperty<ColorProperty>(PropertyID::PrimaryColor, "Primary color", QColor{255, 255, 255}, PropertyBase::Flag::Hidden);
+	primaryColor()->setDescription("The primary color to use.");
+
+	_position = createProperty<PointProperty>(PropertyID::Position, "Position", QPoint{0, 0});
+	position()->setDescription("The item position.");
+
+	_hasDirection = createProperty<BoolProperty>(PropertyID::HasDirection, "Has direction", false);
+	hasDirection()->setDescription("Specifies whether the box has a direction/orientation.");
+
+	_direction = createProperty<AngleProperty>(PropertyID::Direction, "Direction", 0.0);
+	direction()->createConstraint<RangeConstraint>(0, 360);
+	direction()->setDescription("The direction/orientation of the box contents.");
+}
diff --git a/Grinder/image/DraftItem.h b/Grinder/image/DraftItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..5ac87df4060fe3f3171375602e60f6c588f44dd6
--- /dev/null
+++ b/Grinder/image/DraftItem.h
@@ -0,0 +1,74 @@
+/******************************************************************************
+ * File: DraftItem.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#ifndef DRAFTITEM_H
+#define DRAFTITEM_H
+
+#include "common/PropertyObject.h"
+#include "DraftItemType.h"
+#include "DraftItemRendererBase.h"
+
+namespace grndr
+{
+	class Layer;
+
+	class DraftItem : public PropertyObject
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_Type;
+
+	public:
+		DraftItem(Layer* layer, DraftItemType type);
+
+	public:
+		virtual void initDraftItem();
+
+	public:
+		virtual std::unique_ptr<DraftItemRendererBase> createRenderer(const DraftItemRendererBase::RendererStyle& rendererStyle) const = 0;
+
+	public:
+		Layer* layer() { return _layer; }
+		const Layer* layer() const { return _layer; }
+
+		DraftItemType getType() const { return _type; }
+		int getZOrder() const;
+
+		auto primaryColor() { return dynamic_cast<ColorProperty*>(_primaryColor.get()); }
+		auto primaryColor() const { return dynamic_cast<ColorProperty*>(_primaryColor.get()); }
+		auto position() { return dynamic_cast<PointProperty*>(_position.get()); }
+		auto position() const { return dynamic_cast<PointProperty*>(_position.get()); }
+		auto hasDirection() { return dynamic_cast<BoolProperty*>(_hasDirection.get()); }
+		auto hasDirection() const { return dynamic_cast<BoolProperty*>(_hasDirection.get()); }
+		auto direction() { return dynamic_cast<AngleProperty*>(_direction.get()); }
+		auto direction() const { return dynamic_cast<AngleProperty*>(_direction.get()); }
+
+	public:
+		virtual void setDefaultPropertyValues() { }
+		virtual void setDragPropertyValues(QPoint initialPos, QPoint currentPos) { Q_UNUSED(initialPos); Q_UNUSED(currentPos); }
+
+		virtual void normalizePropertyValues() { }
+
+	public:
+		virtual void serialize(SerializationContext& ctx) const override;
+		virtual void deserialize(DeserializationContext& ctx) override;
+
+	protected:
+		virtual void createProperties() override;
+
+	protected:
+		Layer* _layer{nullptr};
+
+		DraftItemType _type{DraftItemType::Undefined};
+
+		std::shared_ptr<PropertyBase> _primaryColor;
+		std::shared_ptr<PropertyBase> _position;
+		std::shared_ptr<PropertyBase> _hasDirection;
+		std::shared_ptr<PropertyBase> _direction;
+	};
+}
+
+#endif
diff --git a/Grinder/image/DraftItemCatalog.cpp b/Grinder/image/DraftItemCatalog.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..65d2ca4a33f2818e093ace09635a5d434a3ee7b7
--- /dev/null
+++ b/Grinder/image/DraftItemCatalog.cpp
@@ -0,0 +1,61 @@
+/******************************************************************************
+ * File: DraftItemCatalog.cpp
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DraftItemCatalog.h"
+#include "DraftItem.h"
+
+#include "draftitems/LineDraftItem.h"
+#include "draftitems/BoxDraftItem.h"
+
+#define REGISTER_ITEM_TYPE(cls)	registerDraftItemType(cls::type_value, [](Layer* layer) { return std::make_unique<cls>(layer); })
+
+std::map<DraftItemType, DraftItemCatalog::item_creator_type> DraftItemCatalog::s_creators;
+
+std::set<DraftItemType> DraftItemCatalog::getTypes()
+{
+	std::set<DraftItemType> types;
+
+	for (const auto& creator : s_creators)
+		types.insert(creator.first);
+
+	return types;
+}
+
+void DraftItemCatalog::registerDraftItemType(DraftItemType type, item_creator_type creator)
+{
+	if (type == DraftItemType::Undefined)
+		throw std::invalid_argument{_EXCPT("type may not be DraftItemType::Undefined")};
+
+	if (!creator)
+		throw std::invalid_argument{_EXCPT("creator may not be null")};
+
+	s_creators[type] = creator;
+}
+
+std::unique_ptr<DraftItem> DraftItemCatalog::createDraftItem(Layer* layer, DraftItemType type)
+{
+	if (type == DraftItemType::Undefined)
+		throw std::invalid_argument{_EXCPT("type may not be DraftItemType::Undefined")};
+
+	if (s_creators.find(type) != s_creators.end())
+	{
+		auto creator = s_creators.at(type);
+		auto item = creator(layer);
+
+		if (!item)
+			throw std::runtime_error{_EXCPT(QString{"Failed to create a draft item of type %1"}.arg(type))};
+
+		return item;
+	}
+	else
+		throw std::invalid_argument{_EXCPT("The given type has not been registered")};
+}
+
+void DraftItemCatalog::registerStandardDraftItems()
+{
+	REGISTER_ITEM_TYPE(LineDraftItem);
+	REGISTER_ITEM_TYPE(BoxDraftItem);
+}
diff --git a/Grinder/image/DraftItemCatalog.h b/Grinder/image/DraftItemCatalog.h
new file mode 100644
index 0000000000000000000000000000000000000000..ff5f58f3ecd5cc29d63813b716be64493cd4faf0
--- /dev/null
+++ b/Grinder/image/DraftItemCatalog.h
@@ -0,0 +1,45 @@
+/******************************************************************************
+ * File: DraftItemCatalog.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#ifndef DRAFTITEMCATALOG_H
+#define DRAFTITEMCATALOG_H
+
+#include <map>
+#include <set>
+#include <functional>
+#include <memory>
+
+#include "DraftItemType.h"
+
+namespace grndr
+{
+	class DraftItem;
+	class Layer;
+
+	class DraftItemCatalog
+	{
+	private:
+		using item_creator_type = std::function<std::unique_ptr<DraftItem>(Layer*)>;
+
+	private:
+		DraftItemCatalog() { }
+
+	public:
+		static std::unique_ptr<DraftItem> createDraftItem(Layer* layer, DraftItemType type);
+
+		static void registerStandardDraftItems();
+
+	public:
+		static std::set<DraftItemType> getTypes();
+
+	private:
+		static void registerDraftItemType(DraftItemType type, item_creator_type creator);
+
+	private:
+		static std::map<DraftItemType, item_creator_type> s_creators;
+	};
+}
+
+#endif
diff --git a/Grinder/image/DraftItemRenderer.h b/Grinder/image/DraftItemRenderer.h
new file mode 100644
index 0000000000000000000000000000000000000000..4566cfe788697702ef17a692e230b23eec27346c
--- /dev/null
+++ b/Grinder/image/DraftItemRenderer.h
@@ -0,0 +1,42 @@
+/******************************************************************************
+ * File: DraftItemRenderer.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef DRAFTITEMRENDERER_H
+#define DRAFTITEMRENDERER_H
+
+#include "DraftItemRendererBase.h"
+
+namespace grndr
+{
+	class DraftItem;
+
+	template<typename ItemType>
+	class DraftItemRenderer : public DraftItemRendererBase
+	{
+		static_assert(std::is_base_of<DraftItem, ItemType>::value, "ItemType must be derived from DraftItem");
+
+	public:
+		using item_type = ItemType;
+
+	public:
+		DraftItemRenderer(const item_type* item, const RendererStyle& rendererStyle);
+
+	protected:
+		QPoint getItemPosition(RenderMode mode) const;
+
+		QPen createStandardPen(int lineWidth) const;
+		QPen createSelectionPen(int lineWidth, bool inactive = false) const;
+
+	protected:
+		void renderDirectionArrow(QPainter* painter, QPoint centerPos);
+
+	protected:
+		const item_type* _draftItem{nullptr};
+	};
+}
+
+#include "DraftItemRenderer.impl.h"
+
+#endif
diff --git a/Grinder/image/DraftItemRenderer.impl.h b/Grinder/image/DraftItemRenderer.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..83553ed2b6f116e5b9ffeceaf2c3d2abf6179fd2
--- /dev/null
+++ b/Grinder/image/DraftItemRenderer.impl.h
@@ -0,0 +1,104 @@
+/******************************************************************************
+ * File: DraftItemRenderer.impl.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DraftItemRenderer.h"
+#include "util/MathUtils.h"
+
+template<typename ItemType>
+DraftItemRenderer<ItemType>::DraftItemRenderer(const item_type* item, const RendererStyle& rendererStyle) : DraftItemRendererBase(rendererStyle),
+	_draftItem{item}
+{
+	if (!item)
+		throw std::invalid_argument{_EXCPT("item may not be null")};
+}
+
+template<typename ItemType>
+QPoint DraftItemRenderer<ItemType>::getItemPosition(RenderMode mode) const
+{
+	QPoint pos{0, 0};
+
+	if (mode.testFlag(RenderModeFlag::AbsolutePositions))
+		pos = *_draftItem->position();
+
+	return pos;
+}
+
+template<typename ItemType>
+QPen DraftItemRenderer<ItemType>::createStandardPen(int lineWidth) const
+{
+	QPen pen{*_draftItem->primaryColor()};
+	pen.setWidth(lineWidth);
+	pen.setCapStyle(Qt::FlatCap);
+	pen.setJoinStyle(Qt::MiterJoin);
+
+	return pen;
+}
+
+template<typename ItemType>
+QPen DraftItemRenderer<ItemType>::createSelectionPen(int lineWidth, bool inactive) const
+{
+	auto selectionPen = createStandardPen(lineWidth + _rendererStyle.selectionMargin);
+	selectionPen.setColor(inactive ? _rendererStyle.selectionColorInactive : _rendererStyle.selectionColor);
+	selectionPen.setCapStyle(Qt::SquareCap);
+
+	return selectionPen;
+}
+
+template<typename ItemType>
+void DraftItemRenderer<ItemType>::renderDirectionArrow(QPainter* painter, QPoint centerPos)
+{
+	painter->save();
+
+	QPainterPath path(centerPos);
+
+	// Calculate the arrow direction vector
+	auto angle = ((360.0 - *_draftItem->direction()) / 180.0) * M_PI;
+	QPointF arrowDir{std::cos(angle) * _rendererStyle.directionArrowLength, std::sin(angle) * _rendererStyle.directionArrowLength};
+
+	// Calculate the arrow start and end positions
+	QPoint startPos = centerPos;
+	QPoint endPos = centerPos + MathUtils::round(arrowDir);
+
+	// Add the arrow base to the painter path
+	path.lineTo(endPos);
+
+	// Add the arrow head to the painter path
+	QPointF arrowPoly[3];
+	qreal vecLine[2];
+	qreal vecLeft[2];
+
+	arrowPoly[0] = endPos;
+
+	vecLine[0] = arrowPoly[0].x() - startPos.x();
+	vecLine[1] = arrowPoly[0].y() - startPos.y();
+
+	vecLeft[0] = -vecLine[1];
+	vecLeft[1] = vecLine[0];
+
+	qreal length = std::sqrt(vecLine[0] * vecLine[0] + vecLine[1] * vecLine[1]);
+	qreal th = _rendererStyle.directionArrowWidth / length;
+	qreal ta = _rendererStyle.directionArrowWidth / ((std::tan(M_PI / 3.0) / 2.0) * length);
+	auto base = QPointF{arrowPoly[0].x() + -ta * vecLine[0], arrowPoly[0].y() + -ta * vecLine[1]};
+
+	arrowPoly[1].setX(base.x() + th * vecLeft[0]);
+	arrowPoly[1].setY(base.y() + th * vecLeft[1]);
+	arrowPoly[2].setX(base.x() - th * vecLeft[0]);
+	arrowPoly[2].setY(base.y() - th * vecLeft[1]);
+
+	path.addPolygon(QPolygonF{} << arrowPoly[0] << arrowPoly[1] << arrowPoly[2]);
+	path.closeSubpath();
+
+	// Draw the arrow
+	QPen pen = createStandardPen(_rendererStyle.directionArrowWidth);
+	pen.setColor(_rendererStyle.directionArrowColor);
+
+	painter->setRenderHints(QPainter::Antialiasing|QPainter::TextAntialiasing|QPainter::HighQualityAntialiasing|QPainter::SmoothPixmapTransform);
+	painter->setPen(pen);
+	painter->setOpacity(_rendererStyle.directionArrowOpacity);
+	painter->drawPath(path);
+
+	painter->restore();
+}
diff --git a/Grinder/image/DraftItemRendererBase.cpp b/Grinder/image/DraftItemRendererBase.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d40c4bab50c9114ed52e48c0d6d9a41be1f6a581
--- /dev/null
+++ b/Grinder/image/DraftItemRendererBase.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: DraftItemRendererBase.cpp
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DraftItemRendererBase.h"
+
+DraftItemRendererBase::DraftItemRendererBase(const RendererStyle& rendererStyle) :
+	_rendererStyle{rendererStyle}
+{
+
+}
diff --git a/Grinder/image/DraftItemRendererBase.h b/Grinder/image/DraftItemRendererBase.h
new file mode 100644
index 0000000000000000000000000000000000000000..1ea587530b50a9f737201aa5e83ea4c8cb67dd96
--- /dev/null
+++ b/Grinder/image/DraftItemRendererBase.h
@@ -0,0 +1,70 @@
+/******************************************************************************
+ * File: DraftItemRendererBase.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef DRAFTITEMRENDERERBASE_H
+#define DRAFTITEMRENDERERBASE_H
+
+#include <QPainter>
+#include <QPalette>
+#include <memory>
+
+namespace grndr
+{
+	class ImageEditorStyle;
+
+	class DraftItemRendererBase
+	{
+	public:
+		enum class RenderModeFlag : unsigned int
+		{
+			RelativePositions = 0x0000,
+			AbsolutePositions = 0x0001,
+
+			RenderToImage = AbsolutePositions,
+			RenderToScene = RelativePositions,
+		};
+
+		Q_DECLARE_FLAGS(RenderMode, RenderModeFlag)
+
+		enum class RenderFlag : unsigned int
+		{
+			NoFlag = 0x0000,
+			Selected = 0x0001,
+			Inactive = 0x0002,
+			ShowDirections = 0x0004,
+			ShowTags = 0x0008,
+		};
+
+		Q_DECLARE_FLAGS(RenderFlags, RenderFlag)
+
+		struct RendererStyle
+		{
+			QColor selectionColor{QPalette{}.highlight().color().lighter()};
+			QColor selectionColorInactive{QPalette{}.color(QPalette::Inactive, QPalette::Highlight).darker()};
+			float selectionMargin{6.0f};
+			float selectionOpacity{0.6f};
+
+			float directionArrowLength{40.0};
+			QColor directionArrowColor{0, 255, 128};
+			float directionArrowWidth{3.5f};
+			float directionArrowOpacity{0.8f};
+		};
+
+	public:
+		DraftItemRendererBase(const RendererStyle& rendererStyle);
+
+	public:
+		virtual void render(QPainter* painter, RenderMode mode, RenderFlags flags) = 0;
+		virtual QPainterPath shape() const { return QPainterPath{}; }
+
+	protected:
+		RendererStyle _rendererStyle;
+	};
+}
+
+Q_DECLARE_OPERATORS_FOR_FLAGS(grndr::DraftItemRendererBase::RenderMode)
+Q_DECLARE_OPERATORS_FOR_FLAGS(grndr::DraftItemRendererBase::RenderFlags)
+
+#endif
diff --git a/Grinder/image/DraftItemType.cpp b/Grinder/image/DraftItemType.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d861564dc7f02188190894fdefe47320c6d5aad7
--- /dev/null
+++ b/Grinder/image/DraftItemType.cpp
@@ -0,0 +1,12 @@
+/******************************************************************************
+ * File: DraftItemType.cpp
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DraftItemType.h"
+
+const char* DraftItemType::Undefined = "";
+
+const char* DraftItemType::Line = "Line";
+const char* DraftItemType::Box = "Box";
diff --git a/Grinder/image/DraftItemType.h b/Grinder/image/DraftItemType.h
new file mode 100644
index 0000000000000000000000000000000000000000..b04e6ac2f91b3dff68fbd52d446b80dd9a7c8dee
--- /dev/null
+++ b/Grinder/image/DraftItemType.h
@@ -0,0 +1,35 @@
+/******************************************************************************
+ * File: DraftItemType.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#ifndef DRAFTITEMTYPE_H
+#define DRAFTITEMTYPE_H
+
+#include <QString>
+
+namespace grndr
+{
+	class DraftItemType final : public QString
+	{
+	public:
+		static const char* Undefined; /* Invalid draft item */
+
+		static const char* Line;
+		static const char* Box;
+
+	public:
+		using QString::QString;
+
+		DraftItemType() = default;
+		DraftItemType(const DraftItemType& other) = default;
+		DraftItemType(DraftItemType&& other) = default;
+		DraftItemType(const QString& str) { *static_cast<QString*>(this) = str; }
+
+		DraftItemType& operator =(const DraftItemType& other) = default;
+		DraftItemType& operator =(DraftItemType&& other) = default;
+		DraftItemType& operator =(const QString& str) { *static_cast<QString*>(this) = str; return *this; }
+	};
+}
+
+#endif
diff --git a/Grinder/image/DraftItemVector.cpp b/Grinder/image/DraftItemVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c9eecee6af2a2690b3febd5354d59b00678da71a
--- /dev/null
+++ b/Grinder/image/DraftItemVector.cpp
@@ -0,0 +1,22 @@
+/******************************************************************************
+ * File: DraftItemVector.cpp
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DraftItemVector.h"
+
+const char* DraftItemVector::Serialization_Group = "DraftItems";
+const char* DraftItemVector::Serialization_Element = "DraftItem";
+
+DraftItemVector::DraftItemVector(const Layer* layer) :
+	_layer{layer}
+{
+	if (!layer)
+		throw std::invalid_argument{_EXCPT("layer may not be null")};
+}
+
+DraftItemVector::pointer_vec_type DraftItemVector::selectByType(DraftItemType type) const
+{
+	return select([type](auto item) { return item->getType() == type; });
+}
diff --git a/Grinder/image/DraftItemVector.h b/Grinder/image/DraftItemVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..f409493613462ee399cb8b6ba14b318e6acd80f2
--- /dev/null
+++ b/Grinder/image/DraftItemVector.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: DraftItemVector.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#ifndef DRAFTITEMVECTOR_H
+#define DRAFTITEMVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "DraftItem.h"
+
+namespace grndr
+{
+	class DraftItemVector : public ObjectVector<DraftItem>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		DraftItemVector(const Layer* layer);
+
+	public:
+		pointer_vec_type selectByType(DraftItemType type) const;
+
+	private:
+		const Layer* _layer{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/image/ImageBuild.cpp b/Grinder/image/ImageBuild.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a035a70c6fcac9a77c69f01f83eb930af6f1a0c4
--- /dev/null
+++ b/Grinder/image/ImageBuild.cpp
@@ -0,0 +1,112 @@
+/******************************************************************************
+ * File: ImageBuild.cpp
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageBuild.h"
+#include "ImageExceptions.h"
+
+const char* ImageBuild::Serialization_Value_ImageReference = "ImageReference";
+
+ImageBuild::ImageBuild(const ImageReference* imageReference) :
+	_imageReference{imageReference}, _layers{this}
+{
+	if (!imageReference)
+		throw std::invalid_argument{_EXCPT("imageReference may not be null")};
+}
+
+std::shared_ptr<Layer> ImageBuild::createLayer(QString name)
+{
+	// Create new block using the block factory; cast the unique ptr to a shared one as well
+	auto layer = std::make_shared<Layer>(this, name);
+
+	try {	// Propagate initialization errors to the caller
+		layer->initLayer();
+	}
+	catch (...) {
+		throw;
+	}
+
+	_layers.push_back(layer);
+	emit layerCreated(layer);
+
+	return layer;
+}
+
+void ImageBuild::removeLayer(const Layer* layer)
+{
+	if (layer)
+	{
+		auto it = _layers.find(layer);
+
+		if (it != _layers.cend())
+		{
+			// Keep a copy of the shared_ptr holding the layer to increase its use count;
+			// otherwise, the layer will be deleted before it has been removed from the vector, potentially causing a crash
+			auto layer = *it;
+
+			emit layerRemoved(*it);
+			_layers.erase(it);
+		}
+		else
+			throw ImageBuildException{this, _EXCPT("Tried to remove a layer not belonging to this image build")};
+	}
+}
+
+void ImageBuild::moveLayer(const Layer* layer, bool up)
+{
+	int index = _layers.indexOf(layer);
+
+	if (index != -1)
+	{
+		int indexNew = index + (up ? 1 : -1);	// Moving up means moving the layer to a higher index
+
+		if (indexNew < 0)
+			indexNew = 0;
+		else if (indexNew >= static_cast<int>(_layers.size()))
+			indexNew = _layers.size() - 1;
+
+		if (indexNew != index)
+		{
+			std::swap(_layers[index], _layers[indexNew]);
+			emit layerMoved(*_layers.find(layer), index, indexNew);
+		}
+	}
+	else
+		throw ImageBuildException{this, _EXCPT("Tried to move a layer not belonging to this image build")};
+}
+
+void ImageBuild::serialize(SerializationContext& ctx) const
+{
+	// Serialize values
+	ctx.settings()[Serialization_Value_ImageReference] = ctx.getImageReferenceIndex(_imageReference);
+
+	// Serialize all layers
+	ctx.beginGroup(LayerVector::Serialization_Group, true);
+	_layers.serialize(LayerVector::Serialization_Element, ctx);
+	ctx.endGroup();
+}
+
+void ImageBuild::deserialize(DeserializationContext& ctx)
+{
+	// Deserialize values
+	int imageRefIndex = ctx.settings()[Serialization_Value_ImageReference].toInt();
+
+	if (imageRefIndex != -1)
+	{
+		if (auto imageRef = ctx.getImageReference(imageRefIndex))
+			_imageReference = imageRef;
+	}
+
+	// Deserialize all layers
+	if (ctx.beginGroup(LayerVector::Serialization_Group))
+	{
+		_layers.deserialize(LayerVector::Serialization_Element, ctx, [this](const SettingsContainer& settings) {
+			QString name = settings[Layer::Serialization_Value_Name].toString();
+			return createLayer(name);
+		});
+
+		ctx.endGroup();
+	}
+}
diff --git a/Grinder/image/ImageBuild.h b/Grinder/image/ImageBuild.h
new file mode 100644
index 0000000000000000000000000000000000000000..4d6ac052255a850cadd6f51524c4eaf082738a58
--- /dev/null
+++ b/Grinder/image/ImageBuild.h
@@ -0,0 +1,61 @@
+/******************************************************************************
+ * File: ImageBuild.h
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEBUILD_H
+#define IMAGEBUILD_H
+
+#include <opencv2/core.hpp>
+
+#include "LayerVector.h"
+
+namespace grndr
+{
+	class ImageReference;
+
+	class ImageBuild : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_ImageReference;
+
+	public:
+		ImageBuild(const ImageReference* imageReference);
+
+	public:
+		std::shared_ptr<Layer> createLayer(QString name = "");
+		void removeLayer(const Layer* layer);
+
+		void moveLayer(const Layer* layer, bool up);
+
+	public:
+		const ImageReference* imageReference() const { return _imageReference; }
+
+		const cv::Mat& imageData() const { return _imageData; }
+		void setImageData(const cv::Mat& data) { _imageData = data; emit imageDataChanged(); }
+		void clearImageData() { _imageData.release(); emit imageDataChanged(); }
+
+		const LayerVector& layers() const { return _layers; }
+
+	public:
+		void serialize(SerializationContext& ctx) const;
+		void deserialize(DeserializationContext& ctx);
+
+	signals:
+		void layerCreated(const std::shared_ptr<Layer>&);
+		void layerRemoved(const std::shared_ptr<Layer>&);
+		void layerMoved(const std::shared_ptr<Layer>&, int, int);
+
+		void imageDataChanged();	
+
+	private:
+		const ImageReference* _imageReference{nullptr};
+		cv::Mat _imageData;
+
+		LayerVector _layers;
+	};
+}
+
+#endif
diff --git a/Grinder/image/ImageBuildItem.cpp b/Grinder/image/ImageBuildItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..fe785e444f7498816172b3c61b6cd5d2426534d3
--- /dev/null
+++ b/Grinder/image/ImageBuildItem.cpp
@@ -0,0 +1,32 @@
+/******************************************************************************
+ * File: ImageBuildItem.cpp
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageBuildItem.h"
+
+const char* ImageBuildItem::Serialization_Value_Name = "Name";
+
+ImageBuildItem::ImageBuildItem(ImageBuild* imageBuild, QString name) :
+	_imageBuild{imageBuild}, _name{name}
+{
+
+}
+
+void ImageBuildItem::initImageBuildItem()
+{
+
+}
+
+void ImageBuildItem::serialize(SerializationContext& ctx) const
+{
+	// Serialize values
+	ctx.settings()[Serialization_Value_Name] = _name;
+}
+
+void ImageBuildItem::deserialize(DeserializationContext& ctx)
+{
+	// Deserialize values
+	_name = ctx.settings()[Serialization_Value_Name].toString();
+}
diff --git a/Grinder/image/ImageBuildItem.h b/Grinder/image/ImageBuildItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..91b224b608e485ead3b66f8b376136d1773d1d35
--- /dev/null
+++ b/Grinder/image/ImageBuildItem.h
@@ -0,0 +1,52 @@
+/******************************************************************************
+ * File: ImageBuildItem.h
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEBUILDITEM_H
+#define IMAGEBUILDITEM_H
+
+#include <QObject>
+
+#include "project/serialization/SerializationContext.h"
+#include "project/serialization/DeserializationContext.h"
+
+namespace grndr
+{
+	class ImageBuild;
+
+	class ImageBuildItem : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_Name;
+
+	public:
+		ImageBuildItem(ImageBuild* imageBuild, QString name = "");
+
+	public:
+		void initImageBuildItem();
+
+	public:
+		const ImageBuild* imageBuild() const { return _imageBuild; }
+		ImageBuild* imageBuild() { return _imageBuild; }
+
+		QString getName() const { return _name; }
+		void setName(QString name) { if (_name != name) { _name = name; emit itemRenamed(); } }
+
+	public:
+		virtual void serialize(SerializationContext& ctx) const;
+		virtual void deserialize(DeserializationContext& ctx);
+
+	signals:
+		void itemRenamed();
+
+	protected:
+		ImageBuild* _imageBuild{nullptr};
+
+		QString _name{""};
+	};
+}
+
+#endif
diff --git a/Grinder/image/ImageBuildPool.cpp b/Grinder/image/ImageBuildPool.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..92c8c38715bd7388490eca8a460bd7b8a899f8da
--- /dev/null
+++ b/Grinder/image/ImageBuildPool.cpp
@@ -0,0 +1,183 @@
+/******************************************************************************
+ * File: ImageBuildPool.cpp
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageBuildPool.h"
+#include "ImageExceptions.h"
+#include "project/Project.h"
+
+const char* ImageBuildPool::Serialization_Group_Item = "Item";
+
+const char* ImageBuildPool::Serialization_Value_Block = "Block";
+
+ImageBuildPool::ImageBuildPool(Label* label)
+{
+	if (!label)
+		throw std::invalid_argument{_EXCPT("label may not be null")};
+
+	// Whenever a block or image reference is removed, remove the associated image builds
+	connect(label->pipeline(), &Pipeline::blockRemoved, this, &ImageBuildPool::blockRemoved);
+	connect(label->project(), &Project::imageReferenceRemoved, this, &ImageBuildPool::imageReferenceRemoved);
+}
+
+const ImageBuildVector* ImageBuildPool::imageBuilds(const Block* block) const
+{
+	if (_imageBuilds.find(block) != _imageBuilds.cend())
+		return _imageBuilds.at(block).get();
+	else
+		return nullptr;
+}
+
+void ImageBuildPool::removeImageBuilds(const Block* block)
+{
+	if (block)
+	{
+		auto it = _imageBuilds.find(block);
+
+		if (it != _imageBuilds.end())
+		{
+			// Send a removal notification for each image build in the build vector
+			for (auto& build : *it->second)
+				emit imageBuildRemoved(build);
+
+			_imageBuilds.erase(it);
+		}
+		else
+			throw ImageBuildException{nullptr, _EXCPT("Tried to remove the image builds of a block not belonging to this pool")};
+	}
+}
+
+void ImageBuildPool::removeImageBuilds(const ImageReference* imageRef)
+{
+	for (auto& entry : _imageBuilds)
+	{
+		if (entry.second->selectByImageReference(imageRef))	// Only remove existing image builds
+			removeImageBuild(entry.first, imageRef);
+	}
+}
+
+std::shared_ptr<ImageBuild> ImageBuildPool::imageBuild(const Block* block, const ImageReference* imageRef)
+{
+	if (auto buildVector = imageBuildVector(block))
+		return imageBuild(buildVector, imageRef);
+	else
+		throw ImageBuildException{nullptr, _EXCPT("Unable to create an image build")};
+}
+
+void ImageBuildPool::removeImageBuild(const Block* block, const ImageReference* imageRef)
+{
+	if (block && imageRef)
+	{
+		auto blockIt = _imageBuilds.find(block);
+
+		if (blockIt != _imageBuilds.end())
+		{
+			if (auto build = blockIt->second->selectByImageReference(imageRef))
+			{
+				auto it = blockIt->second->find(build);	// Should always be valid
+
+				emit imageBuildRemoved(*it);
+				blockIt->second->erase(it);
+			}
+			else
+				throw ImageBuildException{nullptr, _EXCPT("Tried to remove an image build not belonging to this pool")};
+		}
+		else
+			throw ImageBuildException{nullptr, _EXCPT("Tried to remove an image build from a block not belonging to this pool")};
+	}
+}
+
+void ImageBuildPool::serialize(SerializationContext& ctx) const
+{
+	// Serialize all image builds
+	for (const auto& build : _imageBuilds)
+	{
+		ctx.beginGroup(Serialization_Group_Item);
+		ctx.settings()[Serialization_Value_Block] = ctx.getBlockIndex(build.first);
+
+		ctx.beginGroup(ImageBuildVector::Serialization_Group, true);
+		build.second->serialize(ImageBuildVector::Serialization_Element, ctx);
+		ctx.endGroup();
+
+		ctx.endGroup();
+	}
+}
+
+void ImageBuildPool::deserialize(DeserializationContext& ctx)
+{
+	// Deserialize all image builds
+	for (const auto elemSettings : ctx.settings().children(Serialization_Group_Item))
+	{
+		ctx.beginGroup(elemSettings);
+		int blockIndex = ctx.settings()[Serialization_Value_Block].toInt();
+
+		if (auto block = ctx.getBlock(blockIndex))
+		{
+			auto buildVector = imageBuildVector(block);
+
+			if (ctx.beginGroup(ImageBuildVector::Serialization_Group))
+			{
+				buildVector->deserialize(ImageBuildVector::Serialization_Element, ctx, [&ctx, &buildVector, this](const SettingsContainer& settings) -> std::shared_ptr<ImageBuild> {
+					int imageIndex = settings[ImageBuild::Serialization_Value_ImageReference].toInt();
+
+					if (auto imageReference = ctx.getImageReference(imageIndex))
+						return imageBuild(buildVector, imageReference);
+					else
+						return nullptr;
+				});
+
+				ctx.endGroup();
+			}
+		}
+
+		ctx.endGroup();
+	}
+}
+
+std::shared_ptr<ImageBuildVector> ImageBuildPool::imageBuildVector(const Block* block)
+{
+	// Create a new build vector if necessary
+	if (_imageBuilds.find(block) == _imageBuilds.cend())
+	{
+		auto buildVector = std::make_shared<ImageBuildVector>();
+		_imageBuilds.emplace(block, buildVector);
+
+		return buildVector;
+	}
+	else
+		return _imageBuilds.at(block);
+}
+
+std::shared_ptr<ImageBuild> ImageBuildPool::imageBuild(std::shared_ptr<ImageBuildVector>& builds, const ImageReference* imageRef)
+{
+	// Create a new build if necessary
+	if (!builds->selectByImageReference(imageRef))
+	{
+		auto build = std::make_shared<ImageBuild>(imageRef);
+
+		builds->push_back(build);
+		emit imageBuildCreated(build);
+
+		return build;
+	}
+	else
+	{
+		auto build = builds->selectByImageReference(imageRef);
+		return *builds->find(build);
+	}
+}
+
+void ImageBuildPool::blockRemoved(const std::shared_ptr<Block>& block)
+{
+	// When a block is deleted, remove all associated image builds
+	if (_imageBuilds.find(block.get()) != _imageBuilds.end())	// Only remove image builds of blocks currently in the pool
+		removeImageBuilds(block.get());
+}
+
+void ImageBuildPool::imageReferenceRemoved(const std::shared_ptr<ImageReference>& imageRef)
+{
+	// When an image reference is deleted, remove all associated image builds
+	removeImageBuilds(imageRef.get());
+}
diff --git a/Grinder/image/ImageBuildPool.h b/Grinder/image/ImageBuildPool.h
new file mode 100644
index 0000000000000000000000000000000000000000..ff3741863facd95efdaa5d22b444c8c269899cfd
--- /dev/null
+++ b/Grinder/image/ImageBuildPool.h
@@ -0,0 +1,57 @@
+/******************************************************************************
+ * File: ImageBuildPool.h
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEBUILDPOOL_H
+#define IMAGEBUILDPOOL_H
+
+#include "ImageBuildVector.h"
+
+namespace grndr
+{
+	class Label;
+	class Block;
+
+	class ImageBuildPool : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Group_Item;
+
+		static const char* Serialization_Value_Block;
+
+	public:
+		ImageBuildPool(Label* label);
+
+	public:
+		const ImageBuildVector* imageBuilds(const Block* block) const;
+		void removeImageBuilds(const Block* block);
+		void removeImageBuilds(const ImageReference* imageRef);
+
+		std::shared_ptr<ImageBuild> imageBuild(const Block* block, const ImageReference* imageRef);
+		void removeImageBuild(const Block* block, const ImageReference* imageRef);
+
+	public:
+		void serialize(SerializationContext& ctx) const;
+		void deserialize(DeserializationContext& ctx);
+
+	signals:
+		void imageBuildCreated(const std::shared_ptr<ImageBuild>&);
+		void imageBuildRemoved(const std::shared_ptr<ImageBuild>&);
+
+	private:
+		std::shared_ptr<ImageBuildVector> imageBuildVector(const Block* block);
+		std::shared_ptr<ImageBuild> imageBuild(std::shared_ptr<ImageBuildVector>& builds, const ImageReference* imageRef);
+
+	private slots:
+		void blockRemoved(const std::shared_ptr<Block>& block);
+		void imageReferenceRemoved(const std::shared_ptr<ImageReference>& imageRef);
+
+	private:
+		std::map<const Block*, std::shared_ptr<ImageBuildVector>> _imageBuilds;
+	};
+}
+
+#endif
diff --git a/Grinder/image/ImageBuildVector.cpp b/Grinder/image/ImageBuildVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d59e10003ed8214047f532724450b1df51a3031d
--- /dev/null
+++ b/Grinder/image/ImageBuildVector.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: ImageBuildVector.cpp
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageBuildVector.h"
+
+const char* ImageBuildVector::Serialization_Group = "ImageBuilds";
+const char* ImageBuildVector::Serialization_Element = "ImageBuild";
+
+ImageBuildVector::pointer_type ImageBuildVector::selectByImageReference(const ImageReference* imageRef) const
+{
+	return selectFirst([imageRef](auto build) { return build->imageReference() == imageRef; });
+}
diff --git a/Grinder/image/ImageBuildVector.h b/Grinder/image/ImageBuildVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..00479511a623db7541315bd0eb86766b2ae0a6fa
--- /dev/null
+++ b/Grinder/image/ImageBuildVector.h
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: ImageBuildVector.h
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEBUILDVECTOR_H
+#define IMAGEBUILDVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "ImageBuild.h"
+
+namespace grndr
+{
+	class ImageBuildVector : public ObjectVector<ImageBuild>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		pointer_type selectByImageReference(const ImageReference* imageRef) const;
+	};
+}
+
+#endif
diff --git a/Grinder/image/ImageExceptions.cpp b/Grinder/image/ImageExceptions.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..80553638260645367498005032fbf47ae42c00a6
--- /dev/null
+++ b/Grinder/image/ImageExceptions.cpp
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: ImageExceptions.cpp
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageExceptions.h"
+#include "Layer.h"
+
+ImageException::ImageException(QString what) : GrinderException(what)
+{
+
+}
+
+ImageBuildException::ImageBuildException(const ImageBuild* imageBuild, QString what) : ImageException(what),
+	_imageBuild{imageBuild}
+{
+
+}
+
+ImageEditorException::ImageEditorException(const ImageEditor* imageEditor, QString what) : ImageException(what),
+	_imageEditor{imageEditor}
+{
+
+}
+
+LayerException::LayerException(const Layer* layer, QString what) : ImageBuildException(layer ? layer->imageBuild() : nullptr, what),
+	_layer{layer}
+{
+
+}
diff --git a/Grinder/image/ImageExceptions.h b/Grinder/image/ImageExceptions.h
new file mode 100644
index 0000000000000000000000000000000000000000..1079c39d68dee738bc6cae388fc0be148a4d7177
--- /dev/null
+++ b/Grinder/image/ImageExceptions.h
@@ -0,0 +1,60 @@
+/******************************************************************************
+ * File: ImageExceptions.h
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEXCEPTIONS_H
+#define IMAGEEXCEPTIONS_H
+
+#include "core/GrinderExceptions.h"
+
+namespace grndr
+{
+	class ImageBuild;
+	class ImageEditor;
+	class Layer;
+
+	class ImageException : public GrinderException
+	{
+	public:
+		ImageException(QString what);
+	};
+
+	class ImageBuildException : public ImageException
+	{
+	public:
+		ImageBuildException(const ImageBuild* imageBuild, QString what);
+
+	public:
+		const ImageBuild* imageBuild() const { return _imageBuild; }
+
+	private:
+		const ImageBuild* _imageBuild{nullptr};
+	};
+
+	class ImageEditorException : public ImageException
+	{
+	public:
+		ImageEditorException(const ImageEditor* imageEditor, QString what);
+
+	public:
+		const ImageEditor* imageEditor() const { return _imageEditor; }
+
+	private:
+		const ImageEditor* _imageEditor{nullptr};
+	};
+
+	class LayerException : public ImageBuildException
+	{
+	public:
+		LayerException(const Layer* layer, QString what);
+
+	public:
+		const Layer* imageEditor() const { return _layer; }
+
+	private:
+		const Layer* _layer{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/image/Layer.cpp b/Grinder/image/Layer.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..53f3be1cdf99e8bf4baf8b9a47312129cab2e0b2
--- /dev/null
+++ b/Grinder/image/Layer.cpp
@@ -0,0 +1,115 @@
+/******************************************************************************
+ * File: Layer.cpp
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Layer.h"
+#include "ImageBuild.h"
+#include "ImageExceptions.h"
+#include "DraftItemCatalog.h"
+
+const char* Layer::Serialization_Value_Visible = "Visible";
+
+Layer::Layer(ImageBuild* imageBuild, QString name) : ImageBuildItem(imageBuild, name),
+	_draftItems{this}
+{
+
+}
+
+void Layer::initLayer()
+{
+	ImageBuildItem::initImageBuildItem();
+}
+
+std::shared_ptr<DraftItem> Layer::createDraftItem(DraftItemType type)
+{
+	if (type == DraftItemType::Undefined)
+		throw std::invalid_argument(_EXCPT("type may not be DraftItemType::Undefined"));
+
+	// Create new block using the block factory; cast the unique ptr to a shared one as well
+	std::shared_ptr<DraftItem> item = DraftItemCatalog::createDraftItem(this, type);
+
+	try {	// Propagate initialization errors to the caller
+		item->initDraftItem();
+	}
+	catch (...) {
+		throw;
+	}
+
+	_draftItems.push_back(item);
+	emit draftItemCreated(item);
+
+	return item;
+}
+
+void Layer::removeDraftItem(const DraftItem* item)
+{
+	if (item)
+	{
+		auto it = _draftItems.find(item);
+
+		if (it != _draftItems.cend())
+		{
+			// Keep a copy of the shared_ptr holding the draft item to increase its use count;
+			// otherwise, the draft item will be deleted before it has been removed from the vector, potentially causing a crash
+			auto item = *it;
+
+			emit draftItemRemoved(*it);
+			_draftItems.erase(it);
+		}
+		else
+			throw LayerException{this, _EXCPT("Tried to remove a draft item not belonging to this layer")};
+	}
+}
+
+int Layer::getZOrder() const
+{
+	return _imageBuild->layers().indexOf(this);
+}
+
+void Layer::setVisible(bool visible)
+{
+	if (visible != _isVisible)
+	{
+		_isVisible = visible;
+
+		if (_isVisible)
+			emit layerShown();
+		else
+			emit layerHidden();
+	}
+}
+
+void Layer::serialize(SerializationContext& ctx) const
+{
+	ImageBuildItem::serialize(ctx);
+
+	// Serialize values
+	ctx.settings()[Serialization_Value_Visible] = _isVisible;
+
+	// Serialize all draft items
+	ctx.beginGroup(DraftItemVector::Serialization_Group, true);
+	_draftItems.serialize(DraftItemVector::Serialization_Element, ctx);
+	ctx.endGroup();
+}
+
+void Layer::deserialize(DeserializationContext& ctx)
+{
+	ImageBuildItem::deserialize(ctx);
+
+	// Deserialize values
+	_isVisible = ctx.settings()[Serialization_Value_Visible].toBool();
+
+	// Deserialize all draft items
+	if (ctx.beginGroup(DraftItemVector::Serialization_Group))
+	{
+		_draftItems.deserialize(DraftItemVector::Serialization_Element, ctx, [this](const SettingsContainer& settings) {
+			DraftItemType type = settings[DraftItem::Serialization_Value_Type].toString();
+
+			return createDraftItem(type);
+		});
+
+		ctx.endGroup();
+	}
+}
diff --git a/Grinder/image/Layer.h b/Grinder/image/Layer.h
new file mode 100644
index 0000000000000000000000000000000000000000..d0f70425d86dd38c599048de115eb216a320abfa
--- /dev/null
+++ b/Grinder/image/Layer.h
@@ -0,0 +1,57 @@
+/******************************************************************************
+ * File: Layer.h
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#ifndef LAYER_H
+#define LAYER_H
+
+#include "ImageBuildItem.h"
+#include "DraftItemVector.h"
+
+namespace grndr
+{
+	class Layer : public ImageBuildItem
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_Visible;
+
+	public:
+		Layer(ImageBuild* imageBuild, QString name = "");
+
+	public:
+		void initLayer();
+
+	public:
+		std::shared_ptr<DraftItem> createDraftItem(DraftItemType type);
+		void removeDraftItem(const DraftItem* item);
+
+	public:
+		int getZOrder() const;
+
+		bool isVisible() const { return _isVisible; }
+		void setVisible(bool visible = true);
+
+		const DraftItemVector& draftItems() const { return _draftItems; }
+
+	public:
+		virtual void serialize(SerializationContext& ctx) const override;
+		virtual void deserialize(DeserializationContext& ctx) override;
+
+	signals:
+		void layerShown();
+		void layerHidden();
+
+		void draftItemCreated(const std::shared_ptr<DraftItem>&);
+		void draftItemRemoved(const std::shared_ptr<DraftItem>&);
+
+	private:
+		bool _isVisible{true};
+
+		DraftItemVector _draftItems;
+	};
+}
+
+#endif
diff --git a/Grinder/image/LayerVector.cpp b/Grinder/image/LayerVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9a989af150634a4c39b6cc104f4428581d3df44d
--- /dev/null
+++ b/Grinder/image/LayerVector.cpp
@@ -0,0 +1,22 @@
+/******************************************************************************
+ * File: LayerVector.cpp
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LayerVector.h"
+
+const char* LayerVector::Serialization_Group = "Layers";
+const char* LayerVector::Serialization_Element = "Layer";
+
+LayerVector::LayerVector(const ImageBuild* imageBuild) :
+	_imageBuild{imageBuild}
+{
+	if (!imageBuild)
+		throw std::invalid_argument{_EXCPT("imageBuild may not be null")};
+}
+
+LayerVector::pointer_type LayerVector::selectByName(QString name, bool caseSensitive) const
+{
+	return selectFirst([name, caseSensitive](auto layer) { return layer->getName().compare(name, caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive) == 0; });
+}
diff --git a/Grinder/image/LayerVector.h b/Grinder/image/LayerVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..678bb0d93cf82035187e79667b2d084aad29ccf5
--- /dev/null
+++ b/Grinder/image/LayerVector.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: LayerVector.h
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#ifndef LAYERVECTOR_H
+#define LAYERVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "Layer.h"
+
+namespace grndr
+{
+	class LayerVector : public ObjectVector<Layer>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		LayerVector(const ImageBuild* imageBuild);
+
+	public:
+		pointer_type selectByName(QString name, bool caseSensitive = false) const;
+
+	private:
+		const ImageBuild* _imageBuild{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/image/draftitems/BoxDraftItem.cpp b/Grinder/image/draftitems/BoxDraftItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9b09637196ccfa66577de5e7c1674fe5982e6331
--- /dev/null
+++ b/Grinder/image/draftitems/BoxDraftItem.cpp
@@ -0,0 +1,63 @@
+/******************************************************************************
+ * File: BoxDraftItem.cpp
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BoxDraftItem.h"
+#include "BoxDraftItemRenderer.h"
+#include "common/properties/RangeConstraint.h"
+
+const DraftItemType BoxDraftItem::type_value = DraftItemType::Box;
+
+BoxDraftItem::BoxDraftItem(Layer* layer) : DraftItem(layer, type_value)
+{
+
+}
+
+std::unique_ptr<DraftItemRendererBase> BoxDraftItem::createRenderer(const DraftItemRendererBase::RendererStyle& rendererStyle) const
+{
+	return std::make_unique<BoxDraftItemRenderer>(this, rendererStyle);
+}
+
+void BoxDraftItem::createProperties()
+{
+	DraftItem::createProperties();
+
+	// Create specific properties
+	_boxSize = createProperty<SizeProperty>(PropertyID::Size, "Size", QSize{50, 50});
+	boxSize()->setDescription("The size of the box.");
+
+	_lineWidth = createProperty<UIntProperty>(PropertyID::LineWidth, "Line width", 5);
+	lineWidth()->createConstraint<RangeConstraint>(1, 100);
+	lineWidth()->setDescription("The width of the box lines.");
+
+	// Override some property defaults
+	hasDirection()->setValue(true);
+}
+
+void BoxDraftItem::setDragPropertyValues(QPoint initialPos, QPoint currentPos)
+{
+	auto vec = currentPos - initialPos;
+	QSize size{vec.x(), vec.y()};
+	boxSize()->setValue(size);
+}
+
+void BoxDraftItem::normalizePropertyValues()
+{
+	// Adjust for negative box sizes
+	QPoint pos = *position();
+	QSize size = *boxSize();
+
+	if (size.width() < 0)
+		pos.setX(pos.x() + size.width());
+
+	if (size.height() < 0)
+		pos.setY(pos.y() + size.height());
+
+	size.setWidth(std::abs(size.width()));
+	size.setHeight(std::abs(size.height()));
+
+	position()->setValue(pos);
+	boxSize()->setValue(size);
+}
diff --git a/Grinder/image/draftitems/BoxDraftItem.h b/Grinder/image/draftitems/BoxDraftItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..ce4f4d0929b0aaa5301bb89dc0b9f3553b32f8c1
--- /dev/null
+++ b/Grinder/image/draftitems/BoxDraftItem.h
@@ -0,0 +1,46 @@
+/******************************************************************************
+ * File: BoxDraftItem.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#ifndef BOXDRAFTITEM_H
+#define BOXDRAFTITEM_H
+
+#include "image/DraftItem.h"
+
+namespace grndr
+{
+	class BoxDraftItem : public DraftItem
+	{
+		Q_OBJECT
+
+	public:
+		static const DraftItemType type_value;
+
+	public:
+		BoxDraftItem(Layer* layer);
+
+	public:
+		virtual std::unique_ptr<DraftItemRendererBase> createRenderer(const DraftItemRendererBase::RendererStyle& rendererStyle) const override;
+
+	public:
+		auto boxSize() { return dynamic_cast<SizeProperty*>(_boxSize.get()); }
+		auto boxSize() const { return dynamic_cast<SizeProperty*>(_boxSize.get()); }
+		auto lineWidth() { return dynamic_cast<UIntProperty*>(_lineWidth.get()); }
+		auto lineWidth() const { return dynamic_cast<UIntProperty*>(_lineWidth.get()); }
+
+	public:
+		virtual void setDragPropertyValues(QPoint initialPos, QPoint currentPos) override;
+
+		virtual void normalizePropertyValues() override;
+
+	protected:
+		virtual void createProperties() override;
+
+	private:
+		std::shared_ptr<PropertyBase> _boxSize;
+		std::shared_ptr<PropertyBase> _lineWidth;		
+	};
+}
+
+#endif
diff --git a/Grinder/image/draftitems/BoxDraftItemRenderer.cpp b/Grinder/image/draftitems/BoxDraftItemRenderer.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..59931a97e9f5d82ee064bd6453dd2255bba58e1a
--- /dev/null
+++ b/Grinder/image/draftitems/BoxDraftItemRenderer.cpp
@@ -0,0 +1,35 @@
+/******************************************************************************
+ * File: BoxDraftItemRenderer.cpp
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BoxDraftItemRenderer.h"
+#include "util/MathUtils.h"
+
+void BoxDraftItemRenderer::render(QPainter* painter, RenderMode mode, RenderFlags flags)
+{
+	painter->save();
+
+	auto rect = QRect{getItemPosition(mode), *_draftItem->boxSize()};
+
+	// Draw the selection background as an outer glow
+	if (flags.testFlag(RenderFlag::Selected))
+	{
+		painter->save();
+		painter->setPen(createSelectionPen(*_draftItem->lineWidth(), flags.testFlag(RenderFlag::Inactive)));
+		painter->setOpacity(_rendererStyle.selectionOpacity);
+		painter->drawRect(rect);
+		painter->restore();
+	}
+
+	// Draw the box
+	painter->setPen(createStandardPen(*_draftItem->lineWidth()));
+	painter->drawRect(rect);
+
+	// Draw an arrow showing the box's direction if the corresponding property has been set
+	if (_draftItem->hasDirection()->getValue() && (flags.testFlag(RenderFlag::Selected) || flags.testFlag(RenderFlag::ShowDirections)))
+		renderDirectionArrow(painter, rect.center());
+
+	painter->restore();
+}
diff --git a/Grinder/image/draftitems/BoxDraftItemRenderer.h b/Grinder/image/draftitems/BoxDraftItemRenderer.h
new file mode 100644
index 0000000000000000000000000000000000000000..b6d2e3ce4709a3479623b9795cdc0f6c3985e820
--- /dev/null
+++ b/Grinder/image/draftitems/BoxDraftItemRenderer.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: BoxDraftItemRenderer.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef BOXDRAFTITEMRENDERER_H
+#define BOXDRAFTITEMRENDERER_H
+
+#include "image/DraftItemRenderer.h"
+#include "BoxDraftItem.h"
+
+namespace grndr
+{
+	class BoxDraftItemRenderer : public DraftItemRenderer<BoxDraftItem>
+	{
+	public:
+		using DraftItemRenderer<BoxDraftItem>::DraftItemRenderer;
+
+	public:
+		virtual void render(QPainter* painter, RenderMode mode, RenderFlags flags) override;
+	};
+}
+
+#endif
diff --git a/Grinder/image/draftitems/LineDraftItem.cpp b/Grinder/image/draftitems/LineDraftItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d1689263df7fb44d056c97bfa10a51eb8df91eed
--- /dev/null
+++ b/Grinder/image/draftitems/LineDraftItem.cpp
@@ -0,0 +1,49 @@
+/******************************************************************************
+ * File: LineDraftItem.cpp
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LineDraftItem.h"
+#include "LineDraftItemRenderer.h"
+#include "common/properties/RangeConstraint.h"
+
+const DraftItemType LineDraftItem::type_value = DraftItemType::Line;
+
+LineDraftItem::LineDraftItem(Layer* layer) : DraftItem(layer, type_value)
+{
+
+}
+
+std::unique_ptr<DraftItemRendererBase> LineDraftItem::createRenderer(const DraftItemRendererBase::RendererStyle& rendererStyle) const
+{
+	return std::make_unique<LineDraftItemRenderer>(this, rendererStyle);
+}
+
+void LineDraftItem::setDragPropertyValues(QPoint initialPos, QPoint currentPos)
+{
+	Q_UNUSED(initialPos);
+
+	endPosition()->setValue(currentPos);
+}
+
+void LineDraftItem::setDefaultPropertyValues()
+{
+	DraftItem::setDefaultPropertyValues();
+
+	// Create a default line that extends 50px to the right
+	endPosition()->setValue(position()->getValue() + QPoint{50, 0});
+}
+
+void LineDraftItem::createProperties()
+{
+	DraftItem::createProperties();
+
+	// Create specific properties
+	_endPosition = createProperty<PointProperty>(PropertyID::EndPosition, "End position", QPoint{0, 0});
+	endPosition()->setDescription("The end position of the line.");
+
+	_lineWidth = createProperty<UIntProperty>(PropertyID::LineWidth, "Line width", 3);
+	lineWidth()->createConstraint<RangeConstraint>(1, 100);
+	lineWidth()->setDescription("The width of the line.");
+}
diff --git a/Grinder/image/draftitems/LineDraftItem.h b/Grinder/image/draftitems/LineDraftItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..c27ced9f174556054d05ba0e5ace3c2380e4c00f
--- /dev/null
+++ b/Grinder/image/draftitems/LineDraftItem.h
@@ -0,0 +1,46 @@
+/******************************************************************************
+ * File: LineDraftItem.h
+ * Date: 20.3.2018
+ *****************************************************************************/
+
+#ifndef LINEDRAFTITEM_H
+#define LINEDRAFTITEM_H
+
+#include "image/DraftItem.h"
+
+namespace grndr
+{
+	class LineDraftItem : public DraftItem
+	{
+		Q_OBJECT
+
+	public:
+		static const DraftItemType type_value;
+
+	public:
+		LineDraftItem(Layer* layer);
+
+	public:
+		virtual std::unique_ptr<DraftItemRendererBase> createRenderer(const DraftItemRendererBase::RendererStyle& rendererStyle) const override;
+
+	public:
+		virtual void setDragPropertyValues(QPoint initialPos, QPoint currentPos) override;
+
+		virtual void setDefaultPropertyValues() override;
+
+	public:
+		auto endPosition() { return dynamic_cast<PointProperty*>(_endPosition.get()); }
+		auto endPosition() const { return dynamic_cast<PointProperty*>(_endPosition.get()); }
+		auto lineWidth() { return dynamic_cast<UIntProperty*>(_lineWidth.get()); }
+		auto lineWidth() const { return dynamic_cast<UIntProperty*>(_lineWidth.get()); }
+
+	protected:
+		virtual void createProperties() override;
+
+	private:
+		std::shared_ptr<PropertyBase> _endPosition;
+		std::shared_ptr<PropertyBase> _lineWidth;
+	};
+}
+
+#endif
diff --git a/Grinder/image/draftitems/LineDraftItemRenderer.cpp b/Grinder/image/draftitems/LineDraftItemRenderer.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..7e6f425156f683db87519b7760fb59884c559baa
--- /dev/null
+++ b/Grinder/image/draftitems/LineDraftItemRenderer.cpp
@@ -0,0 +1,58 @@
+/******************************************************************************
+ * File: LineDraftItemRenderer.cpp
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LineDraftItemRenderer.h"
+#include "util/MathUtils.h"
+
+void LineDraftItemRenderer::render(QPainter* painter, RenderMode mode, RenderFlags flags)
+{
+	painter->save();
+
+	QPoint endPosition = *_draftItem->endPosition();
+
+	if (mode.testFlag(RenderModeFlag::RelativePositions))
+		endPosition -= *_draftItem->position();
+
+	// Draw the selection background as an outer glow
+	if (flags.testFlag(RenderFlag::Selected))
+	{
+		painter->save();
+		painter->setPen(createSelectionPen(*_draftItem->lineWidth(), flags.testFlag(RenderFlag::Inactive)));
+		painter->setOpacity(_rendererStyle.selectionOpacity);
+		painter->drawLine(getItemPosition(mode), endPosition);
+		painter->restore();
+	}
+
+	// Draw the line
+	painter->setPen(createStandardPen(*_draftItem->lineWidth()));
+	painter->drawLine(getItemPosition(mode), endPosition);
+
+	// Draw an arrow showing the line's direction if the corresponding property has been set
+	if (_draftItem->hasDirection()->getValue() && (flags.testFlag(RenderFlag::Selected) || flags.testFlag(RenderFlag::ShowDirections)))
+	{
+		// Calculate the line center
+		auto vec = *_draftItem->endPosition() - *_draftItem->position();
+		auto center = endPosition - (vec * 0.5);
+
+		renderDirectionArrow(painter, center);
+	}
+
+	painter->restore();
+}
+
+QPainterPath LineDraftItemRenderer::shape() const
+{
+	// Create a shape that is slightly thicker than the shown line
+	QPainterPath linePath;
+	linePath.moveTo(QPointF{0.0, 0.0});
+	linePath.lineTo(*_draftItem->endPosition() - *_draftItem->position());
+
+	QPainterPathStroker ps{createSelectionPen(*_draftItem->lineWidth())};
+	auto path = ps.createStroke(linePath);
+	path.addPath(linePath);
+
+	return path;
+}
diff --git a/Grinder/image/draftitems/LineDraftItemRenderer.h b/Grinder/image/draftitems/LineDraftItemRenderer.h
new file mode 100644
index 0000000000000000000000000000000000000000..b9f95d9a9b03fae0cae96587390c584188dc34f6
--- /dev/null
+++ b/Grinder/image/draftitems/LineDraftItemRenderer.h
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: LineDraftItemRenderer.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef LINEDRAFTITEMRENDERER_H
+#define LINEDRAFTITEMRENDERER_H
+
+#include "image/DraftItemRenderer.h"
+#include "LineDraftItem.h"
+
+namespace grndr
+{
+	class LineDraftItemRenderer : public DraftItemRenderer<LineDraftItem>
+	{
+	public:
+		using DraftItemRenderer<LineDraftItem>::DraftItemRenderer;
+
+	public:
+		virtual void render(QPainter* painter, RenderMode mode, RenderFlags flags) override;
+		virtual QPainterPath shape() const override;
+	};
+}
+
+#endif
diff --git a/Grinder/main.cpp b/Grinder/main.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c64d54569cb33f8df3a8464edc9c75543a39d20f
--- /dev/null
+++ b/Grinder/main.cpp
@@ -0,0 +1,37 @@
+/******************************************************************************
+ * File: main.cpp
+ * Date: 11.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "core/GrinderApplication.h"
+
+int main(int argc, char *argv[])
+{
+	// Add additional search paths for Qt libraries/plugins
+#if !defined(Q_OS_OSX)
+	QString	libraryDir = "lib";
+
+	if (argc >= 1)
+	{
+		QFileInfo fi(argv[0]);
+		libraryDir = QFileInfo{fi.absolutePath(), "lib"}.absoluteFilePath();
+	}
+
+	QCoreApplication::addLibraryPath(libraryDir);
+	qputenv("QT_PLUGIN_PATH", libraryDir.toLatin1());
+#endif
+
+	GrinderApplication app{argc, argv};
+
+	try {
+		return app.run();
+	} catch (std::exception& e) {
+		ShowExceptionMessage(e.what());
+		throw;
+	}
+	catch (...) {
+		ShowExceptionMessage("");
+		throw;
+	}
+}
diff --git a/Grinder/pipeline/Block.cpp b/Grinder/pipeline/Block.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e79526e4fee3af373fdc187a135640ee4c92aaaa
--- /dev/null
+++ b/Grinder/pipeline/Block.cpp
@@ -0,0 +1,127 @@
+/******************************************************************************
+ * File: Block.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Block.h"
+#include "Pipeline.h"
+#include "PipelineManager.h"
+#include "BlockCatalog.h"
+#include "PipelineExceptions.h"
+
+const char* Block::Serialization_Value_Type = "Type";
+const char* Block::Serialization_Value_Index = "Index";
+
+Block::Block(Pipeline* pipeline, BlockType type, BlockCategory category, QString name) : PipelineItem(pipeline, name),
+	_type{type}, _category{category}, _ports{this}
+{
+
+}
+
+void Block::initBlock()
+{
+	PipelineItem::initPipelineItem();
+
+	createPorts();
+}
+
+void Block::verifyConnection(const Port* src, const Port* dest) const
+{
+	if (!src || !dest)
+		throw std::invalid_argument{_EXCPT("src and dest may not be null")};
+
+	// The source port must belong to this block
+	if (src->block() != this)
+		throw BlockException{this, _EXCPT("The source port must belong to the current block")};
+
+	// Do not connect to self
+	if (dest->block() == this)
+		throw BlockException{this, _EXCPT("Cannot connect a block to itself")};
+}
+
+QString Block::getFormattedName() const
+{
+	return QString{"%1::%2"}.arg(_pipeline->getName()).arg(_name);
+}
+
+void Block::serialize(SerializationContext& ctx) const
+{
+	PipelineItem::serialize(ctx);
+
+	// Serialize values
+	ctx.settings()[Serialization_Value_Type] = _type;
+	ctx.settings()[Serialization_Value_Index] = ctx.addBlock(this);
+
+	// Serialize all ports
+	ctx.beginGroup(PortVector::Serialization_Group, true);
+	_ports.serialize(PortVector::Serialization_Element, ctx);
+	ctx.endGroup();
+}
+
+void Block::deserialize(DeserializationContext& ctx)
+{
+	PipelineItem::deserialize(ctx);
+
+	// Deserialize values
+	_type = ctx.settings()[Serialization_Value_Type].toString();
+	ctx.addBlock(ctx.settings()[Serialization_Value_Index].toInt(), this);
+
+	// Deserialize all ports
+	if (ctx.beginGroup(PortVector::Serialization_Group))
+	{
+		_ports.deserialize(PortVector::Serialization_Element, ctx, [this](const SettingsContainer& settings) {
+			PortType type = settings[Port::Serialization_Value_Type].toString();
+			return _ports.selectByType(type);
+		});
+
+		ctx.endGroup();
+	}
+}
+
+std::shared_ptr<Port> Block::createPort(PortType type, Port::Direction dir, DataDescriptors dataDescriptors, QString name)
+{
+	if (type == PortType::Undefined)
+		throw std::invalid_argument{_EXCPT("type may not be PortType::Undefined")};
+
+	if (_ports.selectByType(type))
+		throw BlockException{this, _EXCPT(QString{"A port of type %1 already exists"}.arg(type))};
+
+	auto port = std::make_shared<Port>(this, type, dir, dataDescriptors, name);
+
+	try {	// Propage initialization errors to the caller
+		port->initPort();
+	}
+	catch (...) {
+		throw;
+	}
+
+	_ports.push_back(port);
+	return port;
+}
+
+void Block::removePort(PortType type)
+{
+	if (type != PortType::Undefined)
+	{
+		auto port = _ports.selectByType(type);
+
+		if (port)
+			removePort(port.get());
+		else
+			throw BlockException{this, _EXCPT(QString{"Tried to remove a non-existing port (Type: %1)"}.arg(type))};
+	}
+}
+
+void Block::removePort(const Port* port)
+{
+	if (port)
+	{
+		auto it = _ports.find(port);
+
+		if (it != _ports.cend())
+			_ports.erase(it);
+		else
+			throw BlockException{this, _EXCPT("Tried to remove a port not belonging to this block")};
+	}
+}
diff --git a/Grinder/pipeline/Block.h b/Grinder/pipeline/Block.h
new file mode 100644
index 0000000000000000000000000000000000000000..bb0455da8506e6891824f990f8ad9e59f4415cae
--- /dev/null
+++ b/Grinder/pipeline/Block.h
@@ -0,0 +1,62 @@
+/******************************************************************************
+ * File: Block.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef BLOCK_H
+#define BLOCK_H
+
+#include "PipelineItem.h"
+#include "BlockType.h"
+#include "BlockCategory.h"
+#include "PortVector.h"
+#include "engine/ProcessorBase.h"
+
+namespace grndr
+{
+	class Block : public PipelineItem
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_Type;
+		static const char* Serialization_Value_Index;
+
+	public:
+		Block(Pipeline* pipeline, BlockType type, BlockCategory category, QString name = "");
+
+	public:
+		virtual void initBlock();
+
+		virtual void verifyConnection(const Port* src, const Port* dest) const;
+
+	public:
+		virtual std::unique_ptr<ProcessorBase> createProcessor() const = 0;
+
+	public:
+		QString getFormattedName() const;
+		BlockType getType() const { return _type; }
+		BlockCategory getCategory() const { return _category; }
+
+		const PortVector& ports() const { return _ports; }
+
+	public:
+		virtual void serialize(SerializationContext& ctx) const override;
+		virtual void deserialize(DeserializationContext& ctx) override;
+
+	protected:
+		virtual void createPorts() = 0;
+
+		std::shared_ptr<Port> createPort(PortType type, Port::Direction dir, DataDescriptors dataDescriptors, QString name = "");
+		void removePort(PortType type);
+		void removePort(const Port* port);
+
+	protected:
+		BlockType _type{BlockType::Undefined};
+		BlockCategory _category{BlockCategory::Undefined};
+
+		PortVector _ports;
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/BlockCatalog.cpp b/Grinder/pipeline/BlockCatalog.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..94bf2d9aeeeb19b410e200f6de73ba8d00dbabbe
--- /dev/null
+++ b/Grinder/pipeline/BlockCatalog.cpp
@@ -0,0 +1,103 @@
+/******************************************************************************
+ * File: BlockCatalog.cpp
+ * Date: 29.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BlockCatalog.h"
+
+#include "blocks/InputBlock.h"
+#include "blocks/OutputBlock.h"
+#include "blocks/ConvertToGrayscaleBlock.h"
+#include "blocks/BinaryThresholdBlock.h"
+
+#define REGISTER_BLOCK_TYPE(cls, desc)	registerBlockType(cls::type_value, cls::category_value, [](Pipeline* pipeline, QString name) { return std::make_unique<cls>(pipeline, name); }, desc)
+
+std::map<BlockType, BlockCatalog::BlockDescriptor> BlockCatalog::s_descriptors;
+
+std::set<BlockType> BlockCatalog::getTypes(BlockCategory category)
+{
+	std::set<BlockType> types;
+
+	for (const auto& desc : s_descriptors)
+	{
+		if (category == BlockCategory::Undefined || category == desc.second.category)
+			types.insert(desc.second.type);
+	}
+
+	return types;
+}
+
+std::set<BlockCategory> BlockCatalog::getCategories()
+{
+	std::set<BlockCategory> cats;
+
+	for (const auto& desc : s_descriptors)
+	{
+		if (desc.second.category != BlockCategory::Undefined)
+			cats.insert(desc.second.category);
+	}
+
+	return cats;
+}
+
+BlockCategory BlockCatalog::getCategory(BlockType type)
+{
+	auto it = s_descriptors.find(type);
+
+	if (it != s_descriptors.cend())
+		return it->second.category;
+	else
+		return BlockCategory::Undefined;
+}
+
+QString BlockCatalog::getDescription(BlockType type)
+{
+	auto it = s_descriptors.find(type);
+
+	if (it != s_descriptors.cend())
+		return it->second.description;
+	else
+		return "";
+}
+
+void BlockCatalog::registerBlockType(BlockType type, BlockCategory category, BlockCatalog::block_creator_type creator, QString description)
+{
+	if (type == BlockType::Undefined)
+		throw std::invalid_argument{_EXCPT("type may not be BlockType::Undefined")};
+
+	if (category == BlockCategory::Undefined)
+		throw std::invalid_argument{_EXCPT("category may not be BlockCategory::Undefined")};
+
+	if (!creator)
+		throw std::invalid_argument{_EXCPT("creator may not be null")};
+
+	s_descriptors[type] = {type, category, creator, description};
+}
+
+std::unique_ptr<Block> BlockCatalog::createBlock(Pipeline* pipeline, BlockType type, QString name)
+{
+	if (type == BlockType::Undefined)
+		throw std::invalid_argument{_EXCPT("type may not be BlockType::Undefined")};
+
+	if (s_descriptors.find(type) != s_descriptors.end())
+	{
+		auto descriptor = s_descriptors.at(type);
+		auto block = descriptor.creator(pipeline, name);
+
+		if (!block)
+			throw std::runtime_error{_EXCPT(QString{"Failed to create a block of type %1"}.arg(type))};
+
+		return block;
+	}
+	else
+		throw std::invalid_argument{_EXCPT("The given type has not been registered")};
+}
+
+void BlockCatalog::registerStandardBlocks()
+{
+	REGISTER_BLOCK_TYPE(InputBlock, "The beginning of every pipeline; provides the currently active image of the image stack.");
+	REGISTER_BLOCK_TYPE(OutputBlock, "The final stage of every pipeline; makes the image available for editing and saving.");
+	REGISTER_BLOCK_TYPE(ConvertToGrayscaleBlock, "Converts its input to a grayscale image.");
+	REGISTER_BLOCK_TYPE(BinaryThresholdBlock, "Applies a binary threshold to its input.");
+}
diff --git a/Grinder/pipeline/BlockCatalog.h b/Grinder/pipeline/BlockCatalog.h
new file mode 100644
index 0000000000000000000000000000000000000000..45432802c0b233d0830c4150bc26aea74a52e0ec
--- /dev/null
+++ b/Grinder/pipeline/BlockCatalog.h
@@ -0,0 +1,60 @@
+/******************************************************************************
+ * File: BlockCatalog.h
+ * Date: 29.1.2018
+ *****************************************************************************/
+
+#ifndef BLOCKCATALOG_H
+#define BLOCKCATALOG_H
+
+#include <map>
+#include <set>
+#include <functional>
+#include <memory>
+
+#include "BlockType.h"
+#include "BlockCategory.h"
+
+namespace grndr
+{
+	class Pipeline;
+	class Block;
+
+	class BlockCatalog
+	{
+	private:
+		using block_creator_type = std::function<std::unique_ptr<Block>(Pipeline*, QString)>;
+
+	private:
+		BlockCatalog() { }
+
+	public:
+		static std::unique_ptr<Block> createBlock(Pipeline* pipeline, BlockType type, QString name = "");
+
+		static void registerStandardBlocks();
+
+	public:
+		static std::set<BlockType> getTypes(BlockCategory category = BlockCategory::Undefined);
+		static std::set<BlockCategory> getCategories();
+
+		static BlockCategory getCategory(BlockType type);
+		static QString getDescription(BlockType type);
+
+	private:
+		static void registerBlockType(BlockType type, BlockCategory category, block_creator_type creator, QString description);
+
+	private:
+		struct BlockDescriptor
+		{
+			BlockType type;
+			BlockCategory category;			
+
+			block_creator_type creator;
+
+			QString description;
+		};
+
+		static std::map<BlockType, BlockDescriptor> s_descriptors;
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/BlockCategory.cpp b/Grinder/pipeline/BlockCategory.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d2fcd74562d1aca5b480e9a1c15edbd7dc6fd3ec
--- /dev/null
+++ b/Grinder/pipeline/BlockCategory.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: BlockCategory.cpp
+ * Date: 24.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BlockCategory.h"
+
+const char* BlockCategory::Undefined = "";
+
+const char* BlockCategory::Input = "Input";
+const char* BlockCategory::Output = "Output";
+
+const char* BlockCategory::Conversion = "Conversion";
+const char* BlockCategory::Thresholding = "Thresholding";
diff --git a/Grinder/pipeline/BlockCategory.h b/Grinder/pipeline/BlockCategory.h
new file mode 100644
index 0000000000000000000000000000000000000000..f02ea60402d7a9621da8163d824763827789ea35
--- /dev/null
+++ b/Grinder/pipeline/BlockCategory.h
@@ -0,0 +1,42 @@
+/******************************************************************************
+ * File: BlockCategory.h
+ * Date: 24.1.2018
+ *****************************************************************************/
+
+#ifndef BLOCKCATEGORY_H
+#define BLOCKCATEGORY_H
+
+#include <vector>
+#include <map>
+#include <QString>
+
+#include "BlockType.h"
+
+namespace grndr
+{
+	class BlockCategory : public QString
+	{
+	public:
+		static const char* Undefined;
+
+		static const char* Input;
+		static const char* Output;
+
+		static const char* Conversion;
+		static const char* Thresholding;
+
+	public:
+		using QString::QString;
+
+		BlockCategory() = default;
+		BlockCategory(const BlockCategory& other) = default;
+		BlockCategory(BlockCategory&& other) = default;
+		BlockCategory(const QString& str) { *static_cast<QString*>(this) = str; }
+
+		BlockCategory& operator =(const BlockCategory& other) = default;
+		BlockCategory& operator =(BlockCategory&& other) = default;
+		BlockCategory& operator =(const QString& str) { *static_cast<QString*>(this) = str; return *this; }
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/BlockHierarchy.cpp b/Grinder/pipeline/BlockHierarchy.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8f08457ee1534abebb966e04457d1d4dee784133
--- /dev/null
+++ b/Grinder/pipeline/BlockHierarchy.cpp
@@ -0,0 +1,158 @@
+/******************************************************************************
+ * File: BlockHierarchy.cpp
+ * Date: 20.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BlockHierarchy.h"
+#include "PipelineExceptions.h"
+#include "Pipeline.h"
+#include "Block.h"
+
+BlockHierarchy::BlockHierarchy(const Pipeline* pipeline)
+{
+	if (pipeline)
+		createFromPipeline(pipeline);
+}
+
+void BlockHierarchy::createFromPipeline(const Pipeline* pipeline)
+{
+	if (!pipeline)
+		throw std::invalid_argument{_EXCPT("pipeline may not be null")};
+
+	_hierarchy.clear();
+
+	// Level-0 blocks mark the beginning(s) of our pipeline
+	auto level0 = findLevel0Blocks(pipeline);
+
+	if (level0.empty())	// No level-0 blocks means that the pipeline has no beginnings (the pipeline starts with cycles)
+		throw PipelineException{pipeline, _EXCPT("The pipeline has no starting blocks")};
+
+	_hierarchy.push_back(std::move(level0));
+
+	// Recursively create all following levels
+	createNextLevel(pipeline);
+
+	// All blocks must be part of the hierarchy; if this isn't the case, cyclic dependencies most likely exist
+	unsigned int blocksInHierarchy = 0;
+
+	for (const auto& level : _hierarchy)
+		blocksInHierarchy += level.size();
+
+	if (blocksInHierarchy != pipeline->blocks().size())
+		throw PipelineException{pipeline, _EXCPT("Not all blocks could be added to the hierarchy; this is usually caused by cyclic dependencies in the graph")};
+}
+
+int BlockHierarchy::getBlockLevel(const Block* block) const
+{
+	for (unsigned int i = 0; i < _hierarchy.size(); ++i)
+	{
+		const auto& level = _hierarchy[i];
+
+		for (const auto& blockInHierarchy : level)
+		{
+			if (blockInHierarchy == block)
+				return i;
+		}
+	}
+
+	return -1;
+}
+
+void BlockHierarchy::createNextLevel(const Pipeline* pipeline)
+{
+	const auto nextLevel = findNextLevelBlocks(pipeline, _hierarchy.back());
+
+	// If a new level was created, add it and create the next level once again
+	if (!nextLevel.empty())
+	{
+		_hierarchy.push_back(std::move(nextLevel));
+		createNextLevel(pipeline);
+	}
+}
+
+bool BlockHierarchy::isBlockInHierarchy(const Block* block) const
+{
+	return getBlockLevel(block) != -1;
+}
+
+BlockHierarchy::HierarchyLevel BlockHierarchy::findLevel0Blocks(const Pipeline* pipeline) const
+{
+	HierarchyLevel level0;
+
+	// Find all blocks that have no inputs (level-0 blocks)
+	for (const auto& block : pipeline->blocks())
+	{
+		bool isLevel0Block = true;
+
+		for (const auto& port : block->ports().selectByDirection(Port::Direction::In))
+		{
+			if (port->isConnected())
+			{
+				isLevel0Block = false;
+				break;
+			}
+		}
+
+		if (isLevel0Block)
+			level0.push_back(block.get());
+	}
+
+	return level0;
+}
+
+BlockHierarchy::HierarchyLevel BlockHierarchy::findNextLevelBlocks(const Pipeline* pipeline, const HierarchyLevel& level) const
+{
+	HierarchyLevel nextLevel;
+
+	for (const auto& block : level)
+	{
+		auto nextLevelForBlock = findNextLevelBlocks(block);
+
+		for (const auto& newBlock : nextLevelForBlock)
+		{
+			// If the found block is already in a lower level of the hierarchy, a cycle exists
+			if (isBlockInHierarchy(newBlock))
+				throw PipelineException{pipeline, _EXCPT("A cycle exists in the pipeline")};
+
+			// Otherwise, add the found block to the level
+			if (std::find(nextLevel.begin(), nextLevel.end(), newBlock) == nextLevel.end())
+				nextLevel.push_back(newBlock);
+		}
+	}
+
+	return nextLevel;
+}
+
+BlockHierarchy::HierarchyLevel BlockHierarchy::findNextLevelBlocks(const Block* block) const
+{
+	HierarchyLevel nextLevel;
+
+	// Find all blocks this block is connected to
+	for (const auto& port : block->ports().selectByDirection(Port::Direction::Out))
+	{
+		for (const auto& con : port->connections())
+		{
+			// Check if all inputs of the connected block are in the hierarchy; otherwise, skip it
+			if (checkBlockInputs(con->dest(), block, nextLevel))
+				nextLevel.push_back(con->dest());
+		}
+	}
+
+	return nextLevel;
+}
+
+bool BlockHierarchy::checkBlockInputs(const Block* block, const Block* currentBlock, const HierarchyLevel& currentLevel) const
+{
+	for (const auto& port : block->ports().selectByDirection(Port::Direction::In))
+	{
+		for (const auto& con : port->getConnections(Port::Direction::In))
+		{
+			// Check if the source is already in the hierarchy or in the currently built level; if not, this block must be postponed to a higher level
+			if (con->source() != currentBlock && !isBlockInHierarchy(con->source()) && std::find(currentLevel.begin(), currentLevel.end(), con->source()) == currentLevel.end())
+				return false;
+		}
+	}
+
+	return true;
+}
diff --git a/Grinder/pipeline/BlockHierarchy.h b/Grinder/pipeline/BlockHierarchy.h
new file mode 100644
index 0000000000000000000000000000000000000000..6409770f83af158c7d18b8947aedee38cd8dfa6b
--- /dev/null
+++ b/Grinder/pipeline/BlockHierarchy.h
@@ -0,0 +1,59 @@
+/******************************************************************************
+ * File: BlockHierarchy.h
+ * Date: 20.2.2018
+ *****************************************************************************/
+
+#ifndef BLOCKHIERARCHY_H
+#define BLOCKHIERARCHY_H
+
+#include <vector>
+
+namespace grndr
+{
+	class Pipeline;
+	class Block;
+
+	class BlockHierarchy
+	{
+	public:
+		using HierarchyLevel = std::vector<const Block*>;
+
+	public:
+		BlockHierarchy(const Pipeline* pipeline = nullptr);
+
+	public:
+		void createFromPipeline(const Pipeline* pipeline = nullptr);
+
+	public:
+		auto at(int index) { return _hierarchy.at(index); }
+		auto at(int index) const { return _hierarchy.at(index); }
+
+		auto size() const { return _hierarchy.size(); }
+		auto empty() const { return _hierarchy.empty(); }
+
+		auto begin() { return _hierarchy.begin(); }
+		auto begin() const { return _hierarchy.begin(); }
+		auto cbegin() const { return _hierarchy.cbegin(); }
+		auto end() { return _hierarchy.end(); }
+		auto end() const { return _hierarchy.end(); }
+		auto cend() const { return _hierarchy.cend(); }
+
+		int getBlockLevel(const Block* block) const;
+
+	private:
+		void createNextLevel(const Pipeline* pipeline);
+
+		bool isBlockInHierarchy(const Block* block) const;
+
+		HierarchyLevel findLevel0Blocks(const Pipeline* pipeline) const;
+		HierarchyLevel findNextLevelBlocks(const Pipeline* pipeline, const HierarchyLevel& level) const;
+		HierarchyLevel findNextLevelBlocks(const Block* block) const;
+
+		bool checkBlockInputs(const Block* block, const Block* currentBlock, const HierarchyLevel& currentLevel) const;
+
+	private:
+		std::vector<HierarchyLevel> _hierarchy;
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/BlockType.cpp b/Grinder/pipeline/BlockType.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ad7ec6c5ad9932202e3ba5936231b1a8391d8650
--- /dev/null
+++ b/Grinder/pipeline/BlockType.cpp
@@ -0,0 +1,16 @@
+/******************************************************************************
+ * File: BlockType.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BlockType.h"
+
+const char* BlockType::Undefined = "";
+
+const char* BlockType::Input = "Input";
+const char* BlockType::Output = "Output";
+
+const char* BlockType::ConvertToGrayscale = "ConvertToGrayscale";
+
+const char* BlockType::BinaryThreshold = "BinaryThreshold";
diff --git a/Grinder/pipeline/BlockType.h b/Grinder/pipeline/BlockType.h
new file mode 100644
index 0000000000000000000000000000000000000000..e46cf37c35077f86522c10387d6a65b84fdab51c
--- /dev/null
+++ b/Grinder/pipeline/BlockType.h
@@ -0,0 +1,39 @@
+/******************************************************************************
+ * File: BlockType.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef BLOCKTYPE_H
+#define BLOCKTYPE_H
+
+#include <QString>
+
+namespace grndr
+{
+	class BlockType final : public QString
+	{
+	public:
+		static const char* Undefined; /* Invalid block */
+
+		static const char* Input;
+		static const char* Output;
+
+		static const char* ConvertToGrayscale;
+
+		static const char* BinaryThreshold;
+
+	public:
+		using QString::QString;
+
+		BlockType() = default;
+		BlockType(const BlockType& other) = default;
+		BlockType(BlockType&& other) = default;
+		BlockType(const QString& str) { *static_cast<QString*>(this) = str; }
+
+		BlockType& operator =(const BlockType& other) = default;
+		BlockType& operator =(BlockType&& other) = default;
+		BlockType& operator =(const QString& str) { *static_cast<QString*>(this) = str; return *this; }
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/BlockVector.cpp b/Grinder/pipeline/BlockVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9e73fbb42c53063472376a02dd521b66df9d17c7
--- /dev/null
+++ b/Grinder/pipeline/BlockVector.cpp
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: BlockVector.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BlockVector.h"
+
+const char* BlockVector::Serialization_Group = "Blocks";
+const char* BlockVector::Serialization_Element = "Block";
+
+BlockVector::BlockVector(const Pipeline* pipeline) :
+	_pipeline{pipeline}
+{
+	if (!pipeline)
+		throw std::invalid_argument{_EXCPT("pipeline may not be null")};
+}
+
+BlockVector::pointer_vec_type BlockVector::selectByType(BlockType type) const
+{
+	return select([type](auto block) { return block->getType() == type; });
+}
+
+BlockVector::pointer_type BlockVector::selectByName(QString name, bool caseSensitive) const
+{
+	return selectFirst([name, caseSensitive](auto block) { return block->getName().compare(name, caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive) == 0; });
+}
diff --git a/Grinder/pipeline/BlockVector.h b/Grinder/pipeline/BlockVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..b986c83d17d0672dea064b11f54c17d0f31b4050
--- /dev/null
+++ b/Grinder/pipeline/BlockVector.h
@@ -0,0 +1,32 @@
+/******************************************************************************
+ * File: BlockVector.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef BLOCKVECTOR_H
+#define BLOCKVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "Block.h"
+
+namespace grndr
+{
+	class BlockVector : public ObjectVector<Block>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		BlockVector(const Pipeline* pipeline);
+
+	public:
+		pointer_vec_type selectByType(BlockType type) const;
+		pointer_type selectByName(QString name, bool caseSensitive = false) const;
+
+	private:
+		const Pipeline* _pipeline{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/Connection.cpp b/Grinder/pipeline/Connection.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..dfa9fc919436c4825e4af775fdb394444bf6b1e9
--- /dev/null
+++ b/Grinder/pipeline/Connection.cpp
@@ -0,0 +1,67 @@
+/******************************************************************************
+ * File: Connection.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Connection.h"
+#include "Port.h"
+
+const char* Connection::Serialization_Value_Source = "Source";
+const char* Connection::Serialization_Value_Dest = "Dest";
+
+Connection::Connection(Port* src, Port* dest) : PipelineItem(src->pipeline()),
+	_sourcePort{src}, _destPort{dest}
+{
+	if (!src)
+		throw std::invalid_argument{_EXCPT("src may not be null")};
+
+	if (!dest)
+		throw std::invalid_argument{_EXCPT("dest may not be null")};
+}
+
+void Connection::initConnection()
+{
+	PipelineItem::initPipelineItem();
+}
+
+Block* Connection::_source() const
+{
+	if (_sourcePort)
+		return _sourcePort->block();
+	else
+		return nullptr;
+}
+
+Block* Connection::_dest() const
+{
+	if (_destPort)
+		return _destPort->block();
+	else
+		return nullptr;
+}
+
+void Connection::serialize(SerializationContext& ctx) const
+{
+	PipelineItem::serialize(ctx);
+
+	// Serialize values
+	ctx.settings()[Serialization_Value_Source] = ctx.getPortIndex(_sourcePort);
+	ctx.settings()[Serialization_Value_Dest] = ctx.getPortIndex(_destPort);
+}
+
+void Connection::deserialize(DeserializationContext& ctx)
+{
+	PipelineItem::deserialize(ctx);
+
+	int sourceIndex = ctx.settings()[Serialization_Value_Source].toInt();
+	int destIndex = ctx.settings()[Serialization_Value_Dest].toInt();
+	auto sourcePort = ctx.getPort(sourceIndex);
+	auto destPort = ctx.getPort(destIndex);
+
+	if (sourcePort && destPort)
+	{
+		_sourcePort = sourcePort;
+		_destPort = destPort;
+	}
+}
diff --git a/Grinder/pipeline/Connection.h b/Grinder/pipeline/Connection.h
new file mode 100644
index 0000000000000000000000000000000000000000..c8260b252625f3d72409993426a60706ad9026f0
--- /dev/null
+++ b/Grinder/pipeline/Connection.h
@@ -0,0 +1,56 @@
+/******************************************************************************
+ * File: Connection.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef CONNECTION_H
+#define CONNECTION_H
+
+#include "PipelineItem.h"
+
+namespace grndr
+{
+	class Port;
+	class Block;
+
+	class Connection : public PipelineItem
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_Source;
+		static const char* Serialization_Value_Dest;
+
+	public:
+		Connection(Port* src, Port* dest);
+
+	public:
+		void initConnection();
+
+	public:
+		Block* source() { return _source(); }
+		const Block* source() const { return _source(); }
+		Port* sourcePort() { return _sourcePort; }
+		const Port* sourcePort() const { return _sourcePort; }
+
+		Block* dest() { return _dest(); }
+		const Block* dest() const { return _dest(); }
+		Port* destPort() { return _destPort; }
+		const Port* destPort() const { return _destPort; }
+
+	public:
+		virtual void serialize(SerializationContext& ctx) const override;
+		virtual void deserialize(DeserializationContext& ctx) override;
+
+	private:
+		Block* _source() const;
+		Block* _dest() const;
+
+	private:
+		Port* _sourcePort{nullptr};
+		Port* _destPort{nullptr};	
+	};
+
+}
+
+#endif
diff --git a/Grinder/pipeline/ConnectionVector.cpp b/Grinder/pipeline/ConnectionVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..58111b636218cd0dab31a1fbd88758e13b2859f3
--- /dev/null
+++ b/Grinder/pipeline/ConnectionVector.cpp
@@ -0,0 +1,22 @@
+/******************************************************************************
+ * File: ConnectionVector.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ConnectionVector.h"
+
+const char* ConnectionVector::Serialization_Group = "Connections";
+const char* ConnectionVector::Serialization_Element = "Connection";
+
+ConnectionVector::ConnectionVector(const Port* srcPort) :
+	_sourcePort{srcPort}
+{
+	if (!srcPort)
+		throw std::invalid_argument{_EXCPT("srcPort may not be null")};
+}
+
+ConnectionVector::pointer_type ConnectionVector::selectByDestination(const Port* dest) const
+{
+	return selectFirst([dest](auto con) { return con->destPort() == dest; });
+}
diff --git a/Grinder/pipeline/ConnectionVector.h b/Grinder/pipeline/ConnectionVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..61576490e4dca42ff9d92af5cc5a4ae39a1f352d
--- /dev/null
+++ b/Grinder/pipeline/ConnectionVector.h
@@ -0,0 +1,33 @@
+/******************************************************************************
+ * File: ConnectionVector.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef CONNECTIONVECTOR_H
+#define CONNECTIONVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "Connection.h"
+
+namespace grndr
+{
+	class Port;
+
+	class ConnectionVector : public ObjectVector<Connection>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		ConnectionVector(const Port* srcPort);
+
+	public:
+		pointer_type selectByDestination(const Port* dest) const;
+
+	private:
+		const Port* _sourcePort{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/Pipeline.cpp b/Grinder/pipeline/Pipeline.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d5cccae00419114fbf75ad561abfec1b5074304a
--- /dev/null
+++ b/Grinder/pipeline/Pipeline.cpp
@@ -0,0 +1,124 @@
+/******************************************************************************
+ * File: Pipeline.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Pipeline.h"
+#include "PipelineManager.h"
+#include "Block.h"
+#include "BlockCatalog.h"
+#include "PipelineExceptions.h"
+#include "core/GrinderApplication.h"
+#include "util/SerializationUtils.h"
+
+const char* Pipeline::Serialization_Value_Name = "Name";
+
+Pipeline::Pipeline(QString name) :
+	_name{name}, _blocks{this}
+{
+
+}
+
+void Pipeline::initPipeline()
+{
+
+}
+
+std::shared_ptr<Block> Pipeline::createBlock(BlockType type, QString name)
+{
+	if (type == BlockType::Undefined)
+		throw std::invalid_argument(_EXCPT("type may not be BlockType::Undefined"));
+
+	// Create new block using the block factory; cast the unique ptr to a shared one as well
+	std::shared_ptr<Block> block = BlockCatalog::createBlock(this, type, name);
+
+	try {	// Propagate initialization errors to the caller
+		block->initBlock();
+	}
+	catch (...) {
+		throw;
+	}
+
+	_blocks.push_back(block);
+	emit blockCreated(block);
+
+	return block;
+}
+
+void Pipeline::removeBlock(const Block* block)
+{
+	if (block)
+	{
+		auto it = _blocks.find(block);
+
+		if (it != _blocks.cend())
+		{
+			// Keep a copy of the shared_ptr holding the block to increase its use count;
+			// otherwise, the block will be deleted before it has been removed from the vector, potentially causing a crash
+			auto block = *it;
+
+			// Remove all connections to or from this block
+			for (auto port : block->ports())
+				port->disconnectFromAll();
+
+			emit blockRemoved(*it);
+			_blocks.erase(it);
+		}
+		else
+			throw PipelineException{this, _EXCPT("Tried to remove a block not belonging to this pipeline")};
+	}
+}
+
+void Pipeline::serialize(SerializationContext& ctx) const
+{
+	// Serialize values
+	ctx.settings()[Serialization_Value_Name] = _name;
+
+	// Serialize all blocks
+	ctx.beginGroup(BlockVector::Serialization_Group, true);
+	_blocks.serialize(BlockVector::Serialization_Element, ctx);
+	ctx.endGroup();
+
+	// Serialize all connections
+	ctx.beginGroup(ConnectionVector::Serialization_Group, true);
+	SerializationUtils::serializeContainer(ctx.connections(), ConnectionVector::Serialization_Element, ctx);
+	ctx.endGroup();
+}
+
+void Pipeline::deserialize(DeserializationContext& ctx)
+{
+	// Deserialize values
+	_name = ctx.settings()[Serialization_Value_Name].toString();
+
+	// Deserialize all blocks	
+	if (ctx.beginGroup(BlockVector::Serialization_Group))
+	{
+		_blocks.deserialize(BlockVector::Serialization_Element, ctx, [this](const SettingsContainer& settings) {
+			BlockType type = settings[Block::Serialization_Value_Type].toString();
+			QString name = settings[Block::Serialization_Value_Name].toString();
+
+			return createBlock(type, name);
+		});
+
+		ctx.endGroup();
+	}
+
+	// Deserialize all connections
+	if (ctx.beginGroup(ConnectionVector::Serialization_Group))
+	{
+		SerializationUtils::deserializeContainer<ConnectionVector>(ConnectionVector::Serialization_Element, ctx, [&ctx](const SettingsContainer& settings) -> std::shared_ptr<Connection> {
+			int sourceIndex = settings[Connection::Serialization_Value_Source].toInt();
+			int destIndex = settings[Connection::Serialization_Value_Dest].toInt();
+			auto sourcePort = ctx.getPort(sourceIndex);
+			auto destPort = ctx.getPort(destIndex);
+
+			if (sourcePort && destPort)
+				return sourcePort->connectTo(destPort);
+			else
+				return nullptr;
+		});
+
+		ctx.endGroup();
+	}
+}
diff --git a/Grinder/pipeline/Pipeline.h b/Grinder/pipeline/Pipeline.h
new file mode 100644
index 0000000000000000000000000000000000000000..0a3e622e8f54517a58174c58e293640c1f264340
--- /dev/null
+++ b/Grinder/pipeline/Pipeline.h
@@ -0,0 +1,57 @@
+/******************************************************************************
+ * File: Pipeline.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef PIPELINE_H
+#define PIPELINE_H
+
+#include "BlockVector.h"
+
+namespace grndr
+{
+	class PipelineManager;
+
+	class Pipeline : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_Name;
+
+	public:
+		Pipeline(QString name = "");
+
+	public:
+		void initPipeline();
+
+	public:
+		std::shared_ptr<Block> createBlock(BlockType type, QString name = "");
+		void removeBlock(const Block* block);
+
+	public:
+		QString getName() const { return _name; }
+		void setName(QString name) { if (name != _name) { _name = name; emit pipelineRenamed(); } }
+
+		const BlockVector& blocks() const { return _blocks; }		
+
+	public:
+		void serialize(SerializationContext& ctx) const;
+		void deserialize(DeserializationContext& ctx);
+
+	signals:
+		void pipelineRenamed();
+
+		void blockCreated(const std::shared_ptr<Block>&);
+		void blockRemoved(const std::shared_ptr<Block>&);
+		void connectionCreated(const std::shared_ptr<Connection>&);
+		void connectionRemoved(const std::shared_ptr<Connection>&);
+
+	private:
+		QString _name{""};
+
+		BlockVector _blocks;
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/PipelineExceptions.cpp b/Grinder/pipeline/PipelineExceptions.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..5d9cd030612030c5df62187adf7b3d00911ea801
--- /dev/null
+++ b/Grinder/pipeline/PipelineExceptions.cpp
@@ -0,0 +1,33 @@
+/******************************************************************************
+ * File: PipelineExceptions.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PipelineExceptions.h"
+#include "PipelineItem.h"
+#include "Block.h"
+
+PipelineException::PipelineException(const Pipeline* pipeline, QString what) : GrinderException(what),
+	_pipeline{pipeline}
+{
+
+}
+
+PipelineItemException::PipelineItemException(const PipelineItem* item, QString what) : PipelineException(item ? item->pipeline() : nullptr, what),
+	_pipelineItem{item}
+{
+
+}
+
+BlockException::BlockException(const Block* block, QString what) : PipelineItemException(block, what),
+	_block{block}
+{
+
+}
+
+PortException::PortException(const Port* port, QString what) : BlockException(port->block(), what),
+	_port{port}
+{
+
+}
diff --git a/Grinder/pipeline/PipelineExceptions.h b/Grinder/pipeline/PipelineExceptions.h
new file mode 100644
index 0000000000000000000000000000000000000000..e6faa0e529910153ea03ad7ec0b6453cc7ac6d25
--- /dev/null
+++ b/Grinder/pipeline/PipelineExceptions.h
@@ -0,0 +1,69 @@
+/******************************************************************************
+ * File: PipelineExceptions.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef PIPELINEEXCEPTIONS_H
+#define PIPELINEEXCEPTIONS_H
+
+#include <QString>
+
+#include "core/GrinderExceptions.h"
+
+namespace grndr
+{
+	class Pipeline;
+	class PipelineItem;
+	class Block;
+	class Port;
+
+	class PipelineException : public GrinderException
+	{
+	public:
+		PipelineException(const Pipeline* pipeline, QString what);
+
+	public:
+		const Pipeline* pipeline() const { return _pipeline; }
+
+	protected:
+		const Pipeline* _pipeline{nullptr};
+	};
+
+	class PipelineItemException : public PipelineException
+	{
+	public:
+		PipelineItemException(const PipelineItem* item, QString what);
+
+	public:
+		const PipelineItem* pipelineItem() const { return _pipelineItem; }
+
+	protected:
+		const PipelineItem* _pipelineItem{nullptr};
+	};
+
+	class BlockException : public PipelineItemException
+	{
+	public:
+		BlockException(const Block* block, QString what);
+
+	public:
+		const Block* block() const { return _block; }
+
+	protected:
+		const Block* _block{nullptr};
+	};
+
+	class PortException : public BlockException
+	{
+	public:
+		PortException(const Port* port, QString what);
+
+	public:
+		const Port* port() const { return _port; }
+
+	protected:
+		const Port* _port{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/PipelineItem.cpp b/Grinder/pipeline/PipelineItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..21c8b9295b79557287d8b5976d51835df621bcbd
--- /dev/null
+++ b/Grinder/pipeline/PipelineItem.cpp
@@ -0,0 +1,38 @@
+/******************************************************************************
+ * File: PipelineItem.cpp
+ * Date: 13.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PipelineItem.h"
+#include "PipelineExceptions.h"
+
+const char* PipelineItem::Serialization_Value_Name = "Name";
+
+PipelineItem::PipelineItem(Pipeline* pipeline, QString name) :
+	_pipeline{pipeline}, _name{name}
+{
+	if (!pipeline)
+		throw std::invalid_argument{_EXCPT("pipeline may not be null")};
+}
+
+void PipelineItem::initPipelineItem()
+{
+	createProperties();
+}
+
+void PipelineItem::serialize(SerializationContext& ctx) const
+{
+	PropertyObject::serialize(ctx);
+
+	// Serialize values
+	ctx.settings()[Serialization_Value_Name] = _name;
+}
+
+void PipelineItem::deserialize(DeserializationContext& ctx)
+{
+	PropertyObject::deserialize(ctx);
+
+	// Deserialize values
+	_name = ctx.settings()[Serialization_Value_Name].toString();
+}
diff --git a/Grinder/pipeline/PipelineItem.h b/Grinder/pipeline/PipelineItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..61e1bff1c5a922926c06d1900b4d85a4870d9235
--- /dev/null
+++ b/Grinder/pipeline/PipelineItem.h
@@ -0,0 +1,49 @@
+/******************************************************************************
+ * File: PipelineItem.h
+ * Date: 13.1.2018
+ *****************************************************************************/
+
+#ifndef PIPELINEITEM_H
+#define PIPELINEITEM_H
+
+#include "common/PropertyObject.h"
+
+namespace grndr
+{
+	class Pipeline;
+
+	class PipelineItem : public PropertyObject
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_Name;
+
+	public:
+		PipelineItem(Pipeline* pipeline, QString name = "");
+
+	public:
+		void initPipelineItem();
+
+	public:
+		const Pipeline* pipeline() const { return _pipeline; }
+		Pipeline* pipeline() { return _pipeline; }
+
+		QString getName() const { return _name; }
+		void setName(QString name) { if (_name != name) { _name = name; emit itemRenamed(); } }
+
+	public:
+		virtual void serialize(SerializationContext& ctx) const;
+		virtual void deserialize(DeserializationContext& ctx);
+
+	signals:
+		void itemRenamed();
+
+	protected:
+		Pipeline* _pipeline{nullptr};
+
+		QString _name{""};
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/PipelineManager.cpp b/Grinder/pipeline/PipelineManager.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..71fc08b76f1909d21d681d83cbc741c3ca244aa3
--- /dev/null
+++ b/Grinder/pipeline/PipelineManager.cpp
@@ -0,0 +1,64 @@
+/******************************************************************************
+ * File: PipelineManager.cpp
+ * Date: 16.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PipelineManager.h"
+#include "PipelineExceptions.h"
+
+std::shared_ptr<Pipeline> PipelineManager::createPipeline(QString name)
+{
+	if (name.isEmpty())
+		throw std::invalid_argument{_EXCPT("name may not be empty")};
+
+	if (_pipelines.selectByName(name))
+		throw PipelineException{nullptr, _EXCPT(QString{"A pipeline with name '%1' already exists"}.arg(name))};
+
+	auto pipeline = std::make_shared<Pipeline>(name);
+
+	try {	// Propage initialization errors to the caller
+		pipeline->initPipeline();
+	}
+	catch (...) {
+		throw;
+	}
+
+	_pipelines.push_back(pipeline);
+	emit pipelineCreated(pipeline);
+
+	return pipeline;
+}
+
+void PipelineManager::removePipeline(QString name)
+{
+	if (!name.isEmpty())
+	{
+		auto pipeline = _pipelines.selectByName(name);
+
+		if (pipeline)
+			removePipeline(pipeline.get());
+		else
+			throw PipelineException{nullptr, _EXCPT(QString{"Tried to remove a non-existing pipeline (Name: %1)"}.arg(name))};
+	}
+}
+
+void PipelineManager::removePipeline(const Pipeline* pipeline)
+{
+	if (pipeline)
+	{
+		auto it = _pipelines.find(pipeline);
+
+		if (it != _pipelines.cend())
+		{
+			// Keep a copy of the shared_ptr holding the pipeline to increase its use count;
+			// otherwise, the pipeline will be deleted before it has been removed from the vector, potentially causing a crash
+			auto pipeline = *it;
+
+			emit pipelineRemoved(*it);
+			_pipelines.erase(it);
+		}
+		else
+			throw PipelineException{pipeline, _EXCPT("Tried to remove a pipeline not currently managed")};
+	}
+}
diff --git a/Grinder/pipeline/PipelineManager.h b/Grinder/pipeline/PipelineManager.h
new file mode 100644
index 0000000000000000000000000000000000000000..ea3c0212c5b107219fb812194cd326b1f28e120c
--- /dev/null
+++ b/Grinder/pipeline/PipelineManager.h
@@ -0,0 +1,36 @@
+/******************************************************************************
+ * File: PipelineManager.h
+ * Date: 16.1.2018
+ *****************************************************************************/
+
+#ifndef PIPELINEMANAGER_H
+#define PIPELINEMANAGER_H
+
+#include <QObject>
+
+#include "PipelineVector.h"
+
+namespace grndr
+{
+	class PipelineManager : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		std::shared_ptr<Pipeline> createPipeline(QString name);
+		void removePipeline(QString name);
+		void removePipeline(const Pipeline* pipeline);
+
+	public:
+		const PipelineVector& pipelines() const { return _pipelines; }
+
+	signals:
+		void pipelineCreated(const std::shared_ptr<Pipeline>&);
+		void pipelineRemoved(const std::shared_ptr<Pipeline>&);
+
+	private:
+		PipelineVector _pipelines;
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/PipelineVector.cpp b/Grinder/pipeline/PipelineVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..cf5ec814e57b987dce70ea4ba7d3f7ea04d1693b
--- /dev/null
+++ b/Grinder/pipeline/PipelineVector.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: PipelineVector.cpp
+ * Date: 16.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PipelineVector.h"
+
+const char* PipelineVector::Serialization_Group = "Pipelines";
+const char* PipelineVector::Serialization_Element = "Pipeline";
+
+PipelineVector::pointer_type PipelineVector::selectByName(QString name, bool caseSensitive) const
+{
+	return selectFirst([name, caseSensitive](auto pipeline) { return pipeline->getName().compare(name, caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive) == 0; });
+}
diff --git a/Grinder/pipeline/PipelineVector.h b/Grinder/pipeline/PipelineVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..716c8813451dd48d62185518e6dc33d3f0fd246c
--- /dev/null
+++ b/Grinder/pipeline/PipelineVector.h
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: PipelineVector.h
+ * Date: 16.1.2018
+ *****************************************************************************/
+
+#ifndef PIPELINEVECTOR_H
+#define PIPELINEVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "Pipeline.h"
+
+namespace grndr
+{
+	class PipelineVector : public ObjectVector<Pipeline>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		pointer_type selectByName(QString name, bool caseSensitive = false) const;
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/Port.cpp b/Grinder/pipeline/Port.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8bd5c468a2fe4352598018f137c81442dbbdda29
--- /dev/null
+++ b/Grinder/pipeline/Port.cpp
@@ -0,0 +1,233 @@
+/******************************************************************************
+ * File: Port.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Port.h"
+#include "Block.h"
+#include "Pipeline.h"
+#include "PipelineExceptions.h"
+
+const char* Port::Serialization_Value_Type = "Type";
+const char* Port::Serialization_Value_Index = "Index";
+
+Port::Port(Block* parent, PortType type, Direction dir, const DataDescriptors& dataDescriptors, QString name) : PipelineItem(parent->pipeline(), name),
+	_block{parent}, _type{type}, _direction{dir}, _dataDescriptors{dataDescriptors}, _connections{this}
+{
+	// Verify the given data descriptors
+	if (_direction == Direction::Out && dataDescriptors.size() > 1)
+		throw std::invalid_argument{_EXCPT("Out-ports may only have one data descriptor")};
+
+	for (const auto& dataDesc : dataDescriptors)
+	{
+		if (_direction == Direction::Out && dataDesc.isArbitrary())
+			throw std::invalid_argument{_EXCPT("Out-ports may not have arbitrary data descriptors")};
+		else if (_direction == Direction::In && dataDesc.isAdaptive())
+			throw std::invalid_argument{_EXCPT("In-ports may not have adaptive data descriptors")};
+	}
+}
+
+void Port::initPort()
+{
+	PipelineItem::initPipelineItem();
+}
+
+void Port::verifyConnection(const Port* dest) const
+{
+	if (!dest)
+		throw std::invalid_argument{_EXCPT("dest may not be null")};
+
+	// Perform sanity checks: (1) Do not connect to self; (2) Port directions must match; (3) Connection may not already exist
+	if (dest == this)
+		throw PortException{this, _EXCPT("Cannot connect a port to itself")};
+
+	if (!isOut() || !dest->isIn())
+		throw PortException{this, _EXCPT("The participating ports do not accept the respective connection directions")};
+
+	if (_connections.selectByDestination(dest))
+		throw PortException{this, _EXCPT("The connection already exists")};
+
+	// An in-port may only have one connection at a time
+	if (dest->isConnected())
+		throw PortException{this, _EXCPT("The incoming port is already connected")};
+
+	// Check if the data types of this port and the destination are compatible
+	bool canConvertData = false;
+
+	for (const auto& dataDescFrom : _dataDescriptors)
+	{
+		for (const auto& dataDescTo : dest->dataDescriptors())
+		{
+			if (dataDescFrom.canConvertTo(dataDescTo))
+			{
+				canConvertData = true;
+				break;
+			}
+		}
+	}
+
+	if (!canConvertData)
+		throw PortException{this, _EXCPT("The data types of the ports are not compatible")};
+
+	// Ask the block if the connection is valid
+	try {
+		_block->verifyConnection(this, dest);	// Throws a BlockException if the connection can't be accepted
+	} catch(BlockException& e) {
+		throw PortException{this, e.what()};
+	}
+}
+
+std::shared_ptr<Connection> Port::connectTo(Port* dest)
+{
+	// Check whether the connection can be established (throws if it can't)
+	verifyConnection(dest);
+
+	auto connection = std::make_shared<Connection>(this, dest);
+
+	try {	// Propage initialization errors to the caller
+		connection->initConnection();
+	}
+	catch (...) {
+		throw;
+	}
+
+	_connections.push_back(connection);
+	emit _pipeline->connectionCreated(connection);
+
+	return connection;
+}
+
+void Port::disconnectFrom(const Port* dest)
+{
+	if (!dest)
+		throw std::invalid_argument{_EXCPT("dest may not be null")};
+
+	auto connection = _connections.selectByDestination(dest);
+
+	if (connection)
+		removeConnection(connection.get());
+	else
+		throw PortException{this, _EXCPT("Tried to remove a non-existing connection")};
+}
+
+void Port::disconnectFromAll()
+{
+	try {
+		// Remove outgoing connections
+		auto connections = _connections;	// Need a copy since disconnectFrom will modify _connections
+
+		for (const auto& connection : connections)
+			disconnectFrom(connection->destPort());
+
+		// Remove incoming connections
+		for (auto& connection : getConnections(Direction::In))
+			connection->sourcePort()->disconnectFrom(this);
+	} catch (PipelineException&) {
+		// Ignore any pipeline exceptions during disconnection (shouldn't happen anyway)
+	}
+}
+
+void Port::removeConnection(const Connection* connection)
+{
+	if (!connection)
+		throw std::invalid_argument{_EXCPT("connection may not be null")};
+
+	auto it = _connections.find(connection);
+
+	if (it != _connections.cend())
+	{
+		// Keep a copy of the shared_ptr holding the connection to increase its use count;
+		// otherwise, the connection will be deleted before it has been removed from the vector, potentially causing a crash
+		auto connection = *it;
+
+		emit _pipeline->connectionRemoved(*it);
+		_connections.erase(it);
+	}
+	else
+		throw PortException{this, _EXCPT("Tried to remove a connection not belonging to this port")};
+}
+
+QString Port::getFormattedName() const
+{
+	return QString{"%1::%2"}.arg(_block->getName()).arg(_name);
+}
+
+bool Port::isConnected() const
+{
+	if (isOut())
+		return !_connections.empty();
+	else if (isIn())
+		return !getConnections(Direction::In).empty();
+	else
+		return false;
+}
+
+ConnectionVector::pointer_vec_type Port::getConnections(Direction dir, BlockType blockType) const
+{
+	if (dir == Direction::Dry)
+		throw std::invalid_argument{_EXCPT("dir may not be Direction::Dry")};
+
+	ConnectionVector::pointer_vec_type connections;
+
+	if (dir == Direction::Out)	// Assemble outgoing connections
+	{
+		ConnectionVector::pointer_vec_type cons;
+
+		if (blockType != BlockType::Undefined)	// Only add connections which target to a block of type blockType
+			cons = _connections.select([blockType](auto con) { return con->dest() && con->dest()->getType() == blockType; });
+		else
+			cons = _connections;
+
+		connections.insert(connections.cend(), cons.cbegin(), cons.cend());
+	}	
+	else if (dir == Direction::In)	// Assemble incoming connections: Check all blocks of the pipeline for connections to us
+	{
+		for (const auto& block : _block->pipeline()->blocks())
+		{
+			if (block.get() == _block)
+				continue;
+
+			for (const auto& port : block->ports())
+			{
+				for (const auto& con : port->connections())
+				{
+					if (con->destPort() == this)	// Connection targets to us, add it
+					{
+						if (blockType != BlockType::Undefined)	// Only add connections which stem from a block of type blockType
+						{
+							if (con->source() && con->source()->getType() == blockType)
+								connections.push_back(con);
+						}
+						else
+							connections.push_back(con);
+					}
+				}
+			}
+		}
+	}
+
+	return connections;
+}
+
+void Port::serialize(SerializationContext& ctx) const
+{
+	PipelineItem::serialize(ctx);
+
+	// Serialize values
+	ctx.settings()[Serialization_Value_Type] = _type;
+	ctx.settings()[Serialization_Value_Index] = ctx.addPort(this);
+
+	// Save all connections for later serialization
+	for (const auto& connection : _connections)
+		ctx.addConnection(connection.get());
+}
+
+void Port::deserialize(DeserializationContext& ctx)
+{
+	PipelineItem::deserialize(ctx);
+
+	// Deserialize values
+	_type = ctx.settings()[Serialization_Value_Type].toString();
+	ctx.addPort(ctx.settings()[Serialization_Value_Index].toInt(), this);
+}
diff --git a/Grinder/pipeline/Port.h b/Grinder/pipeline/Port.h
new file mode 100644
index 0000000000000000000000000000000000000000..0cf99f583f96f7c8d1a17796ef3e7e992a7e273c
--- /dev/null
+++ b/Grinder/pipeline/Port.h
@@ -0,0 +1,79 @@
+/******************************************************************************
+ * File: Port.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef PORT_H
+#define PORT_H
+
+#include "PipelineItem.h"
+#include "PortType.h"
+#include "ConnectionVector.h"
+#include "BlockType.h"
+#include "engine/data/DataDescriptor.h"
+
+namespace grndr
+{
+	class Block;
+
+	class Port : public PipelineItem
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_Type;
+		static const char* Serialization_Value_Index;
+
+	public:
+		enum class Direction
+		{
+			Dry,	/* Usually indicates an invalid port */
+			In,
+			Out,
+		};
+
+	public:
+		Port(Block* parent, PortType type, Direction dir, const DataDescriptors& dataDescriptors, QString name = "");
+
+	public:
+		void initPort();		
+
+		void verifyConnection(const Port* dest) const;
+		std::shared_ptr<Connection> connectTo(Port* dest);
+		void disconnectFrom(const Port* dest);
+		void disconnectFromAll();		
+		void removeConnection(const Connection* connection);
+
+	public:
+		Block* block() { return _block; }
+		const Block* block() const { return _block; }
+
+		QString getFormattedName() const;
+		PortType getType() const { return _type; }
+		Direction getDirection() const { return _direction; }
+		void setDirection(Direction dir) { _direction = dir; }
+		bool isIn() const { return _direction == Direction::In; }
+		bool isOut() const { return _direction == Direction::Out; }
+		bool isConnected() const;
+
+		const DataDescriptors& dataDescriptors() const { return _dataDescriptors; }
+
+		const ConnectionVector& connections() const { return _connections; }
+		ConnectionVector::pointer_vec_type getConnections(Direction dir, BlockType blockType = BlockType::Undefined) const;
+
+	public:
+		virtual void serialize(SerializationContext& ctx) const override;
+		virtual void deserialize(DeserializationContext& ctx) override;
+
+	private:
+		Block* _block{nullptr};
+
+		PortType _type{PortType::Undefined};
+		Direction _direction{Direction::Dry};
+		DataDescriptors _dataDescriptors;
+
+		ConnectionVector _connections;		
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/PortType.cpp b/Grinder/pipeline/PortType.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..71166da2c26cc2e38dc1eb0f9d1678ddbff64692
--- /dev/null
+++ b/Grinder/pipeline/PortType.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: PortType.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PortType.h"
+
+const char* PortType::Undefined = "";
+
+const char* PortType::GenericIn = "In";
+const char* PortType::GenericOut = "Out";
+
+const char* PortType::ImageIn = "ImageIn";
+const char* PortType::ImageOut = "ImageOut";
diff --git a/Grinder/pipeline/PortType.h b/Grinder/pipeline/PortType.h
new file mode 100644
index 0000000000000000000000000000000000000000..fd86706dae338112287de8c86eb40fd1f8013320
--- /dev/null
+++ b/Grinder/pipeline/PortType.h
@@ -0,0 +1,38 @@
+/******************************************************************************
+ * File: PortType.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef PORTTYPE_H
+#define PORTTYPE_H
+
+#include <QString>
+
+namespace grndr
+{
+	class PortType final : public QString
+	{
+	public:
+		static const char* Undefined; /* Invalid port */
+
+		static const char* GenericIn;
+		static const char* GenericOut;
+
+		static const char* ImageIn;
+		static const char* ImageOut;
+
+	public:
+		using QString::QString;
+
+		PortType() = default;
+		PortType(const PortType& other) = default;
+		PortType(PortType&& other) = default;
+		PortType(const QString& str) { *static_cast<QString*>(this) = str; }
+
+		PortType& operator =(const PortType& other) = default;
+		PortType& operator =(PortType&& other) = default;
+		PortType& operator =(const QString& str) { *static_cast<QString*>(this) = str; return *this; }
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/PortVector.cpp b/Grinder/pipeline/PortVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ee4b2cab3b3e7ce1adec7267738be22ddc48e970
--- /dev/null
+++ b/Grinder/pipeline/PortVector.cpp
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: PortVector.cpp
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PortVector.h"
+
+const char* PortVector::Serialization_Group = "Ports";
+const char* PortVector::Serialization_Element = "Port";
+
+PortVector::PortVector(const Block* parent) :
+	_block{parent}
+{
+	if (!parent)
+		throw std::invalid_argument{_EXCPT("parent may not be null")};
+}
+
+PortVector::pointer_type PortVector::selectByType(PortType type) const
+{
+	return selectFirst([type](auto port) { return port->getType() == type; });
+}
+
+PortVector::pointer_vec_type PortVector::selectByDirection(Port::Direction dir) const
+{
+	return select([dir](auto port) { return port->getDirection() == dir; });
+}
diff --git a/Grinder/pipeline/PortVector.h b/Grinder/pipeline/PortVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..281a49cbcd331176dcf05ea98f5c8ff25d0cfffc
--- /dev/null
+++ b/Grinder/pipeline/PortVector.h
@@ -0,0 +1,35 @@
+/******************************************************************************
+ * File: PortVector.h
+ * Date: 15.1.2018
+ *****************************************************************************/
+
+#ifndef PORTVECTOR_H
+#define PORTVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "Port.h"
+
+namespace grndr
+{
+	class Block;
+
+	class PortVector : public ObjectVector<Port>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		PortVector(const Block* parent);
+
+	public:
+		pointer_type selectByType(PortType type) const;
+		pointer_vec_type selectByDirection(Port::Direction dir) const;
+
+	private:
+		const Block* _block{nullptr};
+	};
+
+}
+
+#endif
diff --git a/Grinder/pipeline/blocks/BinaryThresholdBlock.cpp b/Grinder/pipeline/blocks/BinaryThresholdBlock.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..073a3c35357cb7203127f0112dd38e1f8c70f166
--- /dev/null
+++ b/Grinder/pipeline/blocks/BinaryThresholdBlock.cpp
@@ -0,0 +1,45 @@
+/******************************************************************************
+ * File: BinaryThresholdBlock.cpp
+ * Date: 20.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BinaryThresholdBlock.h"
+#include "common/properties/RangeConstraint.h"
+#include "engine/processors/BinaryThresholdProcessor.h"
+
+const BlockType BinaryThresholdBlock::type_value = BlockType::BinaryThreshold;
+const BlockCategory BinaryThresholdBlock::category_value = BlockCategory::Thresholding;
+
+BinaryThresholdBlock::BinaryThresholdBlock(Pipeline* pipeline, QString name) : Block(pipeline, type_value, category_value, name)
+{
+
+}
+
+std::unique_ptr<ProcessorBase> BinaryThresholdBlock::createProcessor() const
+{
+	return std::make_unique<BinaryThresholdProcessor>(this);
+}
+
+void BinaryThresholdBlock::createProperties()
+{
+	_threshold = createProperty<UIntProperty>(PropertyID::Threshold, "Threshold", 128);
+	threshold()->createConstraint<RangeConstraint>(0, 255);
+	threshold()->setDescription("The threshold value to use; ranges from 0 to 255.");
+
+	_targetValue = createProperty<UIntProperty>(PropertyID::TargetValue, "Target value", 255);
+	targetValue()->createConstraint<RangeConstraint>(0, 255);
+	targetValue()->setDescription("The value to use if a pixel is above (below if inversed) the threshold value; ranges from 0 to 255.");
+
+	_invert = createProperty<BoolProperty>(PropertyID::Invert, "Invert", false);
+	invert()->setDescription("Invert the thresholding (i.e., discard a pixel if it is <em>above</em> the threshold value).");
+}
+
+void BinaryThresholdBlock::createPorts()
+{
+	DataDescriptors inPortDataDescs = {DataDescriptor::imageDescriptor(true, DataDescriptor::ValueType::Any), DataDescriptor::imageDescriptor(false, DataDescriptor::ValueType::Any)};
+	_inPort = createPort(PortType::ImageIn, Port::Direction::In, inPortDataDescs, "In");
+
+	DataDescriptors outPortDataDescs = {DataDescriptor::adaptiveOutputDescriptor("Image")};
+	_outPort = createPort(PortType::ImageOut, Port::Direction::Out, outPortDataDescs, "Out");
+}
diff --git a/Grinder/pipeline/blocks/BinaryThresholdBlock.h b/Grinder/pipeline/blocks/BinaryThresholdBlock.h
new file mode 100644
index 0000000000000000000000000000000000000000..22603e81db297747babee3dffd378cbae2dc75ee
--- /dev/null
+++ b/Grinder/pipeline/blocks/BinaryThresholdBlock.h
@@ -0,0 +1,54 @@
+/******************************************************************************
+ * File: BinaryThresholdBlock.h
+ * Date: 20.2.2018
+ *****************************************************************************/
+
+#ifndef BINARYTHRESHOLDBLOCK_H
+#define BINARYTHRESHOLDBLOCK_H
+
+#include "pipeline/Block.h"
+
+namespace grndr
+{
+	class BinaryThresholdBlock : public Block
+	{
+		Q_OBJECT
+
+	public:
+		static const BlockType type_value;
+		static const BlockCategory category_value;
+
+	public:
+		BinaryThresholdBlock(Pipeline* pipeline, QString name = "");
+
+	public:
+		virtual std::unique_ptr<ProcessorBase> createProcessor() const override;
+
+	public:
+		auto threshold() { return dynamic_cast<UIntProperty*>(_threshold.get()); }
+		auto threshold() const { return dynamic_cast<const UIntProperty*>(_threshold.get()); }
+		auto targetValue() { return dynamic_cast<UIntProperty*>(_targetValue.get()); }
+		auto targetValue() const { return dynamic_cast<const UIntProperty*>(_targetValue.get()); }
+		auto invert() { return dynamic_cast<BoolProperty*>(_invert.get()); }
+		auto invert() const { return dynamic_cast<const BoolProperty*>(_invert.get()); }
+
+		Port* inPort() { return _inPort.get(); }
+		const Port* inPort() const { return _inPort.get(); }
+		Port* outPort() { return _outPort.get(); }
+		const Port* outPort() const { return _outPort.get(); }
+
+	protected:
+		virtual void createProperties() override;
+		virtual void createPorts() override;
+
+	private:
+		std::shared_ptr<PropertyBase> _threshold;
+		std::shared_ptr<PropertyBase> _targetValue;
+		std::shared_ptr<PropertyBase> _invert;
+
+		std::shared_ptr<Port> _inPort;
+		std::shared_ptr<Port> _outPort;
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/blocks/ConvertToGrayscaleBlock.cpp b/Grinder/pipeline/blocks/ConvertToGrayscaleBlock.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e9e69afcc2c093cbdcbd86bb23ff624a4ef65220
--- /dev/null
+++ b/Grinder/pipeline/blocks/ConvertToGrayscaleBlock.cpp
@@ -0,0 +1,30 @@
+/******************************************************************************
+ * File: ConvertToGrayscaleBlock.cpp
+ * Date: 22.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ConvertToGrayscaleBlock.h"
+#include "engine/processors/ConvertToGrayscaleProcessor.h"
+
+const BlockType ConvertToGrayscaleBlock::type_value = BlockType::ConvertToGrayscale;
+const BlockCategory ConvertToGrayscaleBlock::category_value = BlockCategory::Conversion;
+
+ConvertToGrayscaleBlock::ConvertToGrayscaleBlock(Pipeline* pipeline, QString name) : Block(pipeline, type_value, category_value, name)
+{
+
+}
+
+std::unique_ptr<ProcessorBase> ConvertToGrayscaleBlock::createProcessor() const
+{
+	return std::make_unique<ConvertToGrayscaleProcessor>(this);
+}
+
+void ConvertToGrayscaleBlock::createPorts()
+{
+	DataDescriptors inPortDataDescs = {DataDescriptor::imageDescriptor(true, DataDescriptor::ValueType::Any), DataDescriptor::imageDescriptor(false, DataDescriptor::ValueType::Any)};
+	_inPort = createPort(PortType::ImageIn, Port::Direction::In, inPortDataDescs, "In");
+
+	DataDescriptors outPortDataDescs = {DataDescriptor::imageDescriptor(false, DataDescriptor::ValueType::Adaptive)};
+	_outPort = createPort(PortType::ImageOut, Port::Direction::Out, outPortDataDescs, "Out");
+}
diff --git a/Grinder/pipeline/blocks/ConvertToGrayscaleBlock.h b/Grinder/pipeline/blocks/ConvertToGrayscaleBlock.h
new file mode 100644
index 0000000000000000000000000000000000000000..d9d5b2dee9b16e9392d684e15b7d5914f722493a
--- /dev/null
+++ b/Grinder/pipeline/blocks/ConvertToGrayscaleBlock.h
@@ -0,0 +1,42 @@
+/******************************************************************************
+ * File: ConvertToGrayscaleBlock.h
+ * Date: 22.2.2018
+ *****************************************************************************/
+
+#ifndef CONVERTTOGRAYSCALEBLOCK_H
+#define CONVERTTOGRAYSCALEBLOCK_H
+
+#include "pipeline/Block.h"
+
+namespace grndr
+{
+	class ConvertToGrayscaleBlock : public Block
+	{
+		Q_OBJECT
+
+	public:
+		static const BlockType type_value;
+		static const BlockCategory category_value;
+
+	public:
+		ConvertToGrayscaleBlock(Pipeline* pipeline, QString name = "");
+
+	public:
+		virtual std::unique_ptr<ProcessorBase> createProcessor() const override;
+
+	public:
+		Port* inPort() { return _inPort.get(); }
+		const Port* inPort() const { return _inPort.get(); }
+		Port* outPort() { return _outPort.get(); }
+		const Port* outPort() const { return _outPort.get(); }
+
+	protected:
+		virtual void createPorts() override;
+
+	private:
+		std::shared_ptr<Port> _inPort;
+		std::shared_ptr<Port> _outPort;
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/blocks/InputBlock.cpp b/Grinder/pipeline/blocks/InputBlock.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8a4b9a62fd4827a3ac20c6d077b6aea7eda8e3a8
--- /dev/null
+++ b/Grinder/pipeline/blocks/InputBlock.cpp
@@ -0,0 +1,26 @@
+/******************************************************************************
+ * File: InputBlock.cpp
+ * Date: 03.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "InputBlock.h"
+#include "engine/processors/InputProcessor.h"
+
+const BlockType InputBlock::type_value = BlockType::Input;
+const BlockCategory InputBlock::category_value = BlockCategory::Input;
+
+InputBlock::InputBlock(Pipeline* pipeline, QString name) : Block(pipeline, type_value, category_value, name)
+{
+
+}
+
+std::unique_ptr<ProcessorBase> InputBlock::createProcessor() const
+{
+	return std::make_unique<InputProcessor>(this);
+}
+
+void InputBlock::createPorts()
+{
+	_outPort = createPort(PortType::ImageOut, Port::Direction::Out, {DataDescriptor::imageDescriptor()}, "Out");
+}
diff --git a/Grinder/pipeline/blocks/InputBlock.h b/Grinder/pipeline/blocks/InputBlock.h
new file mode 100644
index 0000000000000000000000000000000000000000..0b408f8a037fcb87bf02dc406d911a2d2f1590d4
--- /dev/null
+++ b/Grinder/pipeline/blocks/InputBlock.h
@@ -0,0 +1,39 @@
+/******************************************************************************
+ * File: InputBlock.h
+ * Date: 03.2.2018
+ *****************************************************************************/
+
+#ifndef INPUTBLOCK_H
+#define INPUTBLOCK_H
+
+#include "pipeline/Block.h"
+
+namespace grndr
+{
+	class InputBlock : public Block
+	{
+		Q_OBJECT
+
+	public:
+		static const BlockType type_value;
+		static const BlockCategory category_value;
+
+	public:
+		InputBlock(Pipeline* pipeline, QString name = "");
+
+	public:
+		virtual std::unique_ptr<ProcessorBase> createProcessor() const override;
+
+	public:
+		Port* outPort() { return _outPort.get(); }
+		const Port* outPort() const { return _outPort.get(); }
+
+	protected:
+		virtual void createPorts() override;
+
+	private:
+		std::shared_ptr<Port> _outPort;
+	};
+}
+
+#endif
diff --git a/Grinder/pipeline/blocks/OutputBlock.cpp b/Grinder/pipeline/blocks/OutputBlock.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9d12232af76fb415fd3a996a20216a843b9d907b
--- /dev/null
+++ b/Grinder/pipeline/blocks/OutputBlock.cpp
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: OutputBlock.cpp
+ * Date: 15.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "OutputBlock.h"
+#include "engine/processors/OutputProcessor.h"
+
+const BlockType OutputBlock::type_value = BlockType::Output;
+const BlockCategory OutputBlock::category_value = BlockCategory::Output;
+
+OutputBlock::OutputBlock(Pipeline* pipeline, QString name) : Block(pipeline, type_value, category_value, name)
+{
+
+}
+
+std::unique_ptr<ProcessorBase> OutputBlock::createProcessor() const
+{
+	return std::make_unique<OutputProcessor>(this);
+}
+
+void OutputBlock::createPorts()
+{
+	DataDescriptors inPortDataDescs = {DataDescriptor::imageDescriptor(true, DataDescriptor::ValueType::Any), DataDescriptor::imageDescriptor(false, DataDescriptor::ValueType::Any)};
+	_inPort = createPort(PortType::ImageIn, Port::Direction::In, inPortDataDescs, "In");
+}
diff --git a/Grinder/pipeline/blocks/OutputBlock.h b/Grinder/pipeline/blocks/OutputBlock.h
new file mode 100644
index 0000000000000000000000000000000000000000..7f7f13e6db1c9eea96cce3becbd022f17473295c
--- /dev/null
+++ b/Grinder/pipeline/blocks/OutputBlock.h
@@ -0,0 +1,39 @@
+/******************************************************************************
+ * File: OutputBlock.h
+ * Date: 15.2.2018
+ *****************************************************************************/
+
+#ifndef OUTPUTBLOCK_H
+#define OUTPUTBLOCK_H
+
+#include "pipeline/Block.h"
+
+namespace grndr
+{
+	class OutputBlock : public Block
+	{
+		Q_OBJECT
+
+	public:
+		static const BlockType type_value;
+		static const BlockCategory category_value;
+
+	public:
+		OutputBlock(Pipeline* pipeline, QString name = "");
+
+	public:
+		virtual std::unique_ptr<ProcessorBase> createProcessor() const override;
+
+	public:
+		Port* inPort() { return _inPort.get(); }
+		const Port* inPort() const { return _inPort.get(); }
+
+	protected:
+		virtual void createPorts() override;
+
+	private:
+		std::shared_ptr<Port> _inPort;
+	};
+}
+
+#endif
diff --git a/Grinder/project/ImageReference.cpp b/Grinder/project/ImageReference.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..2d954dac7705986af270b88b3043553150803389
--- /dev/null
+++ b/Grinder/project/ImageReference.cpp
@@ -0,0 +1,90 @@
+/******************************************************************************
+ * File: ImageReference.cpp
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageReference.h"
+#include "project/ProjectExceptions.h"
+
+#include <QCollator>
+#include <opencv2/highgui.hpp>
+
+const char* ImageReference::Serialization_Value_File = "File";
+const char* ImageReference::Serialization_Value_Index = "Index";
+
+ImageReference::ImageReference(Project* project, QString imageFilePath) : ProjectItem(project),
+	_imageFilePath{imageFilePath}
+{
+
+}
+
+cv::Mat ImageReference::loadImage() const
+{
+	if (_isValid)
+		return cv::imread(_imageFilePath.toStdString(), cv::IMREAD_COLOR);
+	else
+		throw ImageReferenceException{this, _EXCPT("The image is invalid")};
+}
+
+void ImageReference::refreshImageInfo()
+{
+	_isValid = false;
+
+	// Get general file info
+	QFileInfo fileInfo{_imageFilePath};
+
+	if (fileInfo.exists())
+	{
+		_imageInfo.fileSize = fileInfo.size();
+		_imageInfo.fileDate = fileInfo.fileTime(QFile::FileModificationTime);
+
+		// Try loading the image
+		auto image = cv::imread(_imageFilePath.toStdString());
+
+		if (!image.empty())
+			_imageInfo.imageSize = QSize{image.cols, image.rows};
+		else
+			throw ImageReferenceException{this, _EXCPT("Invalid image format")};
+	}
+	else
+		throw ImageReferenceException{this, _EXCPT("Invalid image file path")};
+
+	_isValid = true;
+}
+
+bool ImageReference::operator <(QString image) const
+{
+	QCollator collator;
+	collator.setNumericMode(true);
+
+	return collator.compare(_imageFilePath, image) < 0;
+}
+
+bool ImageReference::operator ==(QString image) const
+{
+	QFileInfo fileInfo1{_imageFilePath};
+	QFileInfo fileInfo2{image};
+
+	return fileInfo1 == fileInfo2;
+}
+
+QString ImageReference::getImageFileName() const
+{
+	QFileInfo fileInfo{_imageFilePath};
+	return fileInfo.fileName();
+}
+
+void ImageReference::serialize(SerializationContext& ctx) const
+{
+	// Serialize values
+	ctx.settings()[Serialization_Value_File] = _imageFilePath;
+	ctx.settings()[Serialization_Value_Index] = ctx.addImageReference(this);
+}
+
+void ImageReference::deserialize(DeserializationContext& ctx)
+{
+	// Deserialize values
+	_imageFilePath = ctx.settings()[Serialization_Value_File].toString();
+	ctx.addImageReference(ctx.settings()[Serialization_Value_Index].toInt(), this);
+}
diff --git a/Grinder/project/ImageReference.h b/Grinder/project/ImageReference.h
new file mode 100644
index 0000000000000000000000000000000000000000..6c0f575ad313e9f7ee4ffab56cd178411eb98654
--- /dev/null
+++ b/Grinder/project/ImageReference.h
@@ -0,0 +1,70 @@
+/******************************************************************************
+ * File: ImageReference.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef IMAGEREFERENCE_H
+#define IMAGEREFERENCE_H
+
+#include <QDateTime>
+#include <QSize>
+#include <opencv2/core.hpp>
+
+#include "project/ProjectItem.h"
+#include "project/serialization/SerializationContext.h"
+#include "project/serialization/DeserializationContext.h"
+
+namespace grndr
+{
+	class Project;
+
+	class ImageReference : public ProjectItem
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Value_File;
+		static const char* Serialization_Value_Index;
+
+	public:
+		struct ImageInfo
+		{
+			qint64 fileSize{0};
+			QDateTime fileDate;
+
+			QSize imageSize;
+		};
+
+	public:
+		ImageReference(Project* project, QString imageFilePath);
+
+		bool operator ==(const ImageReference& ref) const { return *this == ref._imageFilePath; }
+		bool operator ==(QString image) const;
+		bool operator <(const ImageReference& ref) const { return *this < ref._imageFilePath; }
+		bool operator <(QString image) const;
+
+	public:
+		cv::Mat loadImage() const;
+
+		void refreshImageInfo();
+
+	public:
+		QString getImageFilePath() const { return _imageFilePath; }
+		QString getImageFileName() const;
+		ImageInfo getImageInfo() const { return _imageInfo; }
+
+		bool isValid() const { return _isValid; }
+
+	public:
+		void serialize(SerializationContext& ctx) const;
+		void deserialize(DeserializationContext& ctx);
+
+	private:
+		QString _imageFilePath{""};
+		ImageInfo _imageInfo;
+
+		bool _isValid{false};
+	};
+}
+
+#endif
diff --git a/Grinder/project/ImageReferenceVector.cpp b/Grinder/project/ImageReferenceVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..cc64f7a6b19837fb57e821caeb41b6ab2925a11d
--- /dev/null
+++ b/Grinder/project/ImageReferenceVector.cpp
@@ -0,0 +1,20 @@
+/******************************************************************************
+ * File: ImageReferenceVector.cpp
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageReferenceVector.h"
+
+const char* ImageReferenceVector::Serialization_Group = "ImageReferences";
+const char* ImageReferenceVector::Serialization_Element = "ImageReference";
+
+ImageReferenceVector::pointer_type ImageReferenceVector::selectByFilePath(QString filePath) const
+{
+	return selectFirst([filePath](auto ref) { return *ref == filePath; });
+}
+
+ImageReferenceVector::pointer_type ImageReferenceVector::selectByFileName(QString fileName) const
+{
+	return selectFirst([fileName](auto ref) { return ref->getImageFileName().compare(fileName, Qt::CaseInsensitive) == 0; });
+}
diff --git a/Grinder/project/ImageReferenceVector.h b/Grinder/project/ImageReferenceVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..229bc2f324c78c9c9379698fe1f31aa257e8f8fa
--- /dev/null
+++ b/Grinder/project/ImageReferenceVector.h
@@ -0,0 +1,26 @@
+/******************************************************************************
+ * File: ImageReferenceVector.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef IMAGEREFERENCEVECTOR_H
+#define IMAGEREFERENCEVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "ImageReference.h"
+
+namespace grndr
+{
+	class ImageReferenceVector : public ObjectVector<ImageReference>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		pointer_type selectByFilePath(QString filePath) const;
+		pointer_type selectByFileName(QString fileName) const;
+	};
+}
+
+#endif
diff --git a/Grinder/project/Label.cpp b/Grinder/project/Label.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0ea909bc63c15e9d82ea80c87cc310de9f877997
--- /dev/null
+++ b/Grinder/project/Label.cpp
@@ -0,0 +1,65 @@
+/******************************************************************************
+ * File: Label.cpp
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Label.h"
+
+const char* Label::Serialization_Group_Pipeline = "Pipeline";
+const char* Label::Serialization_Group_Layout = "Layout";
+const char* Label::Serialization_Group_ImageBuildPool = "ImageBuildPool";
+
+const char* Label::Serialization_Value_Name = "Name";
+
+Label::Label(Project* project, const std::shared_ptr<Pipeline>& pipeline) : ProjectItem(project),
+	_pipeline{pipeline}, _imageBuildPool{this}
+{
+	if (!pipeline)
+		throw std::invalid_argument{_EXCPT("pipeline may not be null")};
+}
+
+void Label::serialize(SerializationContext& ctx) const
+{
+	// Serialize values
+	ctx.settings()[Serialization_Value_Name] = getName();
+
+	// Serialize the pipeline
+	ctx.beginGroup(Serialization_Group_Pipeline);
+	_pipeline->serialize(ctx);
+	ctx.endGroup();
+
+	// Serialize the graph layout
+	ctx.beginGroup(Serialization_Group_Layout, true);
+	_graphLayout.serialize(ctx);
+	ctx.endGroup();
+
+	// Serialize the image builds
+	ctx.beginGroup(Serialization_Group_ImageBuildPool, true);
+	_imageBuildPool.serialize(ctx);
+	ctx.endGroup();
+}
+
+void Label::deserialize(DeserializationContext& ctx)
+{
+	// Deserialize the pipeline
+	if (ctx.beginGroup(Serialization_Group_Pipeline))
+	{
+		_pipeline->deserialize(ctx);
+		ctx.endGroup();
+	}
+
+	// Deserialize the graph layout
+	if (ctx.beginGroup(Serialization_Group_Layout))
+	{
+		_graphLayout.deserialize(_pipeline.get(), ctx);
+		ctx.endGroup();
+	}
+
+	// Deserialize the image builds
+	if (ctx.beginGroup(Serialization_Group_ImageBuildPool))
+	{
+		_imageBuildPool.deserialize(ctx);
+		ctx.endGroup();
+	}
+}
diff --git a/Grinder/project/Label.h b/Grinder/project/Label.h
new file mode 100644
index 0000000000000000000000000000000000000000..437f2809ee7bc855b327a91f8ddc8a0b87ed4196
--- /dev/null
+++ b/Grinder/project/Label.h
@@ -0,0 +1,58 @@
+/******************************************************************************
+ * File: Label.h
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#ifndef LABEL_H
+#define LABEL_H
+
+#include <memory>
+
+#include "project/ProjectItem.h"
+#include "pipeline/Pipeline.h"
+#include "image/ImageBuildPool.h"
+#include "ui/graph/GraphLayout.h"
+
+namespace grndr
+{
+	class Project;
+
+	class Label : public ProjectItem
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Group_Pipeline;
+		static const char* Serialization_Group_Layout;
+		static const char* Serialization_Group_ImageBuildPool;
+
+		static const char* Serialization_Value_Name;
+
+	public:
+		Label(Project* project, const std::shared_ptr<Pipeline>& pipeline);
+
+	public:
+		Pipeline* pipeline() { return _pipeline.get(); }
+		const Pipeline* pipeline() const { return _pipeline.get(); }
+		GraphLayout& graphLayout() { return _graphLayout; }
+		const GraphLayout& graphLayout() const { return _graphLayout; }
+
+		ImageBuildPool& imageBuildPool() { return _imageBuildPool; }
+		const ImageBuildPool& imageBuildPool() const { return _imageBuildPool; }
+
+		QString getName() const { return _pipeline->getName(); }
+		void setName(QString name) { _pipeline->setName(name); }
+
+	public:
+		void serialize(SerializationContext& ctx) const;
+		void deserialize(DeserializationContext& ctx);
+
+	private:
+		std::shared_ptr<Pipeline> _pipeline;
+		GraphLayout _graphLayout;
+
+		ImageBuildPool _imageBuildPool;
+	};
+}
+
+#endif
diff --git a/Grinder/project/LabelVector.cpp b/Grinder/project/LabelVector.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0b950d96638dfefe3ab983ab659600663df502de
--- /dev/null
+++ b/Grinder/project/LabelVector.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: LabelVector.cpp
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LabelVector.h"
+
+const char* LabelVector::Serialization_Group = "Labels";
+const char* LabelVector::Serialization_Element = "Label";
+
+LabelVector::pointer_type LabelVector::selectByName(QString name, bool caseSensitive) const
+{
+	return selectFirst([name, caseSensitive](auto label) { return label->getName().compare(name, caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive) == 0; });
+}
diff --git a/Grinder/project/LabelVector.h b/Grinder/project/LabelVector.h
new file mode 100644
index 0000000000000000000000000000000000000000..dda5105d14e2ef0adb61837e2bb2066f706eb46c
--- /dev/null
+++ b/Grinder/project/LabelVector.h
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: LabelVector.h
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#ifndef LABELVECTOR_H
+#define LABELVECTOR_H
+
+#include "common/ObjectVector.h"
+#include "Label.h"
+
+namespace grndr
+{
+	class LabelVector : public ObjectVector<Label>
+	{
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Element;
+
+	public:
+		pointer_type selectByName(QString name, bool caseSensitive = false) const;
+	};
+}
+
+#endif
diff --git a/Grinder/project/Project.cpp b/Grinder/project/Project.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8aea8229eb47761d0af409c5017f4002973d0692
--- /dev/null
+++ b/Grinder/project/Project.cpp
@@ -0,0 +1,182 @@
+/******************************************************************************
+ * File: Project.cpp
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "Project.h"
+#include "ProjectExceptions.h"
+#include "core/GrinderApplication.h"
+#include "pipeline/PipelineManager.h"
+
+const char* Project::Serialization_Group = "Project";
+const char* Project::Serialization_Value_Name = "Name";
+
+std::shared_ptr<Label> Project::createLabel(QString name)
+{
+	if (name.isEmpty())
+		throw std::invalid_argument{_EXCPT("name may not be empty")};
+
+	if (_labels.selectByName(name))
+		throw LabelException{nullptr, _EXCPT(QString{"A label with name '%1' already exists"}.arg(name))};
+
+	// Create a pipeline for the label; if this fails, re-throw
+	try {
+		auto pipeline = grinder()->pipelineManager().createPipeline(name);
+
+		// Pipeline creation succeeded, so create a label for it
+		auto label = std::make_shared<Label>(this, pipeline);
+
+		_labels.push_back(label);
+		emit labelCreated(label);
+
+		return label;
+	} catch (std::exception& e) {
+		throw LabelException{nullptr, _EXCPT(QString{"Failed to create label '%1': %2"}.arg(name).arg(e.what()))};
+	}
+}
+
+void Project::removeLabel(QString name)
+{
+	if (!name.isEmpty())
+	{
+		auto label = _labels.selectByName(name);
+
+		if (label)
+			removeLabel(label.get());
+		else
+			throw LabelException{nullptr, _EXCPT(QString{"Tried to remove a non-existing label (Name: %1)"}.arg(name))};
+	}
+}
+
+void Project::removeLabel(const Label* label)
+{
+	if (label)
+	{
+		auto it = _labels.find(label);
+
+		if (it != _labels.cend())
+		{
+			// Remove the pipeline associated with the label from the pipeline manager
+			grinder()->pipelineManager().removePipeline(it->get()->pipeline());
+
+			// Keep a copy of the shared_ptr holding the label to increase its use count;
+			// otherwise, the label will be deleted before it has been removed from the vector, potentially causing a crash
+			auto label = *it;
+
+			emit labelRemoved(*it);
+			_labels.erase(it);
+		}
+		else
+			throw LabelException{label, _EXCPT("Tried to remove a label not currently part of the project")};
+	}
+}
+
+std::shared_ptr<ImageReference> Project::createImageReference(QString imagePath)
+{
+	if (imagePath.isEmpty())
+		throw std::invalid_argument{_EXCPT("imagePath may not be empty")};
+
+	if (_imageReferences.selectByFilePath(imagePath))
+		throw ImageReferenceException{nullptr, _EXCPT(QString{"The image '%1' has already been added to the project"}.arg(imagePath))};
+
+	auto imageRef = std::make_shared<ImageReference>(this, imagePath);
+
+	try {	// Propagate image reference errors to the caller
+		imageRef->refreshImageInfo();
+	} catch (...) {
+		throw;
+	}
+
+	_imageReferences.push_back(imageRef);
+	emit imageReferenceCreated(imageRef);
+
+	return imageRef;
+}
+
+void Project::removeImageReference(QString imagePath)
+{
+	if (!imagePath.isEmpty())
+	{
+		auto ref = _imageReferences.selectByFilePath(imagePath);
+
+		if (ref)
+			removeImageReference(ref.get());
+		else
+			throw ImageReferenceException{nullptr, _EXCPT(QString{"Tried to remove a non-existing image reference (Path: %1)"}.arg(imagePath))};
+	}
+}
+
+void Project::removeImageReference(const ImageReference* imageRef)
+{
+	if (imageRef)
+	{
+		auto it = _imageReferences.find(imageRef);
+
+		if (it != _imageReferences.cend())
+		{
+			// Keep a copy of the shared_ptr holding the image ref to increase its use count;
+			// otherwise, the image ref will be deleted before it has been removed from the vector, potentially causing a crash
+			auto imageRef = *it;
+
+			emit imageReferenceRemoved(*it);
+			_imageReferences.erase(it);
+		}
+		else
+			throw ImageReferenceException{imageRef, _EXCPT("Tried to remove an image reference not currently part of the project")};
+	}
+}
+
+void Project::clear()
+{
+	// Remove all items using the corresponding project functions so that they are removed in a proper manner
+	while (!_labels.empty())
+		removeLabel(_labels.back().get());
+
+	while (!_imageReferences.empty())
+		removeImageReference(_imageReferences.back().get());
+}
+
+void Project::serialize(SerializationContext& ctx) const
+{
+	// Serialize values
+	ctx.settings()[Serialization_Value_Name] = _name;
+
+	// Serialize all image references (has to be done before the label serialization, since labels reference them)
+	ctx.beginGroup(ImageReferenceVector::Serialization_Group, true);
+	_imageReferences.serialize(ImageReferenceVector::Serialization_Element, ctx);
+	ctx.endGroup();
+
+	// Serialize all labels
+	ctx.beginGroup(LabelVector::Serialization_Group, true);
+	_labels.serialize(LabelVector::Serialization_Element, ctx);
+	ctx.endGroup();
+}
+
+void Project::deserialize(DeserializationContext& ctx)
+{
+	// Deserialize values
+	_name = ctx.settings()[Serialization_Value_Name].toString();
+
+	// Deserialize all image references (has to be done before the label deserialization, since labels reference them)
+	if (ctx.beginGroup(ImageReferenceVector::Serialization_Group))
+	{
+		_imageReferences.deserialize(ImageReferenceVector::Serialization_Element, ctx, [this](const SettingsContainer& settings) {
+			QString file = settings[ImageReference::Serialization_Value_File].toString();
+			return createImageReference(file);
+		});
+
+		ctx.endGroup();
+	}
+
+	// Deserialize all labels
+	if (ctx.beginGroup(LabelVector::Serialization_Group))
+	{
+		_labels.deserialize(LabelVector::Serialization_Element, ctx, [this](const SettingsContainer& settings) {
+			QString name = settings[Label::Serialization_Value_Name].toString();
+			return createLabel(name);
+		});
+
+		ctx.endGroup();
+	}
+}
diff --git a/Grinder/project/Project.h b/Grinder/project/Project.h
new file mode 100644
index 0000000000000000000000000000000000000000..b8b7fb5c04dfec56e57f97e2cc9763e1fce4165c
--- /dev/null
+++ b/Grinder/project/Project.h
@@ -0,0 +1,64 @@
+/******************************************************************************
+ * File: Project.h
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#ifndef PROJECT_H
+#define PROJECT_H
+
+#include <QObject>
+
+#include "LabelVector.h"
+#include "ImageReferenceVector.h"
+#include "serialization/SerializationContext.h"
+#include "serialization/DeserializationContext.h"
+
+namespace grndr
+{
+	class Project : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		static const char* Serialization_Group;
+		static const char* Serialization_Value_Name;
+
+	public:
+		std::shared_ptr<Label> createLabel(QString name);
+		void removeLabel(QString name);
+		void removeLabel(const Label* label);
+
+		std::shared_ptr<ImageReference> createImageReference(QString imagePath);
+		void removeImageReference(QString imagePath);
+		void removeImageReference(const ImageReference* imageRef);
+
+	public:
+		QString name() const { return _name; }
+		void setName(QString name) { _name = name; }
+
+		const LabelVector& labels() const { return _labels; }
+		const ImageReferenceVector& imageReferences() const { return _imageReferences; }
+
+	public:
+		void clear();
+
+	public:
+		void serialize(SerializationContext& ctx) const;
+		void deserialize(DeserializationContext& ctx);
+
+	signals:
+		void labelCreated(const std::shared_ptr<Label>&);
+		void labelRemoved(const std::shared_ptr<Label>&);
+
+		void imageReferenceCreated(const std::shared_ptr<ImageReference>&);
+		void imageReferenceRemoved(const std::shared_ptr<ImageReference>&);
+
+	private:
+		QString _name{""};
+
+		LabelVector _labels;
+		ImageReferenceVector _imageReferences;
+	};
+}
+
+#endif
diff --git a/Grinder/project/ProjectExceptions.cpp b/Grinder/project/ProjectExceptions.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9e807613a5bbaec58fa58092aeadb60395a06c15
--- /dev/null
+++ b/Grinder/project/ProjectExceptions.cpp
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: ProjectExceptions.cpp
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ProjectExceptions.h"
+#include "Label.h"
+#include "ImageReference.h"
+
+ProjectException::ProjectException(const Project* project, QString what) : GrinderException{what},
+	_project{project}
+{
+
+}
+
+LabelException::LabelException(const Label* label, QString what) : ProjectException(label ? label->project() : nullptr, what),
+	_label{label}
+{
+
+}
+
+ImageReferenceException::ImageReferenceException(const ImageReference* imageRef, QString what) : ProjectException(imageRef ? imageRef->project() : nullptr, what),
+	_imageReference{imageRef}
+{
+
+}
diff --git a/Grinder/project/ProjectExceptions.h b/Grinder/project/ProjectExceptions.h
new file mode 100644
index 0000000000000000000000000000000000000000..ca1313339eb3cb45645902cb8fa5ab71a15bb1fd
--- /dev/null
+++ b/Grinder/project/ProjectExceptions.h
@@ -0,0 +1,56 @@
+/******************************************************************************
+ * File: ProjectExceptions.h
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#ifndef PROJECTEXCEPTIONS_H
+#define PROJECTEXCEPTIONS_H
+
+#include <QString>
+
+#include "core/GrinderExceptions.h"
+
+namespace grndr
+{
+	class Project;
+	class Label;
+	class ImageReference;
+
+	class ProjectException : public GrinderException
+	{
+	public:
+		ProjectException(const Project* project, QString what);
+
+	public:
+		const Project* project() const { return _project; }
+
+	protected:
+		const Project* _project{nullptr};
+	};
+
+	class LabelException : public ProjectException
+	{
+	public:
+		LabelException(const Label* label, QString what);
+
+	public:
+		const Label* label() const { return _label; }
+
+	protected:
+		const Label* _label{nullptr};
+	};
+
+	class ImageReferenceException : public ProjectException
+	{
+	public:
+		ImageReferenceException(const ImageReference* imageRef, QString what);
+
+	public:
+		const ImageReference* imageReference() const { return _imageReference; }
+
+	protected:
+		const ImageReference* _imageReference{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/project/ProjectItem.cpp b/Grinder/project/ProjectItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..04de9b10812465528d73b91d2f5a88f41c725fa2
--- /dev/null
+++ b/Grinder/project/ProjectItem.cpp
@@ -0,0 +1,14 @@
+/******************************************************************************
+ * File: ProjectItem.cpp
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ProjectItem.h"
+
+ProjectItem::ProjectItem(grndr::Project* project) :
+	_project{project}
+{
+	if (!project)
+		throw std::invalid_argument{_EXCPT("project may not be null")};
+}
diff --git a/Grinder/project/ProjectItem.h b/Grinder/project/ProjectItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..f60c49ee2776b0b1bd56051db3c51e23bde0d6f6
--- /dev/null
+++ b/Grinder/project/ProjectItem.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: ProjectItem.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef PROJECTITEM_H
+#define PROJECTITEM_H
+
+#include <QObject>
+
+namespace grndr
+{
+	class Project;
+
+	class ProjectItem : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		ProjectItem(Project* project);
+
+	public:
+		Project* project() { return _project; }
+		const Project* project() const { return _project; }
+
+	protected:
+		Project* _project{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/project/serialization/DeserializationContext.cpp b/Grinder/project/serialization/DeserializationContext.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..68de26ac1c5cfe3482022fcdbf35e724d74993ed
--- /dev/null
+++ b/Grinder/project/serialization/DeserializationContext.cpp
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: DeserializationContext.cpp
+ * Date: 28.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DeserializationContext.h"
+
+DeserializationContext::DeserializationContext(const SettingsContainer& settings) :
+	_settings{settings}
+{
+
+}
+
+bool DeserializationContext::beginGroup(QString name)
+{
+	if (auto child = settings().child(name))
+	{
+		_settingsStack.push(child);
+		return true;
+	}
+	else
+		return false;
+}
diff --git a/Grinder/project/serialization/DeserializationContext.h b/Grinder/project/serialization/DeserializationContext.h
new file mode 100644
index 0000000000000000000000000000000000000000..b9504d25c897ae87ed238a5663a8ce6b60a9d598
--- /dev/null
+++ b/Grinder/project/serialization/DeserializationContext.h
@@ -0,0 +1,59 @@
+/******************************************************************************
+ * File: DeserializationContext.h
+ * Date: 28.2.2018
+ *****************************************************************************/
+
+#ifndef DESERIALIZATIONCONTEXT_H
+#define DESERIALIZATIONCONTEXT_H
+
+#include <stack>
+
+#include "SettingsContainer.h"
+
+namespace grndr
+{
+	class Block;
+	class Port;
+	class ImageReference;
+
+	class DeserializationContext
+	{
+	public:
+		DeserializationContext(const SettingsContainer& settings);
+
+	public:
+		SettingsContainer& settings(bool rootSettings = false) { if (!rootSettings && !_settingsStack.empty()) return *_settingsStack.top(); else return _settings; }
+		const SettingsContainer& settings(bool rootSettings = false) const { if (!rootSettings && !_settingsStack.empty()) return *_settingsStack.top(); else return _settings; }
+
+	public:
+		bool beginGroup(QString name);
+		void beginGroup(SettingsContainer* settings) { _settingsStack.push(settings); }
+		void endGroup() { _settingsStack.pop(); }
+
+	public:
+		void addBlock(int index, Block* block) { _blocks[index] = block; }
+		Block* getBlock(int index) const { return getObject(index, _blocks); }
+
+		void addPort(int index, Port* port) { _ports[index] = port; }
+		Port* getPort(int index) const { return getObject(index, _ports); }
+
+		void addImageReference(int index, ImageReference* imageRef) { _imageReferences[index] = imageRef; }
+		ImageReference* getImageReference(int index) const { return getObject(index, _imageReferences); }
+
+	private:
+		template<typename ObjType>
+		ObjType* getObject(int index, const std::map<int, ObjType*>& vec) const;
+
+	private:
+		SettingsContainer _settings;
+		std::stack<SettingsContainer*> _settingsStack;
+
+		std::map<int, Block*> _blocks;
+		std::map<int, Port*> _ports;
+		std::map<int, ImageReference*> _imageReferences;
+	};
+}
+
+#include "DeserializationContext.impl.h"
+
+#endif
diff --git a/Grinder/project/serialization/DeserializationContext.impl.h b/Grinder/project/serialization/DeserializationContext.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..f1a257468562ad933104f4d8a4ddb37797ed078b
--- /dev/null
+++ b/Grinder/project/serialization/DeserializationContext.impl.h
@@ -0,0 +1,16 @@
+/******************************************************************************
+ * File: DeserializationContext.impl.h
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DeserializationContext.h"
+
+template<typename ObjType>
+ObjType* DeserializationContext::getObject(int index, const std::map<int, ObjType*>& vec) const
+{
+	if (vec.find(index) != vec.cend())
+		return vec.at(index);
+	else
+		return nullptr;
+}
diff --git a/Grinder/project/serialization/JsonSettingsCodec.cpp b/Grinder/project/serialization/JsonSettingsCodec.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..fc3ef490dfafeb1d760858aacf36c5ae77a8eb57
--- /dev/null
+++ b/Grinder/project/serialization/JsonSettingsCodec.cpp
@@ -0,0 +1,119 @@
+/******************************************************************************
+ * File: JsonSettingsCodec.cpp
+ * Date: 27.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "JsonSettingsCodec.h"
+#include "SerializationExceptions.h"
+
+void JsonSettingsCodec::encodeSettings(const SettingsContainer& settings)
+{
+	_document.setObject(encode(settings));
+}
+
+void JsonSettingsCodec::decodeSettings(SettingsContainer& settings)
+{
+	if (!_document.isObject())
+		throw SerializationException{_EXCPT("Invalid JSON document")};
+
+	settings.clear();
+	decode(settings, _document.object());
+}
+
+QString JsonSettingsCodec::getText() const
+{
+	if (_document.isNull())
+		throw SerializationException{_EXCPT("Invalid JSON document")};
+
+	return _document.toJson();
+}
+
+void JsonSettingsCodec::setText(const QString& text)
+{
+	QJsonParseError parseError;
+	_document = QJsonDocument::fromJson(text.toLatin1(), &parseError);
+
+	if (_document.isNull())
+		throw SerializationException{_EXCPT(QString{"Invalid JSON document: %1"}.arg(parseError.errorString()))};
+}
+
+QJsonObject JsonSettingsCodec::encode(const SettingsContainer& settings)
+{
+	QJsonObject object = QJsonObject::fromVariantMap(settings.values());
+
+	// Add all children of the current container
+	for (const auto& child : settings.children())
+	{
+		if (child->isArray())
+		{
+			QJsonArray array;
+
+			// Add all children of the current child to the array
+			for (const auto& arrayElem : child->children())
+			{
+				QJsonObject containerObj;
+				containerObj[arrayElem->getName()] = encode(*arrayElem);
+				array.append(containerObj);
+			}
+
+			object[child->getName()] = array;
+		}
+		else
+			object[child->getName()] = encode(*child);
+	}
+
+	return object;
+}
+
+void JsonSettingsCodec::decode(SettingsContainer& settings, const QJsonObject& object)
+{
+	for (const auto& key : object.keys())
+	{
+		const auto& value = object[key];
+
+		if (value.isObject())
+		{
+			SettingsContainer childContainer{key};
+			decode(childContainer, value.toObject());
+			settings << childContainer;
+		}
+		else if (value.isArray())
+		{
+			SettingsContainer childContainer{key, true};
+			QJsonArray array = value.toArray();
+
+			// Iterate over all array elements and create setting containers for each
+			for (const auto& arrayElem : array)
+			{
+				if (arrayElem.isObject())
+				{
+					auto containerObj = arrayElem.toObject();
+
+					// The array element must contain a single object which contains the actual (named) object
+					if (containerObj.size() == 1)
+					{
+						QString elemName = containerObj.keys().front();
+
+						if (containerObj[elemName].isObject())
+						{
+							SettingsContainer elemContainer{elemName};
+							decode(elemContainer, containerObj[elemName].toObject());
+							childContainer << elemContainer;
+						}
+						else
+							throw SerializationException{_EXCPT("Invalid array element found")};
+					}
+					else
+						throw SerializationException{_EXCPT("Invalid container object found")};
+				}
+				else
+					throw SerializationException{_EXCPT("Invalid array found")};
+			}
+
+			settings << childContainer;
+		}
+		else
+			settings[key] = value.toVariant();
+	}
+}
diff --git a/Grinder/project/serialization/JsonSettingsCodec.h b/Grinder/project/serialization/JsonSettingsCodec.h
new file mode 100644
index 0000000000000000000000000000000000000000..0c89b6e141e2addcc6f59ad462865c89bbba04c0
--- /dev/null
+++ b/Grinder/project/serialization/JsonSettingsCodec.h
@@ -0,0 +1,34 @@
+/******************************************************************************
+ * File: JsonSettingsCodec.h
+ * Date: 27.2.2018
+ *****************************************************************************/
+
+#ifndef JSONSETTINGSCODEC_H
+#define JSONSETTINGSCODEC_H
+
+#include <QJsonDocument>
+
+#include "SettingsCodec.h"
+
+namespace grndr
+{
+	class JsonSettingsCodec : public SettingsCodec
+	{
+	public:
+		virtual void encodeSettings(const SettingsContainer& settings) override;
+		virtual void decodeSettings(SettingsContainer& settings) override;
+
+	protected:
+		virtual QString getText() const override;
+		virtual void setText(const QString& text) override;
+
+	private:
+		QJsonObject encode(const SettingsContainer& settings);
+		void decode(SettingsContainer& settings, const QJsonObject& object);
+
+	private:
+		QJsonDocument _document;
+	};
+}
+
+#endif
diff --git a/Grinder/project/serialization/ProjectSerializer.cpp b/Grinder/project/serialization/ProjectSerializer.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..4964a7fad305349c9ac14d88ea2da3fd00e57f70
--- /dev/null
+++ b/Grinder/project/serialization/ProjectSerializer.cpp
@@ -0,0 +1,41 @@
+/******************************************************************************
+ * File: ProjectSerializer.cpp
+ * Date: 27.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ProjectSerializer.h"
+#include "SerializationExceptions.h"
+#include "SerializationContext.h"
+#include "DeserializationContext.h"
+#include "project/Project.h"
+
+ProjectSerializer::ProjectSerializer(Project* project) :
+	_project{project}
+{
+	if (!project)
+		throw std::invalid_argument{_EXCPT("project may not be null")};
+}
+
+SettingsContainer ProjectSerializer::serializeProject() const
+{
+	if (_project)
+	{
+		SerializationContext ctx{_project};
+		_project->serialize(ctx);
+		return ctx.settings(true);
+	}
+	else
+		throw SerializationException{_EXCPT("No project to serialize")};
+}
+
+void ProjectSerializer::deserializeProject(const SettingsContainer& settings)
+{
+	if (_project)
+	{
+		DeserializationContext ctx{settings};
+		_project->deserialize(ctx);
+	}
+	else
+		throw SerializationException{_EXCPT("No project to deserialize")};
+}
diff --git a/Grinder/project/serialization/ProjectSerializer.h b/Grinder/project/serialization/ProjectSerializer.h
new file mode 100644
index 0000000000000000000000000000000000000000..50ee5f3b8466aaf860f21b6be75473702bdeb04f
--- /dev/null
+++ b/Grinder/project/serialization/ProjectSerializer.h
@@ -0,0 +1,29 @@
+/******************************************************************************
+ * File: ProjectSerializer.h
+ * Date: 27.2.2018
+ *****************************************************************************/
+
+#ifndef PROJECTSERIALIZER_H
+#define PROJECTSERIALIZER_H
+
+#include "SettingsContainer.h"
+
+namespace grndr
+{
+	class Project;
+
+	class ProjectSerializer
+	{
+	public:
+		ProjectSerializer(Project* project);
+
+	public:
+		SettingsContainer serializeProject() const;
+		void deserializeProject(const SettingsContainer& settings);
+
+	private:
+		Project* _project{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/project/serialization/SerializationContext.cpp b/Grinder/project/serialization/SerializationContext.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c436154f8ef76187b7361183a5bebdb832824253
--- /dev/null
+++ b/Grinder/project/serialization/SerializationContext.cpp
@@ -0,0 +1,33 @@
+/******************************************************************************
+ * File: SerializationContext.cpp
+ * Date: 28.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SerializationContext.h"
+#include "pipeline/ConnectionVector.h"
+#include "project/Project.h"
+
+SerializationContext::SerializationContext(Project* project, const SettingsContainer* settings) :
+	_project{project}, _settings{Project::Serialization_Group}
+{
+	if (!project)
+		throw std::invalid_argument{_EXCPT("project may not be null")};
+
+	if (settings)
+		_settings = *settings;
+}
+
+void SerializationContext::endGroup()
+{
+	// Remove the top group from the stack and add it to the preceding container
+	auto currentSettings = std::move(_settingsStack.top());
+	_settingsStack.pop();
+	settings() << currentSettings;
+}
+
+void SerializationContext::addConnection(const Connection* con)
+{
+	if (std::find(_connections.cbegin(), _connections.cend(), con) == _connections.cend())
+		_connections.push_back(con);
+}
diff --git a/Grinder/project/serialization/SerializationContext.h b/Grinder/project/serialization/SerializationContext.h
new file mode 100644
index 0000000000000000000000000000000000000000..6d494973fd642dfa6159e14fa53ba715897a23c5
--- /dev/null
+++ b/Grinder/project/serialization/SerializationContext.h
@@ -0,0 +1,68 @@
+/******************************************************************************
+ * File: SerializationContext.h
+ * Date: 28.2.2018
+ *****************************************************************************/
+
+#ifndef SERIALIZATIONCONTEXT_H
+#define SERIALIZATIONCONTEXT_H
+
+#include <stack>
+
+#include "SettingsContainer.h"
+
+namespace grndr
+{
+	class Project;
+	class Block;
+	class Port;	
+	class Connection;
+	class ImageReference;
+
+	class SerializationContext
+	{
+	public:
+		SerializationContext(Project* project, const SettingsContainer* settings = nullptr);
+
+	public:
+		SettingsContainer& settings(bool rootSettings = false) { if (!rootSettings && !_settingsStack.empty()) return _settingsStack.top(); else return _settings; }
+		const SettingsContainer& settings(bool rootSettings = false) const { if (!rootSettings && !_settingsStack.empty()) return _settingsStack.top(); else return _settings; }
+
+	public:
+		void beginGroup(QString name, bool isArray = false) { _settingsStack.emplace(name, isArray); }
+		void endGroup();
+
+	public:
+		int addBlock(const Block* block) { return addObject(block, _blocks); }
+		int getBlockIndex(const Block* block) const { return getObjectIndex(block, _blocks); }
+
+		int addPort(const Port* port) { return addObject(port, _ports); }
+		int getPortIndex(const Port* port) const { return getObjectIndex(port, _ports); }
+
+		void addConnection(const Connection* con);
+		const std::vector<const Connection*>& connections() { return _connections; }
+
+		int addImageReference(const ImageReference* imageRef) { return addObject(imageRef, _imageReferences); }
+		int getImageReferenceIndex(const ImageReference* imageRef) const { return getObjectIndex(imageRef, _imageReferences); }
+
+	private:
+		template<typename ObjType>
+		int addObject(const ObjType* obj, std::vector<const ObjType*>& vec);
+		template<typename ObjType>
+		int getObjectIndex(const ObjType* obj, const std::vector<const ObjType*>& vec) const;
+
+	private:
+		Project* _project{nullptr};
+
+		SettingsContainer _settings;
+		std::stack<SettingsContainer> _settingsStack;
+
+		std::vector<const Block*> _blocks;
+		std::vector<const Port*> _ports;		
+		std::vector<const Connection*> _connections;
+		std::vector<const ImageReference*> _imageReferences;
+	};
+}
+
+#include "SerializationContext.impl.h"
+
+#endif
diff --git a/Grinder/project/serialization/SerializationContext.impl.h b/Grinder/project/serialization/SerializationContext.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..01e4170b9dfefe28799f5fcd27eaa59d4e6ca10f
--- /dev/null
+++ b/Grinder/project/serialization/SerializationContext.impl.h
@@ -0,0 +1,32 @@
+/******************************************************************************
+ * File: SerializationContext.impl.h
+ * Date: 12.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SerializationContext.h"
+
+template<typename ObjType>
+int SerializationContext::addObject(const ObjType* obj, std::vector<const ObjType*>& vec)
+{
+	auto index = getObjectIndex(obj, vec);
+
+	if (index == -1)
+	{
+		vec.push_back(obj);
+		index = vec.size() - 1;
+	}
+
+	return index;
+}
+
+template<typename ObjType>
+int SerializationContext::getObjectIndex(const ObjType* obj, const std::vector<const ObjType*>& vec) const
+{
+	auto it = std::find(vec.cbegin(), vec.cend(), obj);
+
+	if (it != vec.cend())
+		return it - vec.cbegin();
+	else
+		return -1;
+}
diff --git a/Grinder/project/serialization/SerializationExceptions.cpp b/Grinder/project/serialization/SerializationExceptions.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..428f8a1077d616a6c893248af979d5846efb83fe
--- /dev/null
+++ b/Grinder/project/serialization/SerializationExceptions.cpp
@@ -0,0 +1,12 @@
+/******************************************************************************
+ * File: SerializationExceptions.cpp
+ * Date: 27.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SerializationExceptions.h"
+
+SerializationException::SerializationException(QString what) : GrinderException(what)
+{
+
+}
diff --git a/Grinder/project/serialization/SerializationExceptions.h b/Grinder/project/serialization/SerializationExceptions.h
new file mode 100644
index 0000000000000000000000000000000000000000..930f3d00beb615e329b9df1d0d5c2066326abb4e
--- /dev/null
+++ b/Grinder/project/serialization/SerializationExceptions.h
@@ -0,0 +1,20 @@
+/******************************************************************************
+ * File: SerializationExceptions.h
+ * Date: 27.2.2018
+ *****************************************************************************/
+
+#ifndef SERIALIZATIONEXCEPTIONS_H
+#define SERIALIZATIONEXCEPTIONS_H
+
+#include "core/GrinderExceptions.h"
+
+namespace grndr
+{
+	class SerializationException : public GrinderException
+	{
+	public:
+		SerializationException(QString what);
+	};
+}
+
+#endif
diff --git a/Grinder/project/serialization/SettingsCodec.cpp b/Grinder/project/serialization/SettingsCodec.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e41ba6e085fde6a7fe8f9ae384fe4d842598595e
--- /dev/null
+++ b/Grinder/project/serialization/SettingsCodec.cpp
@@ -0,0 +1,52 @@
+/******************************************************************************
+ * File: SettingsCodec.cpp
+ * Date: 27.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SettingsCodec.h"
+#include "SerializationExceptions.h"
+
+void SettingsCodec::saveContainer(const SettingsContainer& settings, QString fileName)
+{
+	try {
+		encodeSettings(settings);
+
+		QFile file{fileName};
+
+		if (file.open(QIODevice::WriteOnly|QIODevice::Truncate|QIODevice::Text))
+		{
+			QTextStream out{&file};
+			out << getText();
+		}
+		else
+			throw SerializationException{_EXCPT(QString{"Unable to open file '%1' for writing"}.arg(fileName))};
+	} catch (SerializationException&) {
+		// Just re-throw
+		throw;
+	} catch (std::exception& e) {
+		// Forward any exceptions as a SerializationException
+		throw SerializationException{_EXCPT(e.what())};
+	}
+}
+
+void SettingsCodec::loadContainer(SettingsContainer& settings, QString fileName)
+{
+	try {
+		QFile file{fileName};
+
+		if (file.open(QIODevice::ReadOnly|QIODevice::Text))
+		{
+			setText(file.readAll());
+			decodeSettings(settings);
+		}
+		else
+			throw SerializationException{_EXCPT(QString{"Unable to open file '%1' for reading"}.arg(fileName))};
+	} catch (SerializationException&) {
+		// Just re-throw
+		throw;
+	} catch (std::exception& e) {
+		// Forward any exceptions as a SerializationException
+		throw SerializationException{_EXCPT(e.what())};
+	}
+}
diff --git a/Grinder/project/serialization/SettingsCodec.h b/Grinder/project/serialization/SettingsCodec.h
new file mode 100644
index 0000000000000000000000000000000000000000..f30b3292cece0372cac6f47c75d7ac3e8f5485cf
--- /dev/null
+++ b/Grinder/project/serialization/SettingsCodec.h
@@ -0,0 +1,28 @@
+/******************************************************************************
+ * File: SettingsCodec.h
+ * Date: 27.2.2018
+ *****************************************************************************/
+
+#ifndef SETTINGSCODEC_H
+#define SETTINGSCODEC_H
+
+#include "SettingsContainer.h"
+
+namespace grndr
+{
+	class SettingsCodec
+	{
+	public:
+		virtual void encodeSettings(const SettingsContainer& settings) = 0;
+		virtual void decodeSettings(SettingsContainer& settings) = 0;
+
+		void saveContainer(const SettingsContainer& settings, QString fileName);
+		void loadContainer(SettingsContainer& settings, QString fileName);
+
+	protected:
+		virtual QString getText() const = 0;
+		virtual void setText(const QString& text) = 0;
+	};
+}
+
+#endif
diff --git a/Grinder/project/serialization/SettingsContainer.cpp b/Grinder/project/serialization/SettingsContainer.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..deecb3b47a07f7d874b25a408f471d74487c32b7
--- /dev/null
+++ b/Grinder/project/serialization/SettingsContainer.cpp
@@ -0,0 +1,62 @@
+/******************************************************************************
+ * File: SettingsContainer.cpp
+ * Date: 26.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SettingsContainer.h"
+
+SettingsContainer::SettingsContainer(QString name, bool isArray) :
+	_name{name}, _isArray{isArray}
+{
+
+}
+
+bool SettingsContainer::operator ==(const SettingsContainer& container)
+{
+	// Compare basic settings
+	if (_name != container._name || _isArray != container._isArray)
+		return false;
+
+	// Compare all stored values
+	if (_values != container._values)
+		return false;
+
+	// Compare children
+	auto compare = [](const auto& child1, const auto& child2) { return *child1 == *child2; };
+
+	if (_isArray)	// Arrays need to have the same order to be considered equal
+	{
+		if (!std::equal(container._childContainers.cbegin(), container._childContainers.cend(), _childContainers.cbegin(), _childContainers.cend(), compare))
+			return false;
+	}
+	else
+	{
+		if (!std::is_permutation(container._childContainers.cbegin(), container._childContainers.cend(), _childContainers.cbegin(), _childContainers.cend(), compare))
+			return false;
+	}
+
+	return true;
+}
+
+SettingsContainer& SettingsContainer::operator <<(const SettingsContainer& child)
+{
+	auto container = std::make_shared<SettingsContainer>(child);
+	_childContainers.push_back(container);
+	return *this;
+}
+
+SettingsContainer& SettingsContainer::operator <<(SettingsContainer&& child)
+{
+	auto container = std::make_shared<SettingsContainer>(std::move(child));
+	_childContainers.push_back(container);
+	return *this;
+}
+
+void SettingsContainer::removeChildren(QString name)
+{
+	if (name.isEmpty())
+		throw std::invalid_argument{_EXCPT("name may not be empty")};
+
+	std::remove_if(_childContainers.begin(), _childContainers.end(), [name](const auto& child) { return child->_name.compare(name, Qt::CaseInsensitive) == 0; });
+}
diff --git a/Grinder/project/serialization/SettingsContainer.h b/Grinder/project/serialization/SettingsContainer.h
new file mode 100644
index 0000000000000000000000000000000000000000..4feb392981e8f402b0658a46134202b299210ffd
--- /dev/null
+++ b/Grinder/project/serialization/SettingsContainer.h
@@ -0,0 +1,84 @@
+/******************************************************************************
+ * File: SettingsContainer.h
+ * Date: 26.2.2018
+ *****************************************************************************/
+
+#ifndef SETTINGSCONTAINER_H
+#define SETTINGSCONTAINER_H
+
+#include <QString>
+#include <QVariant>
+#include <memory>
+
+namespace grndr
+{
+	class SettingsContainer
+	{
+	public:
+		SettingsContainer(QString name = "", bool isArray = false);
+		SettingsContainer(const SettingsContainer& container) = default;
+		SettingsContainer(SettingsContainer&& container) = default;
+
+		SettingsContainer& operator =(const SettingsContainer& container) = default;
+		SettingsContainer& operator =(SettingsContainer&& container) = default;	
+
+	public:
+		QString getName() const { return _name; }
+		bool isArray() const { return _isArray; }
+
+		bool isEmpty() const { return _childContainers.empty() && _values.isEmpty(); }
+
+		void clear() { clearChildren(); clearValues(); }
+
+		bool operator ==(const SettingsContainer& container);
+		bool operator !=(const SettingsContainer& container) { return !(*this == container); }
+
+	public:
+		SettingsContainer& operator <<(const SettingsContainer& child);
+		SettingsContainer& operator <<(SettingsContainer&& child);
+
+		SettingsContainer* child(QString name) { return _child<SettingsContainer*>(name); }
+		const SettingsContainer* child(QString name) const { return _child<const SettingsContainer*>(name); }
+		const std::vector<SettingsContainer*> children() { return children(""); }
+		const std::vector<const SettingsContainer*> children() const { return children(""); }
+		const std::vector<SettingsContainer*> children(QString name) { return _children<SettingsContainer*>(name); }
+		const std::vector<const SettingsContainer*> children(QString name) const { return _children<const SettingsContainer*>(name); }
+
+		void removeChildren(QString name);
+		void clearChildren() { _childContainers.clear(); }
+
+	public:
+		QVariant& operator[](QString name) { return _values[name]; }
+		const QVariant operator[](QString name) const { return _values[name]; }
+
+		const QVariantMap& values() const { return _values; }
+
+		QStringList keys() const { return _values.keys(); }
+		bool contains(QString name) { return _values.find(name) != _values.end(); }
+
+		void removeValue(QString name) { _values.remove(name); }
+		void clearValues() { _values.clear(); }
+
+		auto begin() { return _values.begin(); }
+		auto cbegin() const { return _values.cbegin(); }
+		auto end() { return _values.end(); }
+		auto cend() const { return _values.cend(); }
+
+	private:
+		template<typename DataType>
+		DataType _child(QString name) const;
+		template<typename DataType>
+		const std::vector<DataType> _children(QString name) const;
+
+	private:
+		QString _name{""};
+		bool _isArray{false};
+
+		std::vector<std::shared_ptr<SettingsContainer>> _childContainers;
+		QVariantMap _values;
+	};
+}
+
+#include "SettingsContainer.impl.h"
+
+#endif
diff --git a/Grinder/project/serialization/SettingsContainer.impl.h b/Grinder/project/serialization/SettingsContainer.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..deb06c91019ab1bc8c4117a1ea8a014258882662
--- /dev/null
+++ b/Grinder/project/serialization/SettingsContainer.impl.h
@@ -0,0 +1,34 @@
+/******************************************************************************
+ * File: SettingsContainer.impl.h
+ * Date: 27.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SettingsContainer.h"
+
+template<typename DataType>
+DataType SettingsContainer::_child(QString name) const
+{
+	auto children = _children<DataType>(name);
+
+	if (children.size() == 1)
+		return children.front();
+	else if (children.size() > 1)
+		throw std::runtime_error{_EXCPT(QString{"The container '%1' is not unique"}.arg(name))};
+	else
+		return nullptr;
+}
+
+template<typename DataType>
+const std::vector<DataType> SettingsContainer::_children(QString name) const
+{
+	std::vector<DataType> children;
+
+	for (const auto& child : _childContainers)
+	{
+		if (name.isEmpty() || child->_name.compare(name, Qt::CaseInsensitive) == 0)
+			children.push_back(child.get());
+	}
+
+	return children;
+}
diff --git a/Grinder/res/Grinder.ico b/Grinder/res/Grinder.ico
new file mode 100644
index 0000000000000000000000000000000000000000..aa8495a3735f4244c6b34e56a8c0f795b2585501
Binary files /dev/null and b/Grinder/res/Grinder.ico differ
diff --git a/Grinder/res/Grinder.manifest b/Grinder/res/Grinder.manifest
new file mode 100644
index 0000000000000000000000000000000000000000..47d33bc26afe4a66151a090def374fbf4d0cc750
--- /dev/null
+++ b/Grinder/res/Grinder.manifest
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
+	<assemblyIdentity
+		version="1.0.0.0"
+		processorArchitecture="X86"
+		name="WWU Muenster.Grinder"
+		type="win32"
+	/>
+	<description>Grinder</description>
+	<dependency>
+		<dependentAssembly>
+			<assemblyIdentity
+				type="win32"
+				name="Microsoft.Windows.Common-Controls"
+				version="6.0.0.0"
+				processorArchitecture="*"
+				publicKeyToken="6595b64144ccf1df"
+				language="*"
+        />
+		</dependentAssembly>
+	</dependency>
+	<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
+		<security>
+			<requestedPrivileges>
+				<requestedExecutionLevel
+					level="asInvoker"
+					uiAccess="false"/>
+			</requestedPrivileges>
+		</security>
+	</trustInfo>
+</assembly>
diff --git a/Grinder/res/Grinder.qrc b/Grinder/res/Grinder.qrc
new file mode 100644
index 0000000000000000000000000000000000000000..65b460948d7de489c5e8f7f54742b75a62ca81a3
--- /dev/null
+++ b/Grinder/res/Grinder.qrc
@@ -0,0 +1,55 @@
+<RCC>
+    <qresource prefix="/icons">
+        <file>GrinderIcon.png</file>
+        <file>icons/door-exit.png</file>
+        <file>icons/question-mark-in-a-circle.png</file>
+        <file>icons/edit.png</file>
+        <file>icons/delete.png</file>
+        <file>icons/zoom-in.png</file>
+        <file>icons/zoom-orig.png</file>
+        <file>icons/zoom-out.png</file>
+        <file>icons/delete-sel-items.png</file>
+        <file>icons/select-all.png</file>
+        <file>icons/block.png</file>
+        <file>icons/arrow-shuffle.png</file>
+        <file>icons/map-route.png</file>
+        <file>icons/plus-sign.png</file>
+        <file>icons/map-of-roads.png</file>
+        <file>icons/find-text.png</file>
+        <file>icons/maximize-size-option.png</file>
+        <file>icons/layout.png</file>
+        <file>icons/start.png</file>
+        <file>icons/floppy-disk-digital-data-storage-or-save-interface-symbol.png</file>
+        <file>icons/folder-with-information.png</file>
+        <file>icons/document-empty.png</file>
+        <file>icons/open-window-with-gear-sign.png</file>
+        <file>icons/zoom-fit.png</file>
+        <file>icons/documents-empty.png</file>
+        <file>icons/arrow-down.png</file>
+        <file>icons/arrow-up.png</file>
+        <file>icons/painting/transform-box.png</file>
+        <file>icons/painting/line.png</file>
+        <file>icons/painting/eyedropper.png</file>
+        <file>icons/show-arrows.png</file>
+        <file>icons/show-tags.png</file>
+        <file>icons/drag.png</file>
+        <file>icons/arrowheads-of-thin-outline-to-the-left.png</file>
+        <file>icons/right-thin-arrowheads.png</file>
+    </qresource>
+    <qresource prefix="/">
+        <file>css/global.css</file>
+        <file>css/controlBar.css</file>
+        <file>css/propertyDesc.css</file>
+        <file>css/blockList.css</file>
+        <file>css/imageEditorDockWidget.css</file>
+        <file>css/imageEditorProperties.css</file>
+    </qresource>
+    <qresource prefix="/misc">
+        <file>misc/checkerboard.png</file>
+    </qresource>
+    <qresource prefix="/cursors">
+        <file>cursors/box-cursor.png</file>
+        <file>cursors/line-cursor.png</file>
+        <file>cursors/picker-cursor.png</file>
+    </qresource>
+</RCC>
diff --git a/Grinder/res/Grinder.rc b/Grinder/res/Grinder.rc
new file mode 100644
index 0000000000000000000000000000000000000000..c5d17a765ba5d57408bac45b1cb562df2556def2
--- /dev/null
+++ b/Grinder/res/Grinder.rc
@@ -0,0 +1,28 @@
+IDI_ICON1 ICON DISCARDABLE "Grinder.ico"
+
+1 VERSIONINFO
+FILEVERSION 0,2,0,71
+PRODUCTVERSION 0,2,0,71
+FILEOS 0x4
+FILETYPE 0x0
+BEGIN
+  BLOCK "StringFileInfo"
+  BEGIN
+	BLOCK "040904B0"
+	BEGIN
+	  VALUE "CompanyName", "WWU Muenster"
+	  VALUE "FileDescription", "Grinder"
+	  VALUE "FileVersion", "0,2,0,71"
+	  VALUE "InternalName", "Grinder"
+	  VALUE "LegalCopyright", "Copyright (c) WWU Muenster"
+	  VALUE "OriginalFilename", "Grinder.exe"
+	  VALUE "ProductName", "Grinder"
+	  VALUE "ProductVersion", "0,2,0,71"
+	END
+  END
+
+  BLOCK "VarFileInfo"
+  BEGIN
+	VALUE "Translation", 0x0409, 0x04B0
+  END
+END
diff --git a/Grinder/res/GrinderIcon.png b/Grinder/res/GrinderIcon.png
new file mode 100644
index 0000000000000000000000000000000000000000..144ec6e3e7c8c1df2b805240d02ac989acb5bb42
Binary files /dev/null and b/Grinder/res/GrinderIcon.png differ
diff --git a/Grinder/res/Resources.h b/Grinder/res/Resources.h
new file mode 100644
index 0000000000000000000000000000000000000000..6c083f38bde30d4d00355a9f241f7ac8732edb74
--- /dev/null
+++ b/Grinder/res/Resources.h
@@ -0,0 +1,60 @@
+/******************************************************************************
+ * File: Resources.h
+ * Date: 08.3.2018
+ *****************************************************************************/
+
+#ifndef RESOURCES_H
+#define RESOURCES_H
+
+/* Stylesheets */
+
+#define FILE_STYLESHEET_GLOBAL "global.css"
+#define FILE_STYLESHEET_CONTROLBAR "controlBar.css"
+#define FILE_STYLESHEET_CAPTIONLABEL "captionLabel.css"
+#define FILE_STYLESHEET_BLOCKLIST "blockList.css"
+#define FILE_STYLESHEET_PROPERTYDESC "propertyDesc.css"
+#define FILE_STYLESHEET_IMAGEEDITORDOCKWIDGET "imageEditorDockWidget.css"
+#define FILE_STYLESHEET_IMAGEEDITORPROPERTIES "imageEditorProperties.css"
+
+/* Icons */
+
+#define FILE_ICON_MAIN ":/icons/GrinderIcon.png"
+#define FILE_ICON_BLOCK ":/icons/icons/block.png"
+#define FILE_ICON_LABEL ":/icons/icons/map-route.png"
+#define FILE_ICON_IMAGEREFERENCE ":/icons/icons/map-of-roads.png"
+#define FILE_ICON_LAYER ":/icons/icons/documents-empty.png"
+
+#define FILE_ICON_ADD ":/icons/icons/plus-sign.png"
+#define FILE_ICON_EDIT ":/icons/icons/edit.png"
+#define FILE_ICON_DELETE ":/icons/icons/delete.png"
+#define FILE_ICON_DELETE_SELECTED ":/icons/icons/delete-sel-items.png"
+#define FILE_ICON_SELECTALL ":/icons/icons/select-all.png"
+
+#define FILE_ICON_MOVEUP ":/icons/icons/arrow-up.png"
+#define FILE_ICON_MOVEDOWN ":/icons/icons/arrow-down.png"
+
+#define FILE_ICON_ACTIVATE ":/icons/icons/arrow-shuffle.png"
+#define FILE_ICON_VIEWIMAGE ":/icons/icons/find-text.png"
+
+#define FILE_ICON_ZOOMIN ":/icons/icons/zoom-in.png"
+#define FILE_ICON_ZOOMOUT ":/icons/icons/zoom-out.png"
+#define FILE_ICON_ZOOMFULL ":/icons/icons/zoom-orig.png"
+#define FILE_ICON_ZOOMFIT ":/icons/icons/zoom-fit.png"
+#define FILE_ICON_LAYOUTGRAPH ":/icons/icons/layout.png"
+
+#define FILE_ICON_EDITOR_BACKGROUND ":/misc/misc/checkerboard.png"
+#define FILE_ICON_EDITOR_NOIMAGE ":/icons/icons/question-mark-in-a-circle.png"
+#define FILE_ICON_EDITOR_DEFAULT ":/icons/icons/drag.png"
+#define FILE_ICON_EDITOR_LINE ":/icons/icons/painting/line.png"
+#define FILE_ICON_EDITOR_BOX ":/icons/icons/painting/transform-box.png"
+#define FILE_ICON_EDITOR_COLORPICKER ":/icons/icons/painting/eyedropper.png"
+#define FILE_ICON_EDITOR_SHOWDIRECTIONS ":/icons/icons/show-arrows.png"
+#define FILE_ICON_EDITOR_SHOWTAGS ":/icons/icons/show-tags.png"
+
+/* Cursors */
+
+#define FILE_CURSOR_EDITOR_LINE ":/cursors/cursors/line-cursor.png"
+#define FILE_CURSOR_EDITOR_BOX ":/cursors/cursors/box-cursor.png"
+#define FILE_CURSOR_EDITOR_COLORPICKER ":/cursors/cursors/picker-cursor.png"
+
+#endif
diff --git a/Grinder/res/css/blockList.css b/Grinder/res/css/blockList.css
new file mode 100644
index 0000000000000000000000000000000000000000..1edbd6ecf28714b3f6c574fa3ee8f2bf6b75534e
--- /dev/null
+++ b/Grinder/res/css/blockList.css
@@ -0,0 +1,16 @@
+#BlockStockpileCtrl
+{
+	border: none;
+	border-right: 1px solid %BORDER%;
+}
+
+#BlockStockpileCtrl QLabel
+{
+	padding: 5px;
+}
+
+#BlockStockpileCtrl QToolButton
+{
+	margin: 0px 2px 0px 2px;
+	padding: 2px;
+}
diff --git a/Grinder/res/css/controlBar.css b/Grinder/res/css/controlBar.css
new file mode 100644
index 0000000000000000000000000000000000000000..2e68ac2c17a6c43fe3316b15d6635b038277238e
--- /dev/null
+++ b/Grinder/res/css/controlBar.css
@@ -0,0 +1,50 @@
+QFrame
+{
+	background: %LIGHTBG%;
+	border: 1px solid %BORDER%;
+	padding: 0px;
+	margin: 0px;
+}
+
+QFrame QToolButton
+{
+	margin-bottom: -1px;
+}
+
+QFrame QFrame
+{
+	border: none;
+	border-right: 1px solid %LIGHTGRAY%;
+}
+
+QFrame QLabel
+{
+	border: none;
+}
+
+/* ControlBar inside a splitter */
+
+QSplitter QFrame
+{
+	background: %LIGHTBG%;
+	border: 1px solid %BORDER%;
+	padding: 0px;
+	margin: 0px;
+}
+
+QSplitter QFrame QToolButton
+{
+	margin-bottom: -1px;
+}
+
+
+QSplitter QFrame QFrame
+{
+	border: none;
+	border-right: 1px solid %LIGHTGRAY%;
+}
+
+QSplitter QFrame QLabel
+{
+	border: none;
+}
diff --git a/Grinder/res/css/global.css b/Grinder/res/css/global.css
new file mode 100644
index 0000000000000000000000000000000000000000..132351d5fb9a34696a50c7af18971373851e88da
--- /dev/null
+++ b/Grinder/res/css/global.css
@@ -0,0 +1,42 @@
+/* ToolButton */
+
+QToolButton
+{
+	padding: 2px;
+}
+
+/* DockWidget */
+
+QDockWidget
+{
+	titlebar-normal-icon: url(:/icons/icons/maximize-size-option.png);
+	titlebar-close-icon: url(:/icons/icons/delete.png);
+}
+
+QDockWidget::title
+{
+	border: 1px solid %BORDER%;
+	background: %LIGHTGRAY%;
+	text-align: center;
+	margin: 1px;
+	padding: 2px;
+}
+
+QDockWidget::close-button, QDockWidget::float-button
+{
+	margin: 1px;
+	icon-size: 16px;
+	subcontrol-position: top right;
+	subcontrol-origin: margin;
+	position: absolute;
+	top: 0px; right: 2px; bottom: 0px;
+	width: 14px;
+}
+
+/* StatusBar */
+
+QStatusBar
+{
+	border: none;
+	border-top: 1px solid %BORDER%;
+}
diff --git a/Grinder/res/css/imageEditorDockWidget.css b/Grinder/res/css/imageEditorDockWidget.css
new file mode 100644
index 0000000000000000000000000000000000000000..5612e1509ccb87661b8877e59f7e491d630df9bb
--- /dev/null
+++ b/Grinder/res/css/imageEditorDockWidget.css
@@ -0,0 +1,29 @@
+QDockWidget::close-button
+{
+	margin: 1px;
+	icon-size: 16px;
+	subcontrol-position: top right;
+	subcontrol-origin: margin;
+	position: absolute;
+	top: 0px; right: 2px; bottom: 0px;
+	width: 14px;
+}
+
+QDockWidget::float-button
+{
+	margin: 1px;
+	icon-size: 16px;
+	subcontrol-position: top right;
+	subcontrol-origin: margin;
+	position: absolute;
+	top: 0px; right: 19px; bottom: 0px;
+	width: 14px;
+}
+
+/* Inner dock widgets */
+
+QDockWidget QDockWidget::title
+{
+	border: 1px solid %LIGHTGRAY%;
+	background: %LIGHTGRAY%;
+}
diff --git a/Grinder/res/css/imageEditorProperties.css b/Grinder/res/css/imageEditorProperties.css
new file mode 100644
index 0000000000000000000000000000000000000000..1bbd69059ff399dcffb0199b19867a60f291fd19
--- /dev/null
+++ b/Grinder/res/css/imageEditorProperties.css
@@ -0,0 +1,10 @@
+QScrollArea
+{
+	background: %LIGHTBG%;
+	border: 1px solid %BORDER%;
+}
+
+QScrollArea #imageEditorProperties
+{
+	background: %LIGHTBG%;
+}
diff --git a/Grinder/res/css/propertyDesc.css b/Grinder/res/css/propertyDesc.css
new file mode 100644
index 0000000000000000000000000000000000000000..55ea46cee8e335ab2f495555a50aa1c74b0e3120
--- /dev/null
+++ b/Grinder/res/css/propertyDesc.css
@@ -0,0 +1,10 @@
+QFrame
+{
+	background: %LIGHTBG%;
+	border: 1px solid %BORDER%;
+}
+
+QFrame QLabel
+{
+	border: none;
+}
diff --git a/Grinder/res/cursors/box-cursor.png b/Grinder/res/cursors/box-cursor.png
new file mode 100644
index 0000000000000000000000000000000000000000..3b10e5c4a6768a7d5ad35098a3f08144ee5cc8e2
Binary files /dev/null and b/Grinder/res/cursors/box-cursor.png differ
diff --git a/Grinder/res/cursors/click.png b/Grinder/res/cursors/click.png
new file mode 100644
index 0000000000000000000000000000000000000000..49845813b4f830cbf876cb7673a80ace6fb8a983
Binary files /dev/null and b/Grinder/res/cursors/click.png differ
diff --git a/Grinder/res/cursors/cursor-1.png b/Grinder/res/cursors/cursor-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..cc561c47c229ae9624bf432bb07ec94489531504
Binary files /dev/null and b/Grinder/res/cursors/cursor-1.png differ
diff --git a/Grinder/res/cursors/cursor-2.png b/Grinder/res/cursors/cursor-2.png
new file mode 100644
index 0000000000000000000000000000000000000000..acc80a518346802fcb799247b2cb547d2e1cc7b0
Binary files /dev/null and b/Grinder/res/cursors/cursor-2.png differ
diff --git a/Grinder/res/cursors/cursor-3.png b/Grinder/res/cursors/cursor-3.png
new file mode 100644
index 0000000000000000000000000000000000000000..9b927db195661a6436786d593321f28f96b8fcab
Binary files /dev/null and b/Grinder/res/cursors/cursor-3.png differ
diff --git a/Grinder/res/cursors/cursor-4.png b/Grinder/res/cursors/cursor-4.png
new file mode 100644
index 0000000000000000000000000000000000000000..5e1e7e13b8c990923602ed4248a9e06499968853
Binary files /dev/null and b/Grinder/res/cursors/cursor-4.png differ
diff --git a/Grinder/res/cursors/cursor-with-question-mark.png b/Grinder/res/cursors/cursor-with-question-mark.png
new file mode 100644
index 0000000000000000000000000000000000000000..cef08e15d14a41cc2d0512a2d2d353b21d600c74
Binary files /dev/null and b/Grinder/res/cursors/cursor-with-question-mark.png differ
diff --git a/Grinder/res/cursors/cursor.png b/Grinder/res/cursors/cursor.png
new file mode 100644
index 0000000000000000000000000000000000000000..1f81e3774f78a82e9cc2e4a39c5c0904d43853e7
Binary files /dev/null and b/Grinder/res/cursors/cursor.png differ
diff --git a/Grinder/res/cursors/drag-1.png b/Grinder/res/cursors/drag-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..aa8a0805026ca750d1fdf72228c0e27f8e847296
Binary files /dev/null and b/Grinder/res/cursors/drag-1.png differ
diff --git a/Grinder/res/cursors/drag.png b/Grinder/res/cursors/drag.png
new file mode 100644
index 0000000000000000000000000000000000000000..5bd67330efc010822a68f42e66383a34428e3b84
Binary files /dev/null and b/Grinder/res/cursors/drag.png differ
diff --git a/Grinder/res/cursors/forbidden-cursor.png b/Grinder/res/cursors/forbidden-cursor.png
new file mode 100644
index 0000000000000000000000000000000000000000..6528e75f804c37874ad53a2fd45c41ae760651be
Binary files /dev/null and b/Grinder/res/cursors/forbidden-cursor.png differ
diff --git a/Grinder/res/cursors/hand-move.png b/Grinder/res/cursors/hand-move.png
new file mode 100644
index 0000000000000000000000000000000000000000..ee933f278ebab5c5e85a57f21192200fa7a67e4e
Binary files /dev/null and b/Grinder/res/cursors/hand-move.png differ
diff --git a/Grinder/res/cursors/line-cursor.png b/Grinder/res/cursors/line-cursor.png
new file mode 100644
index 0000000000000000000000000000000000000000..27c7e6ef2b283a61b45c59cfc7e5bbdbc7d389f2
Binary files /dev/null and b/Grinder/res/cursors/line-cursor.png differ
diff --git a/Grinder/res/cursors/move-arrows.png b/Grinder/res/cursors/move-arrows.png
new file mode 100644
index 0000000000000000000000000000000000000000..3e0d5368b00b164562e6899e2a3844f5ac4756ae
Binary files /dev/null and b/Grinder/res/cursors/move-arrows.png differ
diff --git a/Grinder/res/cursors/navigation-arrows.png b/Grinder/res/cursors/navigation-arrows.png
new file mode 100644
index 0000000000000000000000000000000000000000..b39503f86b10c7b1128572ea5da1b94105f48e85
Binary files /dev/null and b/Grinder/res/cursors/navigation-arrows.png differ
diff --git a/Grinder/res/cursors/picker-cursor.png b/Grinder/res/cursors/picker-cursor.png
new file mode 100644
index 0000000000000000000000000000000000000000..cf2f622e24c7c9ee11306b4b58f097236704f8b2
Binary files /dev/null and b/Grinder/res/cursors/picker-cursor.png differ
diff --git a/Grinder/res/cursors/scroll.png b/Grinder/res/cursors/scroll.png
new file mode 100644
index 0000000000000000000000000000000000000000..74656f060e1e9f7793f5d53b60e0c8e7de2cc39f
Binary files /dev/null and b/Grinder/res/cursors/scroll.png differ
diff --git a/Grinder/res/cursors/type.png b/Grinder/res/cursors/type.png
new file mode 100644
index 0000000000000000000000000000000000000000..eaa024f85d9b89ce0e17eac25e5883ecb73762ce
Binary files /dev/null and b/Grinder/res/cursors/type.png differ
diff --git a/Grinder/res/cursors/wait-cursor.png b/Grinder/res/cursors/wait-cursor.png
new file mode 100644
index 0000000000000000000000000000000000000000..8f02a0dcaf126bfb9ccd3d0605e15a880a7fac74
Binary files /dev/null and b/Grinder/res/cursors/wait-cursor.png differ
diff --git a/Grinder/res/cursors/wait.png b/Grinder/res/cursors/wait.png
new file mode 100644
index 0000000000000000000000000000000000000000..d9612a042691399ca49fe82d7a94b2977052fe75
Binary files /dev/null and b/Grinder/res/cursors/wait.png differ
diff --git a/Grinder/res/cursors/waiting.png b/Grinder/res/cursors/waiting.png
new file mode 100644
index 0000000000000000000000000000000000000000..3311c4348a792c90ab7bed316c7f4e7fb610e502
Binary files /dev/null and b/Grinder/res/cursors/waiting.png differ
diff --git a/Grinder/res/cursors/zoom-in.png b/Grinder/res/cursors/zoom-in.png
new file mode 100644
index 0000000000000000000000000000000000000000..0e6f71d5def02f657b9db2e42419ca393a7cc174
Binary files /dev/null and b/Grinder/res/cursors/zoom-in.png differ
diff --git a/Grinder/res/cursors/zoom-out.png b/Grinder/res/cursors/zoom-out.png
new file mode 100644
index 0000000000000000000000000000000000000000..c0fcf78f475855fe5165d0104f198bff19b472bd
Binary files /dev/null and b/Grinder/res/cursors/zoom-out.png differ
diff --git a/Grinder/res/icons/add-button-with-plus-symbol-in-a-black-circle.png b/Grinder/res/icons/add-button-with-plus-symbol-in-a-black-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..452bc8d5afc20ed8c0c36baaa75aae44ba190df7
Binary files /dev/null and b/Grinder/res/icons/add-button-with-plus-symbol-in-a-black-circle.png differ
diff --git a/Grinder/res/icons/arrow-circle.png b/Grinder/res/icons/arrow-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..656c9894540220fdb7746cc90522b53a83281329
Binary files /dev/null and b/Grinder/res/icons/arrow-circle.png differ
diff --git a/Grinder/res/icons/arrow-curve-pointing-left.png b/Grinder/res/icons/arrow-curve-pointing-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..e42ead034a3e5a33b467c791713a1d7a25b81b5e
Binary files /dev/null and b/Grinder/res/icons/arrow-curve-pointing-left.png differ
diff --git a/Grinder/res/icons/arrow-curving-around-a-circle.png b/Grinder/res/icons/arrow-curving-around-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..bc5240764c45e5b71f363e7533bdefd9e6da1ac8
Binary files /dev/null and b/Grinder/res/icons/arrow-curving-around-a-circle.png differ
diff --git a/Grinder/res/icons/arrow-down-left.png b/Grinder/res/icons/arrow-down-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..8e03bf1d52a187b304ad53894aa2decd2e7c0796
Binary files /dev/null and b/Grinder/res/icons/arrow-down-left.png differ
diff --git a/Grinder/res/icons/arrow-down-sign-to-navigate.png b/Grinder/res/icons/arrow-down-sign-to-navigate.png
new file mode 100644
index 0000000000000000000000000000000000000000..b48d5343c44a7ac397833568efcd2441f2c1c044
Binary files /dev/null and b/Grinder/res/icons/arrow-down-sign-to-navigate.png differ
diff --git a/Grinder/res/icons/arrow-down.png b/Grinder/res/icons/arrow-down.png
new file mode 100644
index 0000000000000000000000000000000000000000..df963828e00c1a4545ae39380c749d183dd087ef
Binary files /dev/null and b/Grinder/res/icons/arrow-down.png differ
diff --git a/Grinder/res/icons/arrow-fork.png b/Grinder/res/icons/arrow-fork.png
new file mode 100644
index 0000000000000000000000000000000000000000..1536d5acfdefc5b01bc4b6f70217f4e406e30ee6
Binary files /dev/null and b/Grinder/res/icons/arrow-fork.png differ
diff --git a/Grinder/res/icons/arrow-from.png b/Grinder/res/icons/arrow-from.png
new file mode 100644
index 0000000000000000000000000000000000000000..25b131284419365a231b2f9038d15557802192cb
Binary files /dev/null and b/Grinder/res/icons/arrow-from.png differ
diff --git a/Grinder/res/icons/arrow-in-u-shape-to-turn.png b/Grinder/res/icons/arrow-in-u-shape-to-turn.png
new file mode 100644
index 0000000000000000000000000000000000000000..ca42585db92fd97b465696f3ee9af45e53e926b3
Binary files /dev/null and b/Grinder/res/icons/arrow-in-u-shape-to-turn.png differ
diff --git a/Grinder/res/icons/arrow-into-drive-symbol.png b/Grinder/res/icons/arrow-into-drive-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..c5866569a373eb2dc4f9818b53851e3d4339a2f0
Binary files /dev/null and b/Grinder/res/icons/arrow-into-drive-symbol.png differ
diff --git a/Grinder/res/icons/arrow-join.png b/Grinder/res/icons/arrow-join.png
new file mode 100644
index 0000000000000000000000000000000000000000..3900b97fa341f00dc6192776cae38e397cda20d3
Binary files /dev/null and b/Grinder/res/icons/arrow-join.png differ
diff --git a/Grinder/res/icons/arrow-junction-one-to-the-left.png b/Grinder/res/icons/arrow-junction-one-to-the-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..f060c3ed7dc49bfb2a1921dbd3e547000779cd78
Binary files /dev/null and b/Grinder/res/icons/arrow-junction-one-to-the-left.png differ
diff --git a/Grinder/res/icons/arrow-loop-1.png b/Grinder/res/icons/arrow-loop-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..ab199541af226d3b894b1e43cb8c5e7492b0b8fd
Binary files /dev/null and b/Grinder/res/icons/arrow-loop-1.png differ
diff --git a/Grinder/res/icons/arrow-loop-symbol.png b/Grinder/res/icons/arrow-loop-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..7bd3f52a2e8070a056cc39ccdb57b0237b3a67b9
Binary files /dev/null and b/Grinder/res/icons/arrow-loop-symbol.png differ
diff --git a/Grinder/res/icons/arrow-loop.png b/Grinder/res/icons/arrow-loop.png
new file mode 100644
index 0000000000000000000000000000000000000000..7f9fd5965acc3d9dfeb143148ab389df92a75658
Binary files /dev/null and b/Grinder/res/icons/arrow-loop.png differ
diff --git a/Grinder/res/icons/arrow-merge-symbol.png b/Grinder/res/icons/arrow-merge-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..44bb1377b41b83c9dd6f2d60b328df7de8fdb240
Binary files /dev/null and b/Grinder/res/icons/arrow-merge-symbol.png differ
diff --git a/Grinder/res/icons/arrow-navigate-close.png b/Grinder/res/icons/arrow-navigate-close.png
new file mode 100644
index 0000000000000000000000000000000000000000..62ff5256c932f588620c3002538a2379abdfde2e
Binary files /dev/null and b/Grinder/res/icons/arrow-navigate-close.png differ
diff --git a/Grinder/res/icons/arrow-of-large-size-turning-to-the-left.png b/Grinder/res/icons/arrow-of-large-size-turning-to-the-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..4fe7626562a9f920e1feabba18b32b72340ab109
Binary files /dev/null and b/Grinder/res/icons/arrow-of-large-size-turning-to-the-left.png differ
diff --git a/Grinder/res/icons/arrow-out.png b/Grinder/res/icons/arrow-out.png
new file mode 100644
index 0000000000000000000000000000000000000000..30f15c09305e8e79af8b69e9317f31355ba78908
Binary files /dev/null and b/Grinder/res/icons/arrow-out.png differ
diff --git a/Grinder/res/icons/arrow-over-a-rectangular-element.png b/Grinder/res/icons/arrow-over-a-rectangular-element.png
new file mode 100644
index 0000000000000000000000000000000000000000..025f7ba837b3b278aead349231c5f0006c6114a3
Binary files /dev/null and b/Grinder/res/icons/arrow-over-a-rectangular-element.png differ
diff --git a/Grinder/res/icons/arrow-point-to-down.png b/Grinder/res/icons/arrow-point-to-down.png
new file mode 100644
index 0000000000000000000000000000000000000000..70bdeba7a59dc521dd9def75199f800a787e312f
Binary files /dev/null and b/Grinder/res/icons/arrow-point-to-down.png differ
diff --git a/Grinder/res/icons/arrow-point-to-right.png b/Grinder/res/icons/arrow-point-to-right.png
new file mode 100644
index 0000000000000000000000000000000000000000..53537958db76edc69b26584600ffbeaaee232d1a
Binary files /dev/null and b/Grinder/res/icons/arrow-point-to-right.png differ
diff --git a/Grinder/res/icons/arrow-pointing-down-right-in-a-circle.png b/Grinder/res/icons/arrow-pointing-down-right-in-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..5c43ca757c5b1f1586fc2a5441dea87961bb7d58
Binary files /dev/null and b/Grinder/res/icons/arrow-pointing-down-right-in-a-circle.png differ
diff --git a/Grinder/res/icons/arrow-pointing-right-down.png b/Grinder/res/icons/arrow-pointing-right-down.png
new file mode 100644
index 0000000000000000000000000000000000000000..21e190b23ac35569b5f1839b117f397ca82273e3
Binary files /dev/null and b/Grinder/res/icons/arrow-pointing-right-down.png differ
diff --git a/Grinder/res/icons/arrow-pointing-to-right.png b/Grinder/res/icons/arrow-pointing-to-right.png
new file mode 100644
index 0000000000000000000000000000000000000000..2cd649398ca6fc33020a02afc7c8a013bd209270
Binary files /dev/null and b/Grinder/res/icons/arrow-pointing-to-right.png differ
diff --git a/Grinder/res/icons/arrow-pointing-up-left.png b/Grinder/res/icons/arrow-pointing-up-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..4ccbf1bd6b5748efe472c87c00076d078795a656
Binary files /dev/null and b/Grinder/res/icons/arrow-pointing-up-left.png differ
diff --git a/Grinder/res/icons/arrow-shuffle.png b/Grinder/res/icons/arrow-shuffle.png
new file mode 100644
index 0000000000000000000000000000000000000000..74f974e179b1fb8727fce0f5bb5b8469d450b769
Binary files /dev/null and b/Grinder/res/icons/arrow-shuffle.png differ
diff --git a/Grinder/res/icons/arrow-spread-symbol.png b/Grinder/res/icons/arrow-spread-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..0746e694e6d616ad1281163784cf84de7198c271
Binary files /dev/null and b/Grinder/res/icons/arrow-spread-symbol.png differ
diff --git a/Grinder/res/icons/arrow-squiggly.png b/Grinder/res/icons/arrow-squiggly.png
new file mode 100644
index 0000000000000000000000000000000000000000..a972632fe6823ae3ee6203dcbe9d08e1a015a0c5
Binary files /dev/null and b/Grinder/res/icons/arrow-squiggly.png differ
diff --git a/Grinder/res/icons/arrow-through.png b/Grinder/res/icons/arrow-through.png
new file mode 100644
index 0000000000000000000000000000000000000000..23fc8834dfba8999c62e5223248fc2c1d141a7c7
Binary files /dev/null and b/Grinder/res/icons/arrow-through.png differ
diff --git a/Grinder/res/icons/arrow-to-the-left-silhouette.png b/Grinder/res/icons/arrow-to-the-left-silhouette.png
new file mode 100644
index 0000000000000000000000000000000000000000..252f0509e35ccc8c8c32a55b69c3ac01116bbd00
Binary files /dev/null and b/Grinder/res/icons/arrow-to-the-left-silhouette.png differ
diff --git a/Grinder/res/icons/arrow-to-the-right-navigation.png b/Grinder/res/icons/arrow-to-the-right-navigation.png
new file mode 100644
index 0000000000000000000000000000000000000000..6ff683adbd20e1bc5f465f962da08861ae5ce493
Binary files /dev/null and b/Grinder/res/icons/arrow-to-the-right-navigation.png differ
diff --git a/Grinder/res/icons/arrow-to.png b/Grinder/res/icons/arrow-to.png
new file mode 100644
index 0000000000000000000000000000000000000000..ba46e853c2995bee8e666b7e068956f858e62d30
Binary files /dev/null and b/Grinder/res/icons/arrow-to.png differ
diff --git a/Grinder/res/icons/arrow-up-in-a-circle.png b/Grinder/res/icons/arrow-up-in-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..fa416c4c9dbde4d0dad1f9eb934478fdfe1e7cf9
Binary files /dev/null and b/Grinder/res/icons/arrow-up-in-a-circle.png differ
diff --git a/Grinder/res/icons/arrow-up-right.png b/Grinder/res/icons/arrow-up-right.png
new file mode 100644
index 0000000000000000000000000000000000000000..526f04400afdd42eb69a56d5cf3145706f86aaad
Binary files /dev/null and b/Grinder/res/icons/arrow-up-right.png differ
diff --git a/Grinder/res/icons/arrow-up.png b/Grinder/res/icons/arrow-up.png
new file mode 100644
index 0000000000000000000000000000000000000000..f08776cb9d5048e547ecf9ef44eea31e3750a324
Binary files /dev/null and b/Grinder/res/icons/arrow-up.png differ
diff --git a/Grinder/res/icons/arrow-upward-to-rectangle-shape.png b/Grinder/res/icons/arrow-upward-to-rectangle-shape.png
new file mode 100644
index 0000000000000000000000000000000000000000..5105b110771efdba49c289ccc8cf82639b9a50a5
Binary files /dev/null and b/Grinder/res/icons/arrow-upward-to-rectangle-shape.png differ
diff --git a/Grinder/res/icons/arrowhead-thin-outline-to-the-left.png b/Grinder/res/icons/arrowhead-thin-outline-to-the-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..1bab84b37403016c605e013326bc5a58f920ee5c
Binary files /dev/null and b/Grinder/res/icons/arrowhead-thin-outline-to-the-left.png differ
diff --git a/Grinder/res/icons/arrowheads-of-thin-outline-to-the-left.png b/Grinder/res/icons/arrowheads-of-thin-outline-to-the-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..054b282f20e4306c7da21352e7331721546f983a
Binary files /dev/null and b/Grinder/res/icons/arrowheads-of-thin-outline-to-the-left.png differ
diff --git a/Grinder/res/icons/arrows-circle.png b/Grinder/res/icons/arrows-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..7a85e857748112e59cb75659c9762f9c77bf1a40
Binary files /dev/null and b/Grinder/res/icons/arrows-circle.png differ
diff --git a/Grinder/res/icons/arrows-group-interface-symbol-to-expand.png b/Grinder/res/icons/arrows-group-interface-symbol-to-expand.png
new file mode 100644
index 0000000000000000000000000000000000000000..aac515fa06793e0726d90efb3808443555c751c3
Binary files /dev/null and b/Grinder/res/icons/arrows-group-interface-symbol-to-expand.png differ
diff --git a/Grinder/res/icons/arrows-merge-pointing-to-right.png b/Grinder/res/icons/arrows-merge-pointing-to-right.png
new file mode 100644
index 0000000000000000000000000000000000000000..e2a62652912ff046113f8eab90f3e54dc4da696e
Binary files /dev/null and b/Grinder/res/icons/arrows-merge-pointing-to-right.png differ
diff --git a/Grinder/res/icons/arrows-mix.png b/Grinder/res/icons/arrows-mix.png
new file mode 100644
index 0000000000000000000000000000000000000000..2040682afa85a7a3f81e55320b458e67c9c2ede9
Binary files /dev/null and b/Grinder/res/icons/arrows-mix.png differ
diff --git a/Grinder/res/icons/back-navigational-arrow-button-pointing-to-left.png b/Grinder/res/icons/back-navigational-arrow-button-pointing-to-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..1f45a7f13578ad18c8f1e71e7928ee79e55d2ca7
Binary files /dev/null and b/Grinder/res/icons/back-navigational-arrow-button-pointing-to-left.png differ
diff --git a/Grinder/res/icons/barrier-closed.png b/Grinder/res/icons/barrier-closed.png
new file mode 100644
index 0000000000000000000000000000000000000000..421be351c033c999dc14ac0cab96b0a21c4e8562
Binary files /dev/null and b/Grinder/res/icons/barrier-closed.png differ
diff --git a/Grinder/res/icons/barrier-open.png b/Grinder/res/icons/barrier-open.png
new file mode 100644
index 0000000000000000000000000000000000000000..a98e97906595257ead627c22501ac5a02d7ba2bc
Binary files /dev/null and b/Grinder/res/icons/barrier-open.png differ
diff --git a/Grinder/res/icons/basic-text-format.png b/Grinder/res/icons/basic-text-format.png
new file mode 100644
index 0000000000000000000000000000000000000000..8c632cc0cb5dacc6e1a8f1b7cb7eb072fa0dcc1f
Binary files /dev/null and b/Grinder/res/icons/basic-text-format.png differ
diff --git a/Grinder/res/icons/basic-window-appearance.png b/Grinder/res/icons/basic-window-appearance.png
new file mode 100644
index 0000000000000000000000000000000000000000..6425c8f9b1a3bfd86bac0429798771e9c8e18a5b
Binary files /dev/null and b/Grinder/res/icons/basic-window-appearance.png differ
diff --git a/Grinder/res/icons/bell-black-shape.png b/Grinder/res/icons/bell-black-shape.png
new file mode 100644
index 0000000000000000000000000000000000000000..450306db6fb85962684f2852a851bca855fa4df1
Binary files /dev/null and b/Grinder/res/icons/bell-black-shape.png differ
diff --git a/Grinder/res/icons/biohazard-sign-inside-a-triangle-outline.png b/Grinder/res/icons/biohazard-sign-inside-a-triangle-outline.png
new file mode 100644
index 0000000000000000000000000000000000000000..57caa01ee8bf451cf40f0e56523d2e12008ccd5b
Binary files /dev/null and b/Grinder/res/icons/biohazard-sign-inside-a-triangle-outline.png differ
diff --git a/Grinder/res/icons/blank-window-with-key.png b/Grinder/res/icons/blank-window-with-key.png
new file mode 100644
index 0000000000000000000000000000000000000000..f1e445ee64f9c6e2afc4751e27ab4b9ff56e3dda
Binary files /dev/null and b/Grinder/res/icons/blank-window-with-key.png differ
diff --git a/Grinder/res/icons/block.png b/Grinder/res/icons/block.png
new file mode 100644
index 0000000000000000000000000000000000000000..fcb668e90f3931cd6f360267ee7e3d6b386226b7
Binary files /dev/null and b/Grinder/res/icons/block.png differ
diff --git a/Grinder/res/icons/book-closed-with-black-cover.png b/Grinder/res/icons/book-closed-with-black-cover.png
new file mode 100644
index 0000000000000000000000000000000000000000..199a2991083bdb83358f796ef06673aa80120c7f
Binary files /dev/null and b/Grinder/res/icons/book-closed-with-black-cover.png differ
diff --git a/Grinder/res/icons/book-of-black-cover-closed.png b/Grinder/res/icons/book-of-black-cover-closed.png
new file mode 100644
index 0000000000000000000000000000000000000000..f117f744d9d056f7be99c39aa8e140d340cd9148
Binary files /dev/null and b/Grinder/res/icons/book-of-black-cover-closed.png differ
diff --git a/Grinder/res/icons/book-open-in-the-middle.png b/Grinder/res/icons/book-open-in-the-middle.png
new file mode 100644
index 0000000000000000000000000000000000000000..b8cacd00030688c673accde74859c9040e33f0ec
Binary files /dev/null and b/Grinder/res/icons/book-open-in-the-middle.png differ
diff --git a/Grinder/res/icons/book-with-headphones-symbol.png b/Grinder/res/icons/book-with-headphones-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..5b1787b1635e14a242460a8a10cee3b03a882ed4
Binary files /dev/null and b/Grinder/res/icons/book-with-headphones-symbol.png differ
diff --git a/Grinder/res/icons/book-with-white-bookmark.png b/Grinder/res/icons/book-with-white-bookmark.png
new file mode 100644
index 0000000000000000000000000000000000000000..fbca9459c6c650adadd915a766671ff7a923a8f1
Binary files /dev/null and b/Grinder/res/icons/book-with-white-bookmark.png differ
diff --git a/Grinder/res/icons/bookmark-silhouette-variant.png b/Grinder/res/icons/bookmark-silhouette-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..380651f3f68bb7aa2edfe369d27c3f9a8831819c
Binary files /dev/null and b/Grinder/res/icons/bookmark-silhouette-variant.png differ
diff --git a/Grinder/res/icons/bookmarks.png b/Grinder/res/icons/bookmarks.png
new file mode 100644
index 0000000000000000000000000000000000000000..c6f30ed807b28caa0cb1d414179c11d3bb9064cc
Binary files /dev/null and b/Grinder/res/icons/bookmarks.png differ
diff --git a/Grinder/res/icons/books-overlapping-arrangement.png b/Grinder/res/icons/books-overlapping-arrangement.png
new file mode 100644
index 0000000000000000000000000000000000000000..ec24e8cf6892c38604e1ac8871fb84910cbf177d
Binary files /dev/null and b/Grinder/res/icons/books-overlapping-arrangement.png differ
diff --git a/Grinder/res/icons/braille-text.png b/Grinder/res/icons/braille-text.png
new file mode 100644
index 0000000000000000000000000000000000000000..433d460fb5a9fc84aebab8e54d86c45707459f69
Binary files /dev/null and b/Grinder/res/icons/braille-text.png differ
diff --git a/Grinder/res/icons/broken-heart-silhouette-shape.png b/Grinder/res/icons/broken-heart-silhouette-shape.png
new file mode 100644
index 0000000000000000000000000000000000000000..bbb9c0c4d03589f174b84c83645f112e26dc7598
Binary files /dev/null and b/Grinder/res/icons/broken-heart-silhouette-shape.png differ
diff --git a/Grinder/res/icons/buoy.png b/Grinder/res/icons/buoy.png
new file mode 100644
index 0000000000000000000000000000000000000000..3cba764bacb7c3cc8372730ac59ee1aefaec575b
Binary files /dev/null and b/Grinder/res/icons/buoy.png differ
diff --git a/Grinder/res/icons/calibration-mark.png b/Grinder/res/icons/calibration-mark.png
new file mode 100644
index 0000000000000000000000000000000000000000..1a03cd23edd1cc6b38c8c108505257c01f3870e6
Binary files /dev/null and b/Grinder/res/icons/calibration-mark.png differ
diff --git a/Grinder/res/icons/candle-burning.png b/Grinder/res/icons/candle-burning.png
new file mode 100644
index 0000000000000000000000000000000000000000..6ea5ae6e33f20353a1a3a1829bd2bf239b7013aa
Binary files /dev/null and b/Grinder/res/icons/candle-burning.png differ
diff --git a/Grinder/res/icons/candle-holder-with-candle.png b/Grinder/res/icons/candle-holder-with-candle.png
new file mode 100644
index 0000000000000000000000000000000000000000..b363326d8d29d57dac947b9ffb2b0764056875a1
Binary files /dev/null and b/Grinder/res/icons/candle-holder-with-candle.png differ
diff --git a/Grinder/res/icons/cd-burning-application.png b/Grinder/res/icons/cd-burning-application.png
new file mode 100644
index 0000000000000000000000000000000000000000..94fe0a5790feb757724db8f9c40349098e8f94eb
Binary files /dev/null and b/Grinder/res/icons/cd-burning-application.png differ
diff --git a/Grinder/res/icons/cd-case.png b/Grinder/res/icons/cd-case.png
new file mode 100644
index 0000000000000000000000000000000000000000..931be7d917a6fce0459d98fe28b02db1c963dc1a
Binary files /dev/null and b/Grinder/res/icons/cd-case.png differ
diff --git a/Grinder/res/icons/cd-drive.png b/Grinder/res/icons/cd-drive.png
new file mode 100644
index 0000000000000000000000000000000000000000..bbcb1ffda3a085ff60ba17a6b418d791fab04df6
Binary files /dev/null and b/Grinder/res/icons/cd-drive.png differ
diff --git a/Grinder/res/icons/cd-pirated-interface-symbol-for-piracy.png b/Grinder/res/icons/cd-pirated-interface-symbol-for-piracy.png
new file mode 100644
index 0000000000000000000000000000000000000000..5486cae1fa20c2c773c9d43948abf6a54ca9678a
Binary files /dev/null and b/Grinder/res/icons/cd-pirated-interface-symbol-for-piracy.png differ
diff --git a/Grinder/res/icons/cd-window.png b/Grinder/res/icons/cd-window.png
new file mode 100644
index 0000000000000000000000000000000000000000..0adb88e1cbafddcacd746d2e7f373394c00c969d
Binary files /dev/null and b/Grinder/res/icons/cd-window.png differ
diff --git a/Grinder/res/icons/cd-with-open-window.png b/Grinder/res/icons/cd-with-open-window.png
new file mode 100644
index 0000000000000000000000000000000000000000..9c1b8505bbe828180b4988ed831e124e49dd6489
Binary files /dev/null and b/Grinder/res/icons/cd-with-open-window.png differ
diff --git a/Grinder/res/icons/check-mark-black-outline.png b/Grinder/res/icons/check-mark-black-outline.png
new file mode 100644
index 0000000000000000000000000000000000000000..db1dea847f46984aee2de4130a2c2ea634ad053f
Binary files /dev/null and b/Grinder/res/icons/check-mark-black-outline.png differ
diff --git a/Grinder/res/icons/check.png b/Grinder/res/icons/check.png
new file mode 100644
index 0000000000000000000000000000000000000000..4eefbedd9438d6581db0fc0c3c09af75d39fc5a3
Binary files /dev/null and b/Grinder/res/icons/check.png differ
diff --git a/Grinder/res/icons/checkered-racing-flag-variant.png b/Grinder/res/icons/checkered-racing-flag-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..8b0a3608bcd499da8f5186a0f1c2fba786ff166d
Binary files /dev/null and b/Grinder/res/icons/checkered-racing-flag-variant.png differ
diff --git a/Grinder/res/icons/circle-outline-with-exclamation-point.png b/Grinder/res/icons/circle-outline-with-exclamation-point.png
new file mode 100644
index 0000000000000000000000000000000000000000..703087867e58b12d9324f7ff64e23317b508905e
Binary files /dev/null and b/Grinder/res/icons/circle-outline-with-exclamation-point.png differ
diff --git a/Grinder/res/icons/circle-outline.png b/Grinder/res/icons/circle-outline.png
new file mode 100644
index 0000000000000000000000000000000000000000..c8f2833c749e7b86b68e769ea402dd87ae10e188
Binary files /dev/null and b/Grinder/res/icons/circle-outline.png differ
diff --git a/Grinder/res/icons/clipboard-empty-in-black.png b/Grinder/res/icons/clipboard-empty-in-black.png
new file mode 100644
index 0000000000000000000000000000000000000000..24659fd9d59e955e4420b934065fdb9740a01f67
Binary files /dev/null and b/Grinder/res/icons/clipboard-empty-in-black.png differ
diff --git a/Grinder/res/icons/clipboard-paste-option.png b/Grinder/res/icons/clipboard-paste-option.png
new file mode 100644
index 0000000000000000000000000000000000000000..3f806b8375ef334f9c6484fcf822d3650f8cee2e
Binary files /dev/null and b/Grinder/res/icons/clipboard-paste-option.png differ
diff --git a/Grinder/res/icons/clipboard-paste-with-no-format.png b/Grinder/res/icons/clipboard-paste-with-no-format.png
new file mode 100644
index 0000000000000000000000000000000000000000..c49e9168acfae187f9ce5d811012bd3de96534a0
Binary files /dev/null and b/Grinder/res/icons/clipboard-paste-with-no-format.png differ
diff --git a/Grinder/res/icons/clipboard-variant-with-lists-and-checks.png b/Grinder/res/icons/clipboard-variant-with-lists-and-checks.png
new file mode 100644
index 0000000000000000000000000000000000000000..319d98f06ca48a3cfbe06fa0ec4794e6969a328f
Binary files /dev/null and b/Grinder/res/icons/clipboard-variant-with-lists-and-checks.png differ
diff --git a/Grinder/res/icons/clipboard-variant-with-pencil-and-check-mark-variant.png b/Grinder/res/icons/clipboard-variant-with-pencil-and-check-mark-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..0d3776b2a4ccfca3249136f116875c1649e1b0f6
Binary files /dev/null and b/Grinder/res/icons/clipboard-variant-with-pencil-and-check-mark-variant.png differ
diff --git a/Grinder/res/icons/clipboard.png b/Grinder/res/icons/clipboard.png
new file mode 100644
index 0000000000000000000000000000000000000000..fa1da1808c2500fab13af7f9c79025ed83b32283
Binary files /dev/null and b/Grinder/res/icons/clipboard.png differ
diff --git a/Grinder/res/icons/close.png b/Grinder/res/icons/close.png
new file mode 100644
index 0000000000000000000000000000000000000000..fe2aefc84df3cdb284b62b4261a8932e1b97bf95
Binary files /dev/null and b/Grinder/res/icons/close.png differ
diff --git a/Grinder/res/icons/closed-door-with-border-silhouette.png b/Grinder/res/icons/closed-door-with-border-silhouette.png
new file mode 100644
index 0000000000000000000000000000000000000000..fa5e9dc0a13c7f91ca4c94855b715b109ef4e1c1
Binary files /dev/null and b/Grinder/res/icons/closed-door-with-border-silhouette.png differ
diff --git a/Grinder/res/icons/compact-disc-variant-with-border.png b/Grinder/res/icons/compact-disc-variant-with-border.png
new file mode 100644
index 0000000000000000000000000000000000000000..2476aca0c833f4847d31d1804547a2ce997c94ef
Binary files /dev/null and b/Grinder/res/icons/compact-disc-variant-with-border.png differ
diff --git a/Grinder/res/icons/copy-documents-option.png b/Grinder/res/icons/copy-documents-option.png
new file mode 100644
index 0000000000000000000000000000000000000000..0eb0af7437b252608bf00f056d8e20fd281da727
Binary files /dev/null and b/Grinder/res/icons/copy-documents-option.png differ
diff --git a/Grinder/res/icons/cross-variant-with-arrow-edges.png b/Grinder/res/icons/cross-variant-with-arrow-edges.png
new file mode 100644
index 0000000000000000000000000000000000000000..71cf0e1c041c71012f2cc207debed41830920ab0
Binary files /dev/null and b/Grinder/res/icons/cross-variant-with-arrow-edges.png differ
diff --git a/Grinder/res/icons/crosshair-variant-with-navigation-arrows.png b/Grinder/res/icons/crosshair-variant-with-navigation-arrows.png
new file mode 100644
index 0000000000000000000000000000000000000000..955bdc1218fb46bbf538ccf0d9ef1ac447677b9a
Binary files /dev/null and b/Grinder/res/icons/crosshair-variant-with-navigation-arrows.png differ
diff --git a/Grinder/res/icons/curved-arrow-to-the-right.png b/Grinder/res/icons/curved-arrow-to-the-right.png
new file mode 100644
index 0000000000000000000000000000000000000000..dddfce7efdbf563b95dc747b770057af348de50c
Binary files /dev/null and b/Grinder/res/icons/curved-arrow-to-the-right.png differ
diff --git a/Grinder/res/icons/cut.png b/Grinder/res/icons/cut.png
new file mode 100644
index 0000000000000000000000000000000000000000..a1146c814544bc28a1bc7a9de41d5ad5daf6c642
Binary files /dev/null and b/Grinder/res/icons/cut.png differ
diff --git a/Grinder/res/icons/delete-sel-items.png b/Grinder/res/icons/delete-sel-items.png
new file mode 100644
index 0000000000000000000000000000000000000000..a1285c18b11b643ffb83c596c142cb5454e8d1b9
Binary files /dev/null and b/Grinder/res/icons/delete-sel-items.png differ
diff --git a/Grinder/res/icons/delete.png b/Grinder/res/icons/delete.png
new file mode 100644
index 0000000000000000000000000000000000000000..4416877f859255330ee8b985f2adddbd014c74e9
Binary files /dev/null and b/Grinder/res/icons/delete.png differ
diff --git a/Grinder/res/icons/dictionary-book-with-letters-a-to-z.png b/Grinder/res/icons/dictionary-book-with-letters-a-to-z.png
new file mode 100644
index 0000000000000000000000000000000000000000..9495fd781a5d930a965ab3e8fce95f2dc947e2a7
Binary files /dev/null and b/Grinder/res/icons/dictionary-book-with-letters-a-to-z.png differ
diff --git a/Grinder/res/icons/document-center.png b/Grinder/res/icons/document-center.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a301e968b9ba073da519a5200b299e8b8e0e99a
Binary files /dev/null and b/Grinder/res/icons/document-center.png differ
diff --git a/Grinder/res/icons/document-centering-horizontally.png b/Grinder/res/icons/document-centering-horizontally.png
new file mode 100644
index 0000000000000000000000000000000000000000..22b06bee3d4760e9d1f70c0024a71176bb2bd8a2
Binary files /dev/null and b/Grinder/res/icons/document-centering-horizontally.png differ
diff --git a/Grinder/res/icons/document-empty-horizontal-page.png b/Grinder/res/icons/document-empty-horizontal-page.png
new file mode 100644
index 0000000000000000000000000000000000000000..cb7f699098ca2f5ddbc519939f8d32b5af163216
Binary files /dev/null and b/Grinder/res/icons/document-empty-horizontal-page.png differ
diff --git a/Grinder/res/icons/document-empty.png b/Grinder/res/icons/document-empty.png
new file mode 100644
index 0000000000000000000000000000000000000000..557509ce52c833bc278c13fc8d301a05b674d49e
Binary files /dev/null and b/Grinder/res/icons/document-empty.png differ
diff --git a/Grinder/res/icons/document-file-with-line.png b/Grinder/res/icons/document-file-with-line.png
new file mode 100644
index 0000000000000000000000000000000000000000..9445dc4ce5bdb2512b8148bb4b52ad4695c96fc4
Binary files /dev/null and b/Grinder/res/icons/document-file-with-line.png differ
diff --git a/Grinder/res/icons/document-footer.png b/Grinder/res/icons/document-footer.png
new file mode 100644
index 0000000000000000000000000000000000000000..1ce335f9cea6fae4a1e654e6ca093c2efa5a05f7
Binary files /dev/null and b/Grinder/res/icons/document-footer.png differ
diff --git a/Grinder/res/icons/document-header.png b/Grinder/res/icons/document-header.png
new file mode 100644
index 0000000000000000000000000000000000000000..242071eb74b88607928981e8cb1a2a00487c2221
Binary files /dev/null and b/Grinder/res/icons/document-header.png differ
diff --git a/Grinder/res/icons/document-height-adjustment.png b/Grinder/res/icons/document-height-adjustment.png
new file mode 100644
index 0000000000000000000000000000000000000000..efffbc45eb082e75dcd987fb348e40437de068ca
Binary files /dev/null and b/Grinder/res/icons/document-height-adjustment.png differ
diff --git a/Grinder/res/icons/document-landscape-orientation.png b/Grinder/res/icons/document-landscape-orientation.png
new file mode 100644
index 0000000000000000000000000000000000000000..9413e3b57c31eb02f4244211c73ff1eac9a7978f
Binary files /dev/null and b/Grinder/res/icons/document-landscape-orientation.png differ
diff --git a/Grinder/res/icons/document-page-number.png b/Grinder/res/icons/document-page-number.png
new file mode 100644
index 0000000000000000000000000000000000000000..e1324f1c670552e475c95cb6e2398ce2c22b8212
Binary files /dev/null and b/Grinder/res/icons/document-page-number.png differ
diff --git a/Grinder/res/icons/document-pinned.png b/Grinder/res/icons/document-pinned.png
new file mode 100644
index 0000000000000000000000000000000000000000..55d010d2d12cd4d716bc914d7ed31cf7040c1b4e
Binary files /dev/null and b/Grinder/res/icons/document-pinned.png differ
diff --git a/Grinder/res/icons/document-size.png b/Grinder/res/icons/document-size.png
new file mode 100644
index 0000000000000000000000000000000000000000..beaf6ae0ecf01ea175193b730c1e150baeb6ef0a
Binary files /dev/null and b/Grinder/res/icons/document-size.png differ
diff --git a/Grinder/res/icons/document-tag-interface-symbol.png b/Grinder/res/icons/document-tag-interface-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..fbda34dd5e3ddaf953cc3b772b1b2043d45a2cc4
Binary files /dev/null and b/Grinder/res/icons/document-tag-interface-symbol.png differ
diff --git a/Grinder/res/icons/document-vertical-center-alignment.png b/Grinder/res/icons/document-vertical-center-alignment.png
new file mode 100644
index 0000000000000000000000000000000000000000..8baec09342013a7f883ae9b73c30829e9ffbc933
Binary files /dev/null and b/Grinder/res/icons/document-vertical-center-alignment.png differ
diff --git a/Grinder/res/icons/document-width.png b/Grinder/res/icons/document-width.png
new file mode 100644
index 0000000000000000000000000000000000000000..cf2d99150580d168cc0a80d16feca9a2691c8034
Binary files /dev/null and b/Grinder/res/icons/document-width.png differ
diff --git a/Grinder/res/icons/document-with-a-cup.png b/Grinder/res/icons/document-with-a-cup.png
new file mode 100644
index 0000000000000000000000000000000000000000..280e27018718a549cf77662490317c1db3150678
Binary files /dev/null and b/Grinder/res/icons/document-with-a-cup.png differ
diff --git a/Grinder/res/icons/document-with-gear.png b/Grinder/res/icons/document-with-gear.png
new file mode 100644
index 0000000000000000000000000000000000000000..d778b9d92f16d80aa957a5566a374d0d3bf5467b
Binary files /dev/null and b/Grinder/res/icons/document-with-gear.png differ
diff --git a/Grinder/res/icons/document-with-irregular-line.png b/Grinder/res/icons/document-with-irregular-line.png
new file mode 100644
index 0000000000000000000000000000000000000000..6dd4ec8506d71bf63f50b3534cb48fef33bf56ea
Binary files /dev/null and b/Grinder/res/icons/document-with-irregular-line.png differ
diff --git a/Grinder/res/icons/document-with-line-chart.png b/Grinder/res/icons/document-with-line-chart.png
new file mode 100644
index 0000000000000000000000000000000000000000..c2cd75be922f0e8e71d5a31c72b335f939864889
Binary files /dev/null and b/Grinder/res/icons/document-with-line-chart.png differ
diff --git a/Grinder/res/icons/document-with-paper-clip.png b/Grinder/res/icons/document-with-paper-clip.png
new file mode 100644
index 0000000000000000000000000000000000000000..044e2bbebe81db35f035cdf5717c0b7651f3d125
Binary files /dev/null and b/Grinder/res/icons/document-with-paper-clip.png differ
diff --git a/Grinder/res/icons/document-with-selection-box.png b/Grinder/res/icons/document-with-selection-box.png
new file mode 100644
index 0000000000000000000000000000000000000000..8ed3bff15fa74349a591a5f550dd33a10b56c19b
Binary files /dev/null and b/Grinder/res/icons/document-with-selection-box.png differ
diff --git a/Grinder/res/icons/document-with-speaker.png b/Grinder/res/icons/document-with-speaker.png
new file mode 100644
index 0000000000000000000000000000000000000000..26eddb061b71773a4e1fffb398c5d05253c14e56
Binary files /dev/null and b/Grinder/res/icons/document-with-speaker.png differ
diff --git a/Grinder/res/icons/documents-empty.png b/Grinder/res/icons/documents-empty.png
new file mode 100644
index 0000000000000000000000000000000000000000..d1cebaea87ac9cece55c93067bf7d0309a6cfa25
Binary files /dev/null and b/Grinder/res/icons/documents-empty.png differ
diff --git a/Grinder/res/icons/documents-exchange.png b/Grinder/res/icons/documents-exchange.png
new file mode 100644
index 0000000000000000000000000000000000000000..013639b86ef6e77d521ef943fe2d484f6468ff5d
Binary files /dev/null and b/Grinder/res/icons/documents-exchange.png differ
diff --git a/Grinder/res/icons/documents-with-a-heart-symbol.png b/Grinder/res/icons/documents-with-a-heart-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..e60d5a9254fea6efbb308a61613da36a33b71925
Binary files /dev/null and b/Grinder/res/icons/documents-with-a-heart-symbol.png differ
diff --git a/Grinder/res/icons/door-exit.png b/Grinder/res/icons/door-exit.png
new file mode 100644
index 0000000000000000000000000000000000000000..e99db497fe6f9d86d01b348e354c603cd18320ef
Binary files /dev/null and b/Grinder/res/icons/door-exit.png differ
diff --git a/Grinder/res/icons/double-curve-arrow-to-the-right.png b/Grinder/res/icons/double-curve-arrow-to-the-right.png
new file mode 100644
index 0000000000000000000000000000000000000000..281f2deb00d41bd417c6535ec03d4a2e00e51258
Binary files /dev/null and b/Grinder/res/icons/double-curve-arrow-to-the-right.png differ
diff --git a/Grinder/res/icons/drag.png b/Grinder/res/icons/drag.png
new file mode 100644
index 0000000000000000000000000000000000000000..af20596adae17c564274d4c6a44710810762902d
Binary files /dev/null and b/Grinder/res/icons/drag.png differ
diff --git a/Grinder/res/icons/edit.png b/Grinder/res/icons/edit.png
new file mode 100644
index 0000000000000000000000000000000000000000..050e2fd65b0a1d8367b083640a6215ecef561ccc
Binary files /dev/null and b/Grinder/res/icons/edit.png differ
diff --git a/Grinder/res/icons/equalizer-tool-on-open-window.png b/Grinder/res/icons/equalizer-tool-on-open-window.png
new file mode 100644
index 0000000000000000000000000000000000000000..e8b08ba874c75cfc37cb4a313f9f6fb61d080398
Binary files /dev/null and b/Grinder/res/icons/equalizer-tool-on-open-window.png differ
diff --git a/Grinder/res/icons/escalator-down.png b/Grinder/res/icons/escalator-down.png
new file mode 100644
index 0000000000000000000000000000000000000000..4eaff082292e51a17696c3cfc8e0ebfe68f5e824
Binary files /dev/null and b/Grinder/res/icons/escalator-down.png differ
diff --git a/Grinder/res/icons/escalator-silhouette-symbol.png b/Grinder/res/icons/escalator-silhouette-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..c0971d97711b4fdaa5d2f1a898c3966e577dde71
Binary files /dev/null and b/Grinder/res/icons/escalator-silhouette-symbol.png differ
diff --git a/Grinder/res/icons/escalator-up-sign.png b/Grinder/res/icons/escalator-up-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..e78075f68df46c57ca17a7be2bcd6f5020f6cc4a
Binary files /dev/null and b/Grinder/res/icons/escalator-up-sign.png differ
diff --git a/Grinder/res/icons/female-symbol.png b/Grinder/res/icons/female-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..2015503fe9f5868ddf9487acb1d6a1662446f748
Binary files /dev/null and b/Grinder/res/icons/female-symbol.png differ
diff --git a/Grinder/res/icons/find-again-option.png b/Grinder/res/icons/find-again-option.png
new file mode 100644
index 0000000000000000000000000000000000000000..c3c5b0530b41348b7b6066e01798ec119d1f3039
Binary files /dev/null and b/Grinder/res/icons/find-again-option.png differ
diff --git a/Grinder/res/icons/find-and-replace.png b/Grinder/res/icons/find-and-replace.png
new file mode 100644
index 0000000000000000000000000000000000000000..ef2ac692ee22cff281bcb0f9cc2757f5b0b2a673
Binary files /dev/null and b/Grinder/res/icons/find-and-replace.png differ
diff --git a/Grinder/res/icons/find-text.png b/Grinder/res/icons/find-text.png
new file mode 100644
index 0000000000000000000000000000000000000000..831680af82b2430b744914bbf3d7cb0be4ac1c48
Binary files /dev/null and b/Grinder/res/icons/find-text.png differ
diff --git a/Grinder/res/icons/first-aid-cross-in-black-inside-a-circle.png b/Grinder/res/icons/first-aid-cross-in-black-inside-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..2b27381c440a682a9d0aa20d71d6efeb9fc1c955
Binary files /dev/null and b/Grinder/res/icons/first-aid-cross-in-black-inside-a-circle.png differ
diff --git a/Grinder/res/icons/fit-to-height.png b/Grinder/res/icons/fit-to-height.png
new file mode 100644
index 0000000000000000000000000000000000000000..2e1a91769f15580af426c174be2e3db4b37322f9
Binary files /dev/null and b/Grinder/res/icons/fit-to-height.png differ
diff --git a/Grinder/res/icons/fit-to-width.png b/Grinder/res/icons/fit-to-width.png
new file mode 100644
index 0000000000000000000000000000000000000000..415481a2152beb7e9f2129a18322095b594a00ba
Binary files /dev/null and b/Grinder/res/icons/fit-to-width.png differ
diff --git a/Grinder/res/icons/flag-signal-in-black-cloth-on-a-pole.png b/Grinder/res/icons/flag-signal-in-black-cloth-on-a-pole.png
new file mode 100644
index 0000000000000000000000000000000000000000..79304f9f0f57b95d3ac4c5d93e0a0d6ec85bf229
Binary files /dev/null and b/Grinder/res/icons/flag-signal-in-black-cloth-on-a-pole.png differ
diff --git a/Grinder/res/icons/floppy-disk-digital-data-storage-or-save-interface-symbol.png b/Grinder/res/icons/floppy-disk-digital-data-storage-or-save-interface-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..928eda26eae59c990ca36fe75a8b77260fa1904c
Binary files /dev/null and b/Grinder/res/icons/floppy-disk-digital-data-storage-or-save-interface-symbol.png differ
diff --git a/Grinder/res/icons/floppy-diskette-with-open-window.png b/Grinder/res/icons/floppy-diskette-with-open-window.png
new file mode 100644
index 0000000000000000000000000000000000000000..5b7552ee43bb5dad54036683beb6d11c6de518cc
Binary files /dev/null and b/Grinder/res/icons/floppy-diskette-with-open-window.png differ
diff --git a/Grinder/res/icons/floppy-diskette-with-pen.png b/Grinder/res/icons/floppy-diskette-with-pen.png
new file mode 100644
index 0000000000000000000000000000000000000000..4d585524e2b2d6e1e5249fd0e374ab895b0de34d
Binary files /dev/null and b/Grinder/res/icons/floppy-diskette-with-pen.png differ
diff --git a/Grinder/res/icons/floppy-disks-pair.png b/Grinder/res/icons/floppy-disks-pair.png
new file mode 100644
index 0000000000000000000000000000000000000000..deaf267bd4d4444302d855b2e99d88d90e0feccb
Binary files /dev/null and b/Grinder/res/icons/floppy-disks-pair.png differ
diff --git a/Grinder/res/icons/floppy-drive.png b/Grinder/res/icons/floppy-drive.png
new file mode 100644
index 0000000000000000000000000000000000000000..3080b8949c57bf11969665c9d76ad7a7979ad77b
Binary files /dev/null and b/Grinder/res/icons/floppy-drive.png differ
diff --git a/Grinder/res/icons/folder-and-window.png b/Grinder/res/icons/folder-and-window.png
new file mode 100644
index 0000000000000000000000000000000000000000..62f53c24e0efa75db86f70141714a14906cc72ad
Binary files /dev/null and b/Grinder/res/icons/folder-and-window.png differ
diff --git a/Grinder/res/icons/folder-into.png b/Grinder/res/icons/folder-into.png
new file mode 100644
index 0000000000000000000000000000000000000000..0811c34f96672e11bfb84be18b9960b756657f99
Binary files /dev/null and b/Grinder/res/icons/folder-into.png differ
diff --git a/Grinder/res/icons/folder-network-option.png b/Grinder/res/icons/folder-network-option.png
new file mode 100644
index 0000000000000000000000000000000000000000..e10c4ccb2ccf54dd4d15b61cf2ad66c25e5f41d9
Binary files /dev/null and b/Grinder/res/icons/folder-network-option.png differ
diff --git a/Grinder/res/icons/folder-out-interface-symbol.png b/Grinder/res/icons/folder-out-interface-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..a3170b667dc3dfc1792e50640841f0e98ad9a5d5
Binary files /dev/null and b/Grinder/res/icons/folder-out-interface-symbol.png differ
diff --git a/Grinder/res/icons/folder-shared.png b/Grinder/res/icons/folder-shared.png
new file mode 100644
index 0000000000000000000000000000000000000000..9fe27144755e6d848181f7e25403226b5c1c9ea2
Binary files /dev/null and b/Grinder/res/icons/folder-shared.png differ
diff --git a/Grinder/res/icons/folder-with-a-document-page.png b/Grinder/res/icons/folder-with-a-document-page.png
new file mode 100644
index 0000000000000000000000000000000000000000..1cad7473f52bec7e96f9c057a31171d4d496e830
Binary files /dev/null and b/Grinder/res/icons/folder-with-a-document-page.png differ
diff --git a/Grinder/res/icons/folder-with-information.png b/Grinder/res/icons/folder-with-information.png
new file mode 100644
index 0000000000000000000000000000000000000000..0d4d5917befff22fba9f4900d2d559937eaf991d
Binary files /dev/null and b/Grinder/res/icons/folder-with-information.png differ
diff --git a/Grinder/res/icons/folder-with-label-of-large-size.png b/Grinder/res/icons/folder-with-label-of-large-size.png
new file mode 100644
index 0000000000000000000000000000000000000000..506a169d39bd1e8221067f6a7422f585dc047304
Binary files /dev/null and b/Grinder/res/icons/folder-with-label-of-large-size.png differ
diff --git a/Grinder/res/icons/folder-zip.png b/Grinder/res/icons/folder-zip.png
new file mode 100644
index 0000000000000000000000000000000000000000..985c2c031bd47b5666f93b27b98d5509a957b80a
Binary files /dev/null and b/Grinder/res/icons/folder-zip.png differ
diff --git a/Grinder/res/icons/folders-of-large-size-arranged.png b/Grinder/res/icons/folders-of-large-size-arranged.png
new file mode 100644
index 0000000000000000000000000000000000000000..b4c7ec7f75f868484179600b34bbef61e6260edf
Binary files /dev/null and b/Grinder/res/icons/folders-of-large-size-arranged.png differ
diff --git a/Grinder/res/icons/folders-with-information.png b/Grinder/res/icons/folders-with-information.png
new file mode 100644
index 0000000000000000000000000000000000000000..e643a8e5c2eec9826de0b50e5a25088ac0ce5380
Binary files /dev/null and b/Grinder/res/icons/folders-with-information.png differ
diff --git a/Grinder/res/icons/font-style-bold.png b/Grinder/res/icons/font-style-bold.png
new file mode 100644
index 0000000000000000000000000000000000000000..d45dff1622dc5e4cee0174f9088d8056e6de0c5b
Binary files /dev/null and b/Grinder/res/icons/font-style-bold.png differ
diff --git a/Grinder/res/icons/font-style-subscript.png b/Grinder/res/icons/font-style-subscript.png
new file mode 100644
index 0000000000000000000000000000000000000000..832e9ba64a946788720caee9bdd49b3157dccef1
Binary files /dev/null and b/Grinder/res/icons/font-style-subscript.png differ
diff --git a/Grinder/res/icons/font-style-superscript.png b/Grinder/res/icons/font-style-superscript.png
new file mode 100644
index 0000000000000000000000000000000000000000..8456aa6b35d97949202542a0cc2b64589205b863
Binary files /dev/null and b/Grinder/res/icons/font-style-superscript.png differ
diff --git a/Grinder/res/icons/font-style-underline.png b/Grinder/res/icons/font-style-underline.png
new file mode 100644
index 0000000000000000000000000000000000000000..8f1c38a57d83dcbd53a58297108dc2be627b2e2f
Binary files /dev/null and b/Grinder/res/icons/font-style-underline.png differ
diff --git a/Grinder/res/icons/font-window.png b/Grinder/res/icons/font-window.png
new file mode 100644
index 0000000000000000000000000000000000000000..c2db2d9a0d9b2118ddbc23047ae882ed64407c55
Binary files /dev/null and b/Grinder/res/icons/font-window.png differ
diff --git a/Grinder/res/icons/font.png b/Grinder/res/icons/font.png
new file mode 100644
index 0000000000000000000000000000000000000000..08701af8b2810afd799f60e1813e30df5ce6d5f9
Binary files /dev/null and b/Grinder/res/icons/font.png differ
diff --git a/Grinder/res/icons/forbidden-sign.png b/Grinder/res/icons/forbidden-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..f7ad0eb6a1edd2801c282bb063210f4573395fe6
Binary files /dev/null and b/Grinder/res/icons/forbidden-sign.png differ
diff --git a/Grinder/res/icons/four-arrows-meeting-at-the-center.png b/Grinder/res/icons/four-arrows-meeting-at-the-center.png
new file mode 100644
index 0000000000000000000000000000000000000000..28bf5c72fcf97e72dd24cd281f4fd7875b5ee9f5
Binary files /dev/null and b/Grinder/res/icons/four-arrows-meeting-at-the-center.png differ
diff --git a/Grinder/res/icons/garbage-can-half-full-with-recycle-symbol.png b/Grinder/res/icons/garbage-can-half-full-with-recycle-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..21eebb5d8edefd65df18746d3b820ce1d256f7d6
Binary files /dev/null and b/Grinder/res/icons/garbage-can-half-full-with-recycle-symbol.png differ
diff --git a/Grinder/res/icons/garbage-can-with-recycling-symbol.png b/Grinder/res/icons/garbage-can-with-recycling-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..8ce4469124b131f944dba3903cc48787afcecc48
Binary files /dev/null and b/Grinder/res/icons/garbage-can-with-recycling-symbol.png differ
diff --git a/Grinder/res/icons/garbage-can.png b/Grinder/res/icons/garbage-can.png
new file mode 100644
index 0000000000000000000000000000000000000000..aecc7144a2c6fee6a6ae291fbad8111b1019f464
Binary files /dev/null and b/Grinder/res/icons/garbage-can.png differ
diff --git a/Grinder/res/icons/garbage-container.png b/Grinder/res/icons/garbage-container.png
new file mode 100644
index 0000000000000000000000000000000000000000..37917cf01591c6832784b034e44365bc357838ce
Binary files /dev/null and b/Grinder/res/icons/garbage-container.png differ
diff --git a/Grinder/res/icons/garbage-full.png b/Grinder/res/icons/garbage-full.png
new file mode 100644
index 0000000000000000000000000000000000000000..70105997cd3a9c3f6a98c1ec93c3135c6370341c
Binary files /dev/null and b/Grinder/res/icons/garbage-full.png differ
diff --git a/Grinder/res/icons/garbage-with-recycle-sign-overflowing-with-trash.png b/Grinder/res/icons/garbage-with-recycle-sign-overflowing-with-trash.png
new file mode 100644
index 0000000000000000000000000000000000000000..87556948ab058d5d2aefcf2681a131bf6887aca7
Binary files /dev/null and b/Grinder/res/icons/garbage-with-recycle-sign-overflowing-with-trash.png differ
diff --git a/Grinder/res/icons/hard-drive-computer-part.png b/Grinder/res/icons/hard-drive-computer-part.png
new file mode 100644
index 0000000000000000000000000000000000000000..39ef5ecde6fc383291e407779b5c1e78184e25bc
Binary files /dev/null and b/Grinder/res/icons/hard-drive-computer-part.png differ
diff --git a/Grinder/res/icons/hard-drive-network.png b/Grinder/res/icons/hard-drive-network.png
new file mode 100644
index 0000000000000000000000000000000000000000..00b8faeda5a3feaa367a89bf503d5c9fe0aa34af
Binary files /dev/null and b/Grinder/res/icons/hard-drive-network.png differ
diff --git a/Grinder/res/icons/heart-simple-shape-silhouette.png b/Grinder/res/icons/heart-simple-shape-silhouette.png
new file mode 100644
index 0000000000000000000000000000000000000000..61970cd9f15ecef41148e0d06c59ee1d5e07c05c
Binary files /dev/null and b/Grinder/res/icons/heart-simple-shape-silhouette.png differ
diff --git a/Grinder/res/icons/history.png b/Grinder/res/icons/history.png
new file mode 100644
index 0000000000000000000000000000000000000000..61fadde48b1d2ac71878916d9b5040294ad99bc0
Binary files /dev/null and b/Grinder/res/icons/history.png differ
diff --git a/Grinder/res/icons/indent-decrease-symbol.png b/Grinder/res/icons/indent-decrease-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..286cb08f8dca3b57ad29ef51e6fdc86bd7d7d00a
Binary files /dev/null and b/Grinder/res/icons/indent-decrease-symbol.png differ
diff --git a/Grinder/res/icons/indent-increase-interface-symbol.png b/Grinder/res/icons/indent-increase-interface-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..1b535a132da464c03c430be4e4fe0f013b012041
Binary files /dev/null and b/Grinder/res/icons/indent-increase-interface-symbol.png differ
diff --git a/Grinder/res/icons/information.png b/Grinder/res/icons/information.png
new file mode 100644
index 0000000000000000000000000000000000000000..7670c919740148339e2e425be384baf695b70267
Binary files /dev/null and b/Grinder/res/icons/information.png differ
diff --git a/Grinder/res/icons/italics-font-style-variant.png b/Grinder/res/icons/italics-font-style-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..e5fe168cd06b13118f6fe31fc4054704c7208309
Binary files /dev/null and b/Grinder/res/icons/italics-font-style-variant.png differ
diff --git a/Grinder/res/icons/justified-text-alignment.png b/Grinder/res/icons/justified-text-alignment.png
new file mode 100644
index 0000000000000000000000000000000000000000..48f20015042e278b9b94213259a68a36037c2fbb
Binary files /dev/null and b/Grinder/res/icons/justified-text-alignment.png differ
diff --git a/Grinder/res/icons/key-with-cross-sign.png b/Grinder/res/icons/key-with-cross-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..504199f0be95441cdd6e30d6fb3a5482ad81459c
Binary files /dev/null and b/Grinder/res/icons/key-with-cross-sign.png differ
diff --git a/Grinder/res/icons/layout.png b/Grinder/res/icons/layout.png
new file mode 100644
index 0000000000000000000000000000000000000000..e17d2ff920a9f2aed8ba76b7f1940894cd1232b9
Binary files /dev/null and b/Grinder/res/icons/layout.png differ
diff --git a/Grinder/res/icons/led-light-lamp.png b/Grinder/res/icons/led-light-lamp.png
new file mode 100644
index 0000000000000000000000000000000000000000..4fc48531bb0330bf155c5d96b06a6ee6bb3402a7
Binary files /dev/null and b/Grinder/res/icons/led-light-lamp.png differ
diff --git a/Grinder/res/icons/letter-n-lower-case-font.png b/Grinder/res/icons/letter-n-lower-case-font.png
new file mode 100644
index 0000000000000000000000000000000000000000..6ae0ef8df7ede0b5310113e29dc15e1fca71d8ae
Binary files /dev/null and b/Grinder/res/icons/letter-n-lower-case-font.png differ
diff --git a/Grinder/res/icons/letters-abc-in-pixelated-form.png b/Grinder/res/icons/letters-abc-in-pixelated-form.png
new file mode 100644
index 0000000000000000000000000000000000000000..fe8ee889ed049b7abfe4e9075d3b26bf36ee6046
Binary files /dev/null and b/Grinder/res/icons/letters-abc-in-pixelated-form.png differ
diff --git a/Grinder/res/icons/lifebelt.png b/Grinder/res/icons/lifebelt.png
new file mode 100644
index 0000000000000000000000000000000000000000..292ecd55cece412394518995df15e4a066e80c58
Binary files /dev/null and b/Grinder/res/icons/lifebelt.png differ
diff --git a/Grinder/res/icons/light-bulb-off.png b/Grinder/res/icons/light-bulb-off.png
new file mode 100644
index 0000000000000000000000000000000000000000..bacd7817fd1bdd8e7a95f002714c49cac4e77608
Binary files /dev/null and b/Grinder/res/icons/light-bulb-off.png differ
diff --git a/Grinder/res/icons/lightbulb-on.png b/Grinder/res/icons/lightbulb-on.png
new file mode 100644
index 0000000000000000000000000000000000000000..b3ce1b576a18c4eb6f4ab0a5611039afd0204017
Binary files /dev/null and b/Grinder/res/icons/lightbulb-on.png differ
diff --git a/Grinder/res/icons/lighthouse-with-flashing-lights.png b/Grinder/res/icons/lighthouse-with-flashing-lights.png
new file mode 100644
index 0000000000000000000000000000000000000000..1551a45a3fc246cd3992013f884e40646190fdeb
Binary files /dev/null and b/Grinder/res/icons/lighthouse-with-flashing-lights.png differ
diff --git a/Grinder/res/icons/line-break-symbol.png b/Grinder/res/icons/line-break-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..349ee589b44164e8f93fec408e02bba0df1fdde1
Binary files /dev/null and b/Grinder/res/icons/line-break-symbol.png differ
diff --git a/Grinder/res/icons/line-spacing-adjustment-in-a-paragraph.png b/Grinder/res/icons/line-spacing-adjustment-in-a-paragraph.png
new file mode 100644
index 0000000000000000000000000000000000000000..aa58f030a0037dc3c1f2e94d8fa271af1cb8063c
Binary files /dev/null and b/Grinder/res/icons/line-spacing-adjustment-in-a-paragraph.png differ
diff --git a/Grinder/res/icons/line-spacing-text.png b/Grinder/res/icons/line-spacing-text.png
new file mode 100644
index 0000000000000000000000000000000000000000..c5d9e20884696e8c054d67e4873d58dc8cf82c0f
Binary files /dev/null and b/Grinder/res/icons/line-spacing-text.png differ
diff --git a/Grinder/res/icons/list-in-bullet-form.png b/Grinder/res/icons/list-in-bullet-form.png
new file mode 100644
index 0000000000000000000000000000000000000000..dbadcc7a7fcea334e9fb005a93f7ab307bfa9296
Binary files /dev/null and b/Grinder/res/icons/list-in-bullet-form.png differ
diff --git a/Grinder/res/icons/list-roman-style-numbers.png b/Grinder/res/icons/list-roman-style-numbers.png
new file mode 100644
index 0000000000000000000000000000000000000000..749dbb89f1c9a3059915b4c8d51c64bcfa3c5e36
Binary files /dev/null and b/Grinder/res/icons/list-roman-style-numbers.png differ
diff --git a/Grinder/res/icons/locator-on-an-open-folded-map.png b/Grinder/res/icons/locator-on-an-open-folded-map.png
new file mode 100644
index 0000000000000000000000000000000000000000..e9cfea6abf0e8acbcfe0dd7e72018228c98a2901
Binary files /dev/null and b/Grinder/res/icons/locator-on-an-open-folded-map.png differ
diff --git a/Grinder/res/icons/log-in.png b/Grinder/res/icons/log-in.png
new file mode 100644
index 0000000000000000000000000000000000000000..c4c411bf52315dab458fd13db1453cbf3c597e31
Binary files /dev/null and b/Grinder/res/icons/log-in.png differ
diff --git a/Grinder/res/icons/magazine-folder.png b/Grinder/res/icons/magazine-folder.png
new file mode 100644
index 0000000000000000000000000000000000000000..d44c39f3a182626657f0f18b33c8cbea617664bc
Binary files /dev/null and b/Grinder/res/icons/magazine-folder.png differ
diff --git a/Grinder/res/icons/magic-wand.png b/Grinder/res/icons/magic-wand.png
new file mode 100644
index 0000000000000000000000000000000000000000..dcaa33e11e17ad03be40e8a96bad69a6038058ba
Binary files /dev/null and b/Grinder/res/icons/magic-wand.png differ
diff --git a/Grinder/res/icons/male-gender-symbol-variant.png b/Grinder/res/icons/male-gender-symbol-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..1a5285e7b61328bc0b9ae7577946a74e83dce0bd
Binary files /dev/null and b/Grinder/res/icons/male-gender-symbol-variant.png differ
diff --git a/Grinder/res/icons/map-location.png b/Grinder/res/icons/map-location.png
new file mode 100644
index 0000000000000000000000000000000000000000..95e57c58c615e81c31624ed1b2f9b5916de66508
Binary files /dev/null and b/Grinder/res/icons/map-location.png differ
diff --git a/Grinder/res/icons/map-of-roads.png b/Grinder/res/icons/map-of-roads.png
new file mode 100644
index 0000000000000000000000000000000000000000..21b6d369bab17af36cfcd8b971cf3a8dc1e9014d
Binary files /dev/null and b/Grinder/res/icons/map-of-roads.png differ
diff --git a/Grinder/res/icons/map-route.png b/Grinder/res/icons/map-route.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a76ff6bb05f57adcdffb4a7d04caec2b9fed675
Binary files /dev/null and b/Grinder/res/icons/map-route.png differ
diff --git a/Grinder/res/icons/map.png b/Grinder/res/icons/map.png
new file mode 100644
index 0000000000000000000000000000000000000000..19f6167d2c0f8ad139de51bfc6aa3fd887967a05
Binary files /dev/null and b/Grinder/res/icons/map.png differ
diff --git a/Grinder/res/icons/maximize-size-option.png b/Grinder/res/icons/maximize-size-option.png
new file mode 100644
index 0000000000000000000000000000000000000000..05fe1629eada2c97f0cb78dd18177dd662fa01f6
Binary files /dev/null and b/Grinder/res/icons/maximize-size-option.png differ
diff --git a/Grinder/res/icons/microsoft-word-document-file.png b/Grinder/res/icons/microsoft-word-document-file.png
new file mode 100644
index 0000000000000000000000000000000000000000..7df66cd32f31ed9b94b3ebeac95ddd85769daa09
Binary files /dev/null and b/Grinder/res/icons/microsoft-word-document-file.png differ
diff --git a/Grinder/res/icons/minimize.png b/Grinder/res/icons/minimize.png
new file mode 100644
index 0000000000000000000000000000000000000000..253ce5fa7ff85c91d399172d2da56d1cba0c3cbf
Binary files /dev/null and b/Grinder/res/icons/minimize.png differ
diff --git a/Grinder/res/icons/minus-sign-of-a-line-in-horizontal-position.png b/Grinder/res/icons/minus-sign-of-a-line-in-horizontal-position.png
new file mode 100644
index 0000000000000000000000000000000000000000..b76f59b13363de88a21f58c0095157d06b926513
Binary files /dev/null and b/Grinder/res/icons/minus-sign-of-a-line-in-horizontal-position.png differ
diff --git a/Grinder/res/icons/minus-sign.png b/Grinder/res/icons/minus-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..935633d63df8284a0b9b523f3f573e3063e89dfb
Binary files /dev/null and b/Grinder/res/icons/minus-sign.png differ
diff --git a/Grinder/res/icons/music-cd.png b/Grinder/res/icons/music-cd.png
new file mode 100644
index 0000000000000000000000000000000000000000..6b169808df957a429b68ae75d1b9f74ce4202918
Binary files /dev/null and b/Grinder/res/icons/music-cd.png differ
diff --git a/Grinder/res/icons/music-document.png b/Grinder/res/icons/music-document.png
new file mode 100644
index 0000000000000000000000000000000000000000..4b286dfbead32de3fc115bf6b046270e5b8865b9
Binary files /dev/null and b/Grinder/res/icons/music-document.png differ
diff --git a/Grinder/res/icons/music-folder.png b/Grinder/res/icons/music-folder.png
new file mode 100644
index 0000000000000000000000000000000000000000..b8c4563a36715ec4c5d049a631ba67ebde86a222
Binary files /dev/null and b/Grinder/res/icons/music-folder.png differ
diff --git a/Grinder/res/icons/navigate-arrow-button-to-beginning.png b/Grinder/res/icons/navigate-arrow-button-to-beginning.png
new file mode 100644
index 0000000000000000000000000000000000000000..2265db9e1fa7ef722dcc58aa8c0d7335fa411381
Binary files /dev/null and b/Grinder/res/icons/navigate-arrow-button-to-beginning.png differ
diff --git a/Grinder/res/icons/navigate-arrows-pointing-to-down.png b/Grinder/res/icons/navigate-arrows-pointing-to-down.png
new file mode 100644
index 0000000000000000000000000000000000000000..a295efe8b8c27f74aa936ec7e006f2fe0d93579f
Binary files /dev/null and b/Grinder/res/icons/navigate-arrows-pointing-to-down.png differ
diff --git a/Grinder/res/icons/navigate-to-end.png b/Grinder/res/icons/navigate-to-end.png
new file mode 100644
index 0000000000000000000000000000000000000000..e3bb0039a612ede48198df9469ed3b8339911435
Binary files /dev/null and b/Grinder/res/icons/navigate-to-end.png differ
diff --git a/Grinder/res/icons/navigate-up-arrow.png b/Grinder/res/icons/navigate-up-arrow.png
new file mode 100644
index 0000000000000000000000000000000000000000..15e4f9e77f5ef609b23d843686e6947641027de9
Binary files /dev/null and b/Grinder/res/icons/navigate-up-arrow.png differ
diff --git a/Grinder/res/icons/navigate-up-arrows.png b/Grinder/res/icons/navigate-up-arrows.png
new file mode 100644
index 0000000000000000000000000000000000000000..83c44a5f3c472bff35222c6cebea5fa615bf073a
Binary files /dev/null and b/Grinder/res/icons/navigate-up-arrows.png differ
diff --git a/Grinder/res/icons/navigation-arrow-down-button.png b/Grinder/res/icons/navigation-arrow-down-button.png
new file mode 100644
index 0000000000000000000000000000000000000000..73f68e2fb8867138e15251f4202cae7781cfe28a
Binary files /dev/null and b/Grinder/res/icons/navigation-arrow-down-button.png differ
diff --git a/Grinder/res/icons/navigation-history-interface-symbol-of-a-clock-with-an-arrow.png b/Grinder/res/icons/navigation-history-interface-symbol-of-a-clock-with-an-arrow.png
new file mode 100644
index 0000000000000000000000000000000000000000..6dd46664968ac0e8da0a0188d6a45ed82846994f
Binary files /dev/null and b/Grinder/res/icons/navigation-history-interface-symbol-of-a-clock-with-an-arrow.png differ
diff --git a/Grinder/res/icons/navigation-up-arrow-to-the-left.png b/Grinder/res/icons/navigation-up-arrow-to-the-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..4cf98fc5ea3b7514738c87be6a4dbc814445bc01
Binary files /dev/null and b/Grinder/res/icons/navigation-up-arrow-to-the-left.png differ
diff --git a/Grinder/res/icons/navigational-arrow-pointing-down-left-in-a-circle.png b/Grinder/res/icons/navigational-arrow-pointing-down-left-in-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..53eeb525b2de10127d7aeeaf88af5452bb201e28
Binary files /dev/null and b/Grinder/res/icons/navigational-arrow-pointing-down-left-in-a-circle.png differ
diff --git a/Grinder/res/icons/numbered-list-style.png b/Grinder/res/icons/numbered-list-style.png
new file mode 100644
index 0000000000000000000000000000000000000000..c95f8ca4e5b3b0c632ac71aeefe474e76464ed5b
Binary files /dev/null and b/Grinder/res/icons/numbered-list-style.png differ
diff --git a/Grinder/res/icons/open-door-with-border.png b/Grinder/res/icons/open-door-with-border.png
new file mode 100644
index 0000000000000000000000000000000000000000..145104dd3f20bde07bbf077a2e1b0597a5e98c8e
Binary files /dev/null and b/Grinder/res/icons/open-door-with-border.png differ
diff --git a/Grinder/res/icons/open-folder-black-and-white-variant.png b/Grinder/res/icons/open-folder-black-and-white-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..6b3ed313c2cc3342962acb78185905235a48754e
Binary files /dev/null and b/Grinder/res/icons/open-folder-black-and-white-variant.png differ
diff --git a/Grinder/res/icons/open-folder-with-document.png b/Grinder/res/icons/open-folder-with-document.png
new file mode 100644
index 0000000000000000000000000000000000000000..fcffdd7ece110b5c1b885cd5eaeb7143ccad0938
Binary files /dev/null and b/Grinder/res/icons/open-folder-with-document.png differ
diff --git a/Grinder/res/icons/open-scroll-outline.png b/Grinder/res/icons/open-scroll-outline.png
new file mode 100644
index 0000000000000000000000000000000000000000..b9992c7abdd2ef27137bb9cd7edd73e342925f91
Binary files /dev/null and b/Grinder/res/icons/open-scroll-outline.png differ
diff --git a/Grinder/res/icons/open-window-with-gear-sign.png b/Grinder/res/icons/open-window-with-gear-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..0b05922b02586806db99628d629ba2ef49520647
Binary files /dev/null and b/Grinder/res/icons/open-window-with-gear-sign.png differ
diff --git a/Grinder/res/icons/open-window-with-height-adjustment.png b/Grinder/res/icons/open-window-with-height-adjustment.png
new file mode 100644
index 0000000000000000000000000000000000000000..d21f1bf27ef8a27ee023cd84b5a26407678329fb
Binary files /dev/null and b/Grinder/res/icons/open-window-with-height-adjustment.png differ
diff --git a/Grinder/res/icons/open-window-with-star-symbol.png b/Grinder/res/icons/open-window-with-star-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..81828761de7b317e2fd01fae50f8ae002e450b4e
Binary files /dev/null and b/Grinder/res/icons/open-window-with-star-symbol.png differ
diff --git a/Grinder/res/icons/open-window-with-timer.png b/Grinder/res/icons/open-window-with-timer.png
new file mode 100644
index 0000000000000000000000000000000000000000..218918c203c554cc0a03b6f799fde603781745c4
Binary files /dev/null and b/Grinder/res/icons/open-window-with-timer.png differ
diff --git a/Grinder/res/icons/painting/051-calculator.png b/Grinder/res/icons/painting/051-calculator.png
new file mode 100644
index 0000000000000000000000000000000000000000..3b95a17644e5e82c376cc24bef127b21dbe8796c
Binary files /dev/null and b/Grinder/res/icons/painting/051-calculator.png differ
diff --git a/Grinder/res/icons/painting/051-chalk.png b/Grinder/res/icons/painting/051-chalk.png
new file mode 100644
index 0000000000000000000000000000000000000000..a0184d3971b63cdf570dc37e4c700cfce6485a37
Binary files /dev/null and b/Grinder/res/icons/painting/051-chalk.png differ
diff --git a/Grinder/res/icons/painting/051-compass.png b/Grinder/res/icons/painting/051-compass.png
new file mode 100644
index 0000000000000000000000000000000000000000..07fa462c7993276a74571e0f0bdf677c9446d388
Binary files /dev/null and b/Grinder/res/icons/painting/051-compass.png differ
diff --git a/Grinder/res/icons/painting/051-crayon.png b/Grinder/res/icons/painting/051-crayon.png
new file mode 100644
index 0000000000000000000000000000000000000000..7629955133553e0d2edc3372c67d77238f030c01
Binary files /dev/null and b/Grinder/res/icons/painting/051-crayon.png differ
diff --git a/Grinder/res/icons/painting/051-cutter.png b/Grinder/res/icons/painting/051-cutter.png
new file mode 100644
index 0000000000000000000000000000000000000000..742c7bf8447168251317459bdc7027d6d5453079
Binary files /dev/null and b/Grinder/res/icons/painting/051-cutter.png differ
diff --git a/Grinder/res/icons/painting/051-desk-lamp.png b/Grinder/res/icons/painting/051-desk-lamp.png
new file mode 100644
index 0000000000000000000000000000000000000000..6c5d4d48bb2aa1e3fa7359836340b6d029e0a4a1
Binary files /dev/null and b/Grinder/res/icons/painting/051-desk-lamp.png differ
diff --git a/Grinder/res/icons/painting/051-dropper-1.png b/Grinder/res/icons/painting/051-dropper-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..6f33d8714b070dd63408d834daba66d123d93a86
Binary files /dev/null and b/Grinder/res/icons/painting/051-dropper-1.png differ
diff --git a/Grinder/res/icons/painting/051-dropper.png b/Grinder/res/icons/painting/051-dropper.png
new file mode 100644
index 0000000000000000000000000000000000000000..c43e5830e3d0cce7ea1fa3a5276f4345a69e74ab
Binary files /dev/null and b/Grinder/res/icons/painting/051-dropper.png differ
diff --git a/Grinder/res/icons/painting/051-easel.png b/Grinder/res/icons/painting/051-easel.png
new file mode 100644
index 0000000000000000000000000000000000000000..64cfe5377cf25b2e6ee33288c86fab83efa67d71
Binary files /dev/null and b/Grinder/res/icons/painting/051-easel.png differ
diff --git a/Grinder/res/icons/painting/051-eraser.png b/Grinder/res/icons/painting/051-eraser.png
new file mode 100644
index 0000000000000000000000000000000000000000..ab8f1485cbce806e0819c308b18ece0a6481071d
Binary files /dev/null and b/Grinder/res/icons/painting/051-eraser.png differ
diff --git a/Grinder/res/icons/painting/051-file-1.png b/Grinder/res/icons/painting/051-file-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..22da46f02eecae56e8cc63c13f1a764fb555f513
Binary files /dev/null and b/Grinder/res/icons/painting/051-file-1.png differ
diff --git a/Grinder/res/icons/painting/051-file-2.png b/Grinder/res/icons/painting/051-file-2.png
new file mode 100644
index 0000000000000000000000000000000000000000..a11b7ea86d7dd863ad082d352f4f0e188d6c3358
Binary files /dev/null and b/Grinder/res/icons/painting/051-file-2.png differ
diff --git a/Grinder/res/icons/painting/051-file-3.png b/Grinder/res/icons/painting/051-file-3.png
new file mode 100644
index 0000000000000000000000000000000000000000..83ddd18fd3eb2494827607e37216d07d39dc372d
Binary files /dev/null and b/Grinder/res/icons/painting/051-file-3.png differ
diff --git a/Grinder/res/icons/painting/051-file.png b/Grinder/res/icons/painting/051-file.png
new file mode 100644
index 0000000000000000000000000000000000000000..479440db38f0b332e845c1b7d6066a1667c248de
Binary files /dev/null and b/Grinder/res/icons/painting/051-file.png differ
diff --git a/Grinder/res/icons/painting/051-glue.png b/Grinder/res/icons/painting/051-glue.png
new file mode 100644
index 0000000000000000000000000000000000000000..12c84b982004a6aa1a48de0a9e8ff16632d7a6f5
Binary files /dev/null and b/Grinder/res/icons/painting/051-glue.png differ
diff --git a/Grinder/res/icons/painting/051-highlighter.png b/Grinder/res/icons/painting/051-highlighter.png
new file mode 100644
index 0000000000000000000000000000000000000000..3be149d1d1cf527d1e18fb6aead23cb6906350b8
Binary files /dev/null and b/Grinder/res/icons/painting/051-highlighter.png differ
diff --git a/Grinder/res/icons/painting/051-idea.png b/Grinder/res/icons/painting/051-idea.png
new file mode 100644
index 0000000000000000000000000000000000000000..7530f912791ce98828a61408b737e90c9e0f470c
Binary files /dev/null and b/Grinder/res/icons/painting/051-idea.png differ
diff --git a/Grinder/res/icons/painting/051-ink.png b/Grinder/res/icons/painting/051-ink.png
new file mode 100644
index 0000000000000000000000000000000000000000..ed4e730e8b1333f475d316e5eb7553928dfa77c3
Binary files /dev/null and b/Grinder/res/icons/painting/051-ink.png differ
diff --git a/Grinder/res/icons/painting/051-monitor.png b/Grinder/res/icons/painting/051-monitor.png
new file mode 100644
index 0000000000000000000000000000000000000000..d4e8dbd4bb22cae737744a031fd3086b68f8b013
Binary files /dev/null and b/Grinder/res/icons/painting/051-monitor.png differ
diff --git a/Grinder/res/icons/painting/051-mouse.png b/Grinder/res/icons/painting/051-mouse.png
new file mode 100644
index 0000000000000000000000000000000000000000..e0783912d936bb727e176a991a6622988db3359b
Binary files /dev/null and b/Grinder/res/icons/painting/051-mouse.png differ
diff --git a/Grinder/res/icons/painting/051-notebook.png b/Grinder/res/icons/painting/051-notebook.png
new file mode 100644
index 0000000000000000000000000000000000000000..d813279305684420667dac651edf76f53e86d82d
Binary files /dev/null and b/Grinder/res/icons/painting/051-notebook.png differ
diff --git a/Grinder/res/icons/painting/051-paint-brush-1.png b/Grinder/res/icons/painting/051-paint-brush-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..492ed6a18bebaa138ed4c8b07fd1eaeb70598ca4
Binary files /dev/null and b/Grinder/res/icons/painting/051-paint-brush-1.png differ
diff --git a/Grinder/res/icons/painting/051-paint-brush-2.png b/Grinder/res/icons/painting/051-paint-brush-2.png
new file mode 100644
index 0000000000000000000000000000000000000000..8002df8993d088f84c9c26e8694b20ced6b14c07
Binary files /dev/null and b/Grinder/res/icons/painting/051-paint-brush-2.png differ
diff --git a/Grinder/res/icons/painting/051-paint-brush.png b/Grinder/res/icons/painting/051-paint-brush.png
new file mode 100644
index 0000000000000000000000000000000000000000..4f3fdab1c99568a79b6467a7b0765c8dd162c2c3
Binary files /dev/null and b/Grinder/res/icons/painting/051-paint-brush.png differ
diff --git a/Grinder/res/icons/painting/051-paint-bucket.png b/Grinder/res/icons/painting/051-paint-bucket.png
new file mode 100644
index 0000000000000000000000000000000000000000..df676ccfb635eb169dbacdfeaab4e0af7b262156
Binary files /dev/null and b/Grinder/res/icons/painting/051-paint-bucket.png differ
diff --git a/Grinder/res/icons/painting/051-paint-palette-1.png b/Grinder/res/icons/painting/051-paint-palette-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..5756b141a370f02aba8d0e91ef4ebb121cc4d68b
Binary files /dev/null and b/Grinder/res/icons/painting/051-paint-palette-1.png differ
diff --git a/Grinder/res/icons/painting/051-paint-palette.png b/Grinder/res/icons/painting/051-paint-palette.png
new file mode 100644
index 0000000000000000000000000000000000000000..07bcfdc98ca89b67e609b7e53945be1c6d8ba1d9
Binary files /dev/null and b/Grinder/res/icons/painting/051-paint-palette.png differ
diff --git a/Grinder/res/icons/painting/051-paint-roller.png b/Grinder/res/icons/painting/051-paint-roller.png
new file mode 100644
index 0000000000000000000000000000000000000000..f8a836e219f196763dbb8881fefa79494b9f57ac
Binary files /dev/null and b/Grinder/res/icons/painting/051-paint-roller.png differ
diff --git a/Grinder/res/icons/painting/051-paint-tube.png b/Grinder/res/icons/painting/051-paint-tube.png
new file mode 100644
index 0000000000000000000000000000000000000000..cd39f7cd3e633c6420d1fc3261528cedadcdc662
Binary files /dev/null and b/Grinder/res/icons/painting/051-paint-tube.png differ
diff --git a/Grinder/res/icons/painting/051-paperclip.png b/Grinder/res/icons/painting/051-paperclip.png
new file mode 100644
index 0000000000000000000000000000000000000000..855cce53fcb8953a9cba09dab4c1a276874b400b
Binary files /dev/null and b/Grinder/res/icons/painting/051-paperclip.png differ
diff --git a/Grinder/res/icons/painting/051-pen-1.png b/Grinder/res/icons/painting/051-pen-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..848a93299454b6264d06e94acee4c7fe1ce49d15
Binary files /dev/null and b/Grinder/res/icons/painting/051-pen-1.png differ
diff --git a/Grinder/res/icons/painting/051-pen-2.png b/Grinder/res/icons/painting/051-pen-2.png
new file mode 100644
index 0000000000000000000000000000000000000000..96ebe101958c5c58395fccecdb92389d3e8e5a74
Binary files /dev/null and b/Grinder/res/icons/painting/051-pen-2.png differ
diff --git a/Grinder/res/icons/painting/051-pen-3.png b/Grinder/res/icons/painting/051-pen-3.png
new file mode 100644
index 0000000000000000000000000000000000000000..16748e46c93658e0ebb4dc7bec23f958ae42e897
Binary files /dev/null and b/Grinder/res/icons/painting/051-pen-3.png differ
diff --git a/Grinder/res/icons/painting/051-pen.png b/Grinder/res/icons/painting/051-pen.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a322f98ae2e1fc089e92c0fa694aa532323fba5
Binary files /dev/null and b/Grinder/res/icons/painting/051-pen.png differ
diff --git a/Grinder/res/icons/painting/051-pencil.png b/Grinder/res/icons/painting/051-pencil.png
new file mode 100644
index 0000000000000000000000000000000000000000..de24bf7b112e995372eac4cce237972d40b67116
Binary files /dev/null and b/Grinder/res/icons/painting/051-pencil.png differ
diff --git a/Grinder/res/icons/painting/051-photo-camera.png b/Grinder/res/icons/painting/051-photo-camera.png
new file mode 100644
index 0000000000000000000000000000000000000000..1cc80d3b70331ad92f8ef4b4b83f4b56eef5ddaf
Binary files /dev/null and b/Grinder/res/icons/painting/051-photo-camera.png differ
diff --git a/Grinder/res/icons/painting/051-quill-1.png b/Grinder/res/icons/painting/051-quill-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..09861cebde035126381f5e22327aac2df30acda7
Binary files /dev/null and b/Grinder/res/icons/painting/051-quill-1.png differ
diff --git a/Grinder/res/icons/painting/051-quill-2.png b/Grinder/res/icons/painting/051-quill-2.png
new file mode 100644
index 0000000000000000000000000000000000000000..148f405358e407f10f5676cefb1cc0791a67b178
Binary files /dev/null and b/Grinder/res/icons/painting/051-quill-2.png differ
diff --git a/Grinder/res/icons/painting/051-quill.png b/Grinder/res/icons/painting/051-quill.png
new file mode 100644
index 0000000000000000000000000000000000000000..ef5c07ff9d8964a36aedc771fdc4f8b1eabbfa60
Binary files /dev/null and b/Grinder/res/icons/painting/051-quill.png differ
diff --git a/Grinder/res/icons/painting/051-ruler-1.png b/Grinder/res/icons/painting/051-ruler-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..796a96838a976893a846c84d066bd115c5d70838
Binary files /dev/null and b/Grinder/res/icons/painting/051-ruler-1.png differ
diff --git a/Grinder/res/icons/painting/051-ruler.png b/Grinder/res/icons/painting/051-ruler.png
new file mode 100644
index 0000000000000000000000000000000000000000..e0e8ffb68839b493d567ad25832226c8ecd20e47
Binary files /dev/null and b/Grinder/res/icons/painting/051-ruler.png differ
diff --git a/Grinder/res/icons/painting/051-scissors.png b/Grinder/res/icons/painting/051-scissors.png
new file mode 100644
index 0000000000000000000000000000000000000000..e358decbc054348a07250caca4c94b63b3797806
Binary files /dev/null and b/Grinder/res/icons/painting/051-scissors.png differ
diff --git a/Grinder/res/icons/painting/051-set-square-1.png b/Grinder/res/icons/painting/051-set-square-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..2016d9097898065d339139e7e497382a14dc3778
Binary files /dev/null and b/Grinder/res/icons/painting/051-set-square-1.png differ
diff --git a/Grinder/res/icons/painting/051-set-square.png b/Grinder/res/icons/painting/051-set-square.png
new file mode 100644
index 0000000000000000000000000000000000000000..fa4661704196464f58f31d60193eeac5570d57d7
Binary files /dev/null and b/Grinder/res/icons/painting/051-set-square.png differ
diff --git a/Grinder/res/icons/painting/051-sharpener.png b/Grinder/res/icons/painting/051-sharpener.png
new file mode 100644
index 0000000000000000000000000000000000000000..34016b3ac52f808abdc4b29268effe640a51ff3d
Binary files /dev/null and b/Grinder/res/icons/painting/051-sharpener.png differ
diff --git a/Grinder/res/icons/painting/051-spray-paint.png b/Grinder/res/icons/painting/051-spray-paint.png
new file mode 100644
index 0000000000000000000000000000000000000000..38504bbafebbc3678414a9d8af5effd402a2d145
Binary files /dev/null and b/Grinder/res/icons/painting/051-spray-paint.png differ
diff --git a/Grinder/res/icons/painting/051-transform.png b/Grinder/res/icons/painting/051-transform.png
new file mode 100644
index 0000000000000000000000000000000000000000..8c576a49e4d8540a3b740325dd9dfca7dd9d0e5f
Binary files /dev/null and b/Grinder/res/icons/painting/051-transform.png differ
diff --git a/Grinder/res/icons/painting/051-vector-1.png b/Grinder/res/icons/painting/051-vector-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..9f6e05cb3f5d36ad4f3ad01b5b948bcd93aeee93
Binary files /dev/null and b/Grinder/res/icons/painting/051-vector-1.png differ
diff --git a/Grinder/res/icons/painting/051-vector.png b/Grinder/res/icons/painting/051-vector.png
new file mode 100644
index 0000000000000000000000000000000000000000..20968e82a5cc7f0584db2b35beb93fe5754ccead
Binary files /dev/null and b/Grinder/res/icons/painting/051-vector.png differ
diff --git a/Grinder/res/icons/painting/051-watercolor.png b/Grinder/res/icons/painting/051-watercolor.png
new file mode 100644
index 0000000000000000000000000000000000000000..6f15885a7b4eccfde79e125bb1a53df4b3d72f73
Binary files /dev/null and b/Grinder/res/icons/painting/051-watercolor.png differ
diff --git a/Grinder/res/icons/painting/art-board.png b/Grinder/res/icons/painting/art-board.png
new file mode 100644
index 0000000000000000000000000000000000000000..1250eebaf4f8fd3f1b3bd3a5bcf9d1266c426645
Binary files /dev/null and b/Grinder/res/icons/painting/art-board.png differ
diff --git a/Grinder/res/icons/painting/art-palette.png b/Grinder/res/icons/painting/art-palette.png
new file mode 100644
index 0000000000000000000000000000000000000000..710cbc8be0f941f6d8e3ca63a48565663f7e7ee9
Binary files /dev/null and b/Grinder/res/icons/painting/art-palette.png differ
diff --git a/Grinder/res/icons/painting/barrett.png b/Grinder/res/icons/painting/barrett.png
new file mode 100644
index 0000000000000000000000000000000000000000..8498d502b3f2b5b4286c899ad458ccac6e2063e5
Binary files /dev/null and b/Grinder/res/icons/painting/barrett.png differ
diff --git a/Grinder/res/icons/painting/calculator.png b/Grinder/res/icons/painting/calculator.png
new file mode 100644
index 0000000000000000000000000000000000000000..205748233e8ca9aef3b8aacd57e893ccacbc1047
Binary files /dev/null and b/Grinder/res/icons/painting/calculator.png differ
diff --git a/Grinder/res/icons/painting/chalk.png b/Grinder/res/icons/painting/chalk.png
new file mode 100644
index 0000000000000000000000000000000000000000..40cc311b1c35233874c587d1df3187fdec3eecfd
Binary files /dev/null and b/Grinder/res/icons/painting/chalk.png differ
diff --git a/Grinder/res/icons/painting/circle-ruler.png b/Grinder/res/icons/painting/circle-ruler.png
new file mode 100644
index 0000000000000000000000000000000000000000..9e3a30048582d776b53d050a4d545783b973e9fc
Binary files /dev/null and b/Grinder/res/icons/painting/circle-ruler.png differ
diff --git a/Grinder/res/icons/painting/compass.png b/Grinder/res/icons/painting/compass.png
new file mode 100644
index 0000000000000000000000000000000000000000..6719b55d6d8ccb00fd3c2d936c3dc70a9e679ae9
Binary files /dev/null and b/Grinder/res/icons/painting/compass.png differ
diff --git a/Grinder/res/icons/painting/computer-mouse.png b/Grinder/res/icons/painting/computer-mouse.png
new file mode 100644
index 0000000000000000000000000000000000000000..be73856ed39ea6ce94986a15d2773ed6157265f8
Binary files /dev/null and b/Grinder/res/icons/painting/computer-mouse.png differ
diff --git a/Grinder/res/icons/painting/computer-screen.png b/Grinder/res/icons/painting/computer-screen.png
new file mode 100644
index 0000000000000000000000000000000000000000..3e34a1413a0fabf51f060fd7dc1484538ff2ce51
Binary files /dev/null and b/Grinder/res/icons/painting/computer-screen.png differ
diff --git a/Grinder/res/icons/painting/crayola.png b/Grinder/res/icons/painting/crayola.png
new file mode 100644
index 0000000000000000000000000000000000000000..8b0a1918dc996187a4ebc9809fa48bc6fd819418
Binary files /dev/null and b/Grinder/res/icons/painting/crayola.png differ
diff --git a/Grinder/res/icons/painting/curve.png b/Grinder/res/icons/painting/curve.png
new file mode 100644
index 0000000000000000000000000000000000000000..26706b679d297ff08752bfedb3244f9a26642051
Binary files /dev/null and b/Grinder/res/icons/painting/curve.png differ
diff --git a/Grinder/res/icons/painting/cutter-knife.png b/Grinder/res/icons/painting/cutter-knife.png
new file mode 100644
index 0000000000000000000000000000000000000000..90937981de41ab77d853778e0b357e803753df16
Binary files /dev/null and b/Grinder/res/icons/painting/cutter-knife.png differ
diff --git a/Grinder/res/icons/painting/desk-lamp.png b/Grinder/res/icons/painting/desk-lamp.png
new file mode 100644
index 0000000000000000000000000000000000000000..0d780c1ee291cf46c9c39f9183ef19b62f4f4d69
Binary files /dev/null and b/Grinder/res/icons/painting/desk-lamp.png differ
diff --git a/Grinder/res/icons/painting/eraser.png b/Grinder/res/icons/painting/eraser.png
new file mode 100644
index 0000000000000000000000000000000000000000..c02328e75076f4eb0a6c92fa93eba37575b94ed1
Binary files /dev/null and b/Grinder/res/icons/painting/eraser.png differ
diff --git a/Grinder/res/icons/painting/eyedropper-1.png b/Grinder/res/icons/painting/eyedropper-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..99051ea79d42b701ca14b61f19cfee4901f2fd8b
Binary files /dev/null and b/Grinder/res/icons/painting/eyedropper-1.png differ
diff --git a/Grinder/res/icons/painting/eyedropper.png b/Grinder/res/icons/painting/eyedropper.png
new file mode 100644
index 0000000000000000000000000000000000000000..567d53354f7d5957edfad28f43b4f9406c9d5113
Binary files /dev/null and b/Grinder/res/icons/painting/eyedropper.png differ
diff --git a/Grinder/res/icons/painting/feather-and-ink.png b/Grinder/res/icons/painting/feather-and-ink.png
new file mode 100644
index 0000000000000000000000000000000000000000..3fc153119a5b098145723fe41085662fac9e9d15
Binary files /dev/null and b/Grinder/res/icons/painting/feather-and-ink.png differ
diff --git a/Grinder/res/icons/painting/feather.png b/Grinder/res/icons/painting/feather.png
new file mode 100644
index 0000000000000000000000000000000000000000..8a19e0b2a4d1320454adf558f2e6a4e53e6a07fa
Binary files /dev/null and b/Grinder/res/icons/painting/feather.png differ
diff --git a/Grinder/res/icons/painting/glue.png b/Grinder/res/icons/painting/glue.png
new file mode 100644
index 0000000000000000000000000000000000000000..e15df887c94720950b7e9a0e1c169dce97feb335
Binary files /dev/null and b/Grinder/res/icons/painting/glue.png differ
diff --git a/Grinder/res/icons/painting/idea.png b/Grinder/res/icons/painting/idea.png
new file mode 100644
index 0000000000000000000000000000000000000000..be0854d181280941ca9ac8d1d5cc6144ddd8be42
Binary files /dev/null and b/Grinder/res/icons/painting/idea.png differ
diff --git a/Grinder/res/icons/painting/ink.png b/Grinder/res/icons/painting/ink.png
new file mode 100644
index 0000000000000000000000000000000000000000..d65e588001061e54c8a70e394a28d1a29a6497c0
Binary files /dev/null and b/Grinder/res/icons/painting/ink.png differ
diff --git a/Grinder/res/icons/painting/line.png b/Grinder/res/icons/painting/line.png
new file mode 100644
index 0000000000000000000000000000000000000000..e0917178e22bb0d7145e4a1e616ef854942d299c
Binary files /dev/null and b/Grinder/res/icons/painting/line.png differ
diff --git a/Grinder/res/icons/painting/marker.png b/Grinder/res/icons/painting/marker.png
new file mode 100644
index 0000000000000000000000000000000000000000..9bb68cbce27ff7fee91c77929a79c5bae69bd584
Binary files /dev/null and b/Grinder/res/icons/painting/marker.png differ
diff --git a/Grinder/res/icons/painting/paint-brush-1.png b/Grinder/res/icons/painting/paint-brush-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..5c801ea19d25d0acd134f5d0aeba0e9e52926732
Binary files /dev/null and b/Grinder/res/icons/painting/paint-brush-1.png differ
diff --git a/Grinder/res/icons/painting/paint-brush.png b/Grinder/res/icons/painting/paint-brush.png
new file mode 100644
index 0000000000000000000000000000000000000000..8e8394f3ea22cc7358051465eeee52b83626a680
Binary files /dev/null and b/Grinder/res/icons/painting/paint-brush.png differ
diff --git a/Grinder/res/icons/painting/paint-can.png b/Grinder/res/icons/painting/paint-can.png
new file mode 100644
index 0000000000000000000000000000000000000000..26f05075aef094856e622c06142c609a1141c49f
Binary files /dev/null and b/Grinder/res/icons/painting/paint-can.png differ
diff --git a/Grinder/res/icons/painting/paint-palette.png b/Grinder/res/icons/painting/paint-palette.png
new file mode 100644
index 0000000000000000000000000000000000000000..04c2587810f1da893ba25e6bb3ec5c684049f01b
Binary files /dev/null and b/Grinder/res/icons/painting/paint-palette.png differ
diff --git a/Grinder/res/icons/painting/paint-roller.png b/Grinder/res/icons/painting/paint-roller.png
new file mode 100644
index 0000000000000000000000000000000000000000..33f426c82946d5462be8a454c6c7828b347bb88d
Binary files /dev/null and b/Grinder/res/icons/painting/paint-roller.png differ
diff --git a/Grinder/res/icons/painting/paint-tube.png b/Grinder/res/icons/painting/paint-tube.png
new file mode 100644
index 0000000000000000000000000000000000000000..bb8386a70d022a16a28b00923e5ae55cee24c5a9
Binary files /dev/null and b/Grinder/res/icons/painting/paint-tube.png differ
diff --git a/Grinder/res/icons/painting/paintbrush.png b/Grinder/res/icons/painting/paintbrush.png
new file mode 100644
index 0000000000000000000000000000000000000000..79d2c4e9397cc6f8c3615bbe6c4dffab92f70639
Binary files /dev/null and b/Grinder/res/icons/painting/paintbrush.png differ
diff --git a/Grinder/res/icons/painting/paper-1.png b/Grinder/res/icons/painting/paper-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..33b50be30a62d52cf136740e78e08b4caaa928f3
Binary files /dev/null and b/Grinder/res/icons/painting/paper-1.png differ
diff --git a/Grinder/res/icons/painting/paper-and-feather.png b/Grinder/res/icons/painting/paper-and-feather.png
new file mode 100644
index 0000000000000000000000000000000000000000..228bca38990f912ec4edeb1ed619ab7a5a1274bc
Binary files /dev/null and b/Grinder/res/icons/painting/paper-and-feather.png differ
diff --git a/Grinder/res/icons/painting/paper-and-pencil.png b/Grinder/res/icons/painting/paper-and-pencil.png
new file mode 100644
index 0000000000000000000000000000000000000000..7fcbf28a2f32b6cecc7371c475359ae4a88ed881
Binary files /dev/null and b/Grinder/res/icons/painting/paper-and-pencil.png differ
diff --git a/Grinder/res/icons/painting/paper-clip.png b/Grinder/res/icons/painting/paper-clip.png
new file mode 100644
index 0000000000000000000000000000000000000000..50bad49f4ffc49ef942139a0fb7d8dbfdbda32ce
Binary files /dev/null and b/Grinder/res/icons/painting/paper-clip.png differ
diff --git a/Grinder/res/icons/painting/paper-with-text.png b/Grinder/res/icons/painting/paper-with-text.png
new file mode 100644
index 0000000000000000000000000000000000000000..86ed02b9c436799dc53183bae2c6a16dc4049d93
Binary files /dev/null and b/Grinder/res/icons/painting/paper-with-text.png differ
diff --git a/Grinder/res/icons/painting/paper.png b/Grinder/res/icons/painting/paper.png
new file mode 100644
index 0000000000000000000000000000000000000000..fb3706cd5712ca24cfbda746a380c1071ec4d23b
Binary files /dev/null and b/Grinder/res/icons/painting/paper.png differ
diff --git a/Grinder/res/icons/painting/pen-1.png b/Grinder/res/icons/painting/pen-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..1212bee1b3db81ba4da29c2134cb09359c00d2a6
Binary files /dev/null and b/Grinder/res/icons/painting/pen-1.png differ
diff --git a/Grinder/res/icons/painting/pen-tool.png b/Grinder/res/icons/painting/pen-tool.png
new file mode 100644
index 0000000000000000000000000000000000000000..a61a20957b09865436795e31d87c869d31381376
Binary files /dev/null and b/Grinder/res/icons/painting/pen-tool.png differ
diff --git a/Grinder/res/icons/painting/pen.png b/Grinder/res/icons/painting/pen.png
new file mode 100644
index 0000000000000000000000000000000000000000..b3c95dd1b31226ebd77fd760ab9e1bf4daadbf59
Binary files /dev/null and b/Grinder/res/icons/painting/pen.png differ
diff --git a/Grinder/res/icons/painting/pencil-1.png b/Grinder/res/icons/painting/pencil-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..3720bd3ffab277a20d7911f8570c90cc237bdffc
Binary files /dev/null and b/Grinder/res/icons/painting/pencil-1.png differ
diff --git a/Grinder/res/icons/painting/pencil.png b/Grinder/res/icons/painting/pencil.png
new file mode 100644
index 0000000000000000000000000000000000000000..f8c653050539f3c0a2009b8e422eeb703062838f
Binary files /dev/null and b/Grinder/res/icons/painting/pencil.png differ
diff --git a/Grinder/res/icons/painting/photo-camera.png b/Grinder/res/icons/painting/photo-camera.png
new file mode 100644
index 0000000000000000000000000000000000000000..24d2bd15cfe3ed8a63a67b00bc8c7630472972ca
Binary files /dev/null and b/Grinder/res/icons/painting/photo-camera.png differ
diff --git a/Grinder/res/icons/painting/ruler.png b/Grinder/res/icons/painting/ruler.png
new file mode 100644
index 0000000000000000000000000000000000000000..2c4d484d0bcd4a4154a003f89ec1517882f10139
Binary files /dev/null and b/Grinder/res/icons/painting/ruler.png differ
diff --git a/Grinder/res/icons/painting/s-curve.png b/Grinder/res/icons/painting/s-curve.png
new file mode 100644
index 0000000000000000000000000000000000000000..e76a2f0a8bbb0118b43c3e9fb55a50f498832417
Binary files /dev/null and b/Grinder/res/icons/painting/s-curve.png differ
diff --git a/Grinder/res/icons/painting/scissors.png b/Grinder/res/icons/painting/scissors.png
new file mode 100644
index 0000000000000000000000000000000000000000..7cbdbc2c1c194064b39a1021cc6087bbf714aa5d
Binary files /dev/null and b/Grinder/res/icons/painting/scissors.png differ
diff --git a/Grinder/res/icons/painting/sharpener.png b/Grinder/res/icons/painting/sharpener.png
new file mode 100644
index 0000000000000000000000000000000000000000..00879f9dcd5d5a65086e0d17a48aa98e226e5411
Binary files /dev/null and b/Grinder/res/icons/painting/sharpener.png differ
diff --git a/Grinder/res/icons/painting/sketchbook.png b/Grinder/res/icons/painting/sketchbook.png
new file mode 100644
index 0000000000000000000000000000000000000000..ce78b64be53349928bba995ed32821eda31ecbab
Binary files /dev/null and b/Grinder/res/icons/painting/sketchbook.png differ
diff --git a/Grinder/res/icons/painting/spray.png b/Grinder/res/icons/painting/spray.png
new file mode 100644
index 0000000000000000000000000000000000000000..b15f16923347ac27fad32a58b9ec9c3c538586d6
Binary files /dev/null and b/Grinder/res/icons/painting/spray.png differ
diff --git a/Grinder/res/icons/painting/transform-box.png b/Grinder/res/icons/painting/transform-box.png
new file mode 100644
index 0000000000000000000000000000000000000000..5bf8a255bf918704b02c23a7b43e6ad4ed4f5ec9
Binary files /dev/null and b/Grinder/res/icons/painting/transform-box.png differ
diff --git a/Grinder/res/icons/painting/triangle-ruler.png b/Grinder/res/icons/painting/triangle-ruler.png
new file mode 100644
index 0000000000000000000000000000000000000000..c1501595bd87328023bb4a3a8e9aef63a075a743
Binary files /dev/null and b/Grinder/res/icons/painting/triangle-ruler.png differ
diff --git a/Grinder/res/icons/painting/watercolor-palette.png b/Grinder/res/icons/painting/watercolor-palette.png
new file mode 100644
index 0000000000000000000000000000000000000000..f85585bfb71b24c9321335c8ebe9c5326de48a28
Binary files /dev/null and b/Grinder/res/icons/painting/watercolor-palette.png differ
diff --git a/Grinder/res/icons/plus-sign.png b/Grinder/res/icons/plus-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..55c7c826b2592b79be3e610eef34ad8e4550cdb2
Binary files /dev/null and b/Grinder/res/icons/plus-sign.png differ
diff --git a/Grinder/res/icons/plus-symbol-button.png b/Grinder/res/icons/plus-symbol-button.png
new file mode 100644
index 0000000000000000000000000000000000000000..609fb590dc44f04d617093d0a1706477e658daae
Binary files /dev/null and b/Grinder/res/icons/plus-symbol-button.png differ
diff --git a/Grinder/res/icons/portfolio-folder-with-button-lock.png b/Grinder/res/icons/portfolio-folder-with-button-lock.png
new file mode 100644
index 0000000000000000000000000000000000000000..991524b46cc7fe14241cb5f591fe706401bfb470
Binary files /dev/null and b/Grinder/res/icons/portfolio-folder-with-button-lock.png differ
diff --git a/Grinder/res/icons/printer-of-network.png b/Grinder/res/icons/printer-of-network.png
new file mode 100644
index 0000000000000000000000000000000000000000..50df5f63c45af9b1af7a798251364730f4e17cc8
Binary files /dev/null and b/Grinder/res/icons/printer-of-network.png differ
diff --git a/Grinder/res/icons/printer-with-document-coming-out-of-machine.png b/Grinder/res/icons/printer-with-document-coming-out-of-machine.png
new file mode 100644
index 0000000000000000000000000000000000000000..c807448076c08a4c6c5c7551f992286444e927c1
Binary files /dev/null and b/Grinder/res/icons/printer-with-document-coming-out-of-machine.png differ
diff --git a/Grinder/res/icons/question-mark-in-a-circle.png b/Grinder/res/icons/question-mark-in-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..c52555083b0fb82fde4f85d133d616495a511ee1
Binary files /dev/null and b/Grinder/res/icons/question-mark-in-a-circle.png differ
diff --git a/Grinder/res/icons/quotation-mark.png b/Grinder/res/icons/quotation-mark.png
new file mode 100644
index 0000000000000000000000000000000000000000..513cfb2b2dca89314363a3d1e41cca9b43586d58
Binary files /dev/null and b/Grinder/res/icons/quotation-mark.png differ
diff --git a/Grinder/res/icons/quotation-marks.png b/Grinder/res/icons/quotation-marks.png
new file mode 100644
index 0000000000000000000000000000000000000000..6e872539191bb569e91c2a3ac979b30d39aee8b0
Binary files /dev/null and b/Grinder/res/icons/quotation-marks.png differ
diff --git a/Grinder/res/icons/quotation-right-mark.png b/Grinder/res/icons/quotation-right-mark.png
new file mode 100644
index 0000000000000000000000000000000000000000..903d7e9dc72b4e347e7679a8b3f86b49f18d8f2c
Binary files /dev/null and b/Grinder/res/icons/quotation-right-mark.png differ
diff --git a/Grinder/res/icons/radiation-warning-sign.png b/Grinder/res/icons/radiation-warning-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..2c108c47dad92db0f401e689e55b7ef27181e074
Binary files /dev/null and b/Grinder/res/icons/radiation-warning-sign.png differ
diff --git a/Grinder/res/icons/recycle-symbol-made-of-arrows-with-selection-box.png b/Grinder/res/icons/recycle-symbol-made-of-arrows-with-selection-box.png
new file mode 100644
index 0000000000000000000000000000000000000000..f610ca8f79294a9e688eba3af339071739243728
Binary files /dev/null and b/Grinder/res/icons/recycle-symbol-made-of-arrows-with-selection-box.png differ
diff --git a/Grinder/res/icons/redo-navigational-arrow-in-a-circle.png b/Grinder/res/icons/redo-navigational-arrow-in-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..c9e91654cbe0b1510e97f0730ff0c368cc4a641f
Binary files /dev/null and b/Grinder/res/icons/redo-navigational-arrow-in-a-circle.png differ
diff --git a/Grinder/res/icons/refresh-arrows-couple.png b/Grinder/res/icons/refresh-arrows-couple.png
new file mode 100644
index 0000000000000000000000000000000000000000..48ffa35848fbb6defb914986a913a0cc9212dffd
Binary files /dev/null and b/Grinder/res/icons/refresh-arrows-couple.png differ
diff --git a/Grinder/res/icons/refresh-navigational-arrows-interface-symbol-inside-a-circle.png b/Grinder/res/icons/refresh-navigational-arrows-interface-symbol-inside-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..574986a8c84b2eb2d83e57534792eeb10dda27c0
Binary files /dev/null and b/Grinder/res/icons/refresh-navigational-arrows-interface-symbol-inside-a-circle.png differ
diff --git a/Grinder/res/icons/resize-arrow-inside-a-square-interface-symbol.png b/Grinder/res/icons/resize-arrow-inside-a-square-interface-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..4e76e6253bff4d91ad92e39a3d130ceaae740957
Binary files /dev/null and b/Grinder/res/icons/resize-arrow-inside-a-square-interface-symbol.png differ
diff --git a/Grinder/res/icons/right-text-alignment-option.png b/Grinder/res/icons/right-text-alignment-option.png
new file mode 100644
index 0000000000000000000000000000000000000000..327f0f253d7d001825bc12012fb5a58dd6895a1f
Binary files /dev/null and b/Grinder/res/icons/right-text-alignment-option.png differ
diff --git a/Grinder/res/icons/right-thin-arrowheads.png b/Grinder/res/icons/right-thin-arrowheads.png
new file mode 100644
index 0000000000000000000000000000000000000000..0c3db37398d3e729a01039a5d561821732408699
Binary files /dev/null and b/Grinder/res/icons/right-thin-arrowheads.png differ
diff --git a/Grinder/res/icons/route-sign.png b/Grinder/res/icons/route-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..b5e5607b8285783a3e93faa70230435b693c8f55
Binary files /dev/null and b/Grinder/res/icons/route-sign.png differ
diff --git a/Grinder/res/icons/scroll-variant-with-seal.png b/Grinder/res/icons/scroll-variant-with-seal.png
new file mode 100644
index 0000000000000000000000000000000000000000..a53fa60656e383a472a98e0565e1019305ee98a7
Binary files /dev/null and b/Grinder/res/icons/scroll-variant-with-seal.png differ
diff --git a/Grinder/res/icons/select-all.png b/Grinder/res/icons/select-all.png
new file mode 100644
index 0000000000000000000000000000000000000000..07dac5053d857d20a268cab15f0dd1bd09e87c61
Binary files /dev/null and b/Grinder/res/icons/select-all.png differ
diff --git a/Grinder/res/icons/selection-find-interface-symbol-of-binoculars-with-eyes-in-a-square-of-broken-line.png b/Grinder/res/icons/selection-find-interface-symbol-of-binoculars-with-eyes-in-a-square-of-broken-line.png
new file mode 100644
index 0000000000000000000000000000000000000000..30b0e4755737584959b708217875763b32743655
Binary files /dev/null and b/Grinder/res/icons/selection-find-interface-symbol-of-binoculars-with-eyes-in-a-square-of-broken-line.png differ
diff --git a/Grinder/res/icons/service-bell.png b/Grinder/res/icons/service-bell.png
new file mode 100644
index 0000000000000000000000000000000000000000..a2cc8a62331abfc13f836f85039d3c217d4fd01d
Binary files /dev/null and b/Grinder/res/icons/service-bell.png differ
diff --git a/Grinder/res/icons/ships-wheel.png b/Grinder/res/icons/ships-wheel.png
new file mode 100644
index 0000000000000000000000000000000000000000..e4d7006e24cae1b7b20b3287ee7ea4688020f0d0
Binary files /dev/null and b/Grinder/res/icons/ships-wheel.png differ
diff --git a/Grinder/res/icons/show-arrows.png b/Grinder/res/icons/show-arrows.png
new file mode 100644
index 0000000000000000000000000000000000000000..3b4128297334757c2168d2cac82bb010ab026d99
Binary files /dev/null and b/Grinder/res/icons/show-arrows.png differ
diff --git a/Grinder/res/icons/show-tags.png b/Grinder/res/icons/show-tags.png
new file mode 100644
index 0000000000000000000000000000000000000000..e8ca441b3fe23bb516cc742bd132f8a949f238a4
Binary files /dev/null and b/Grinder/res/icons/show-tags.png differ
diff --git a/Grinder/res/icons/signaling-disc-tool.png b/Grinder/res/icons/signaling-disc-tool.png
new file mode 100644
index 0000000000000000000000000000000000000000..d65dd36b78209c9e31c05bad98c5279e43f156be
Binary files /dev/null and b/Grinder/res/icons/signaling-disc-tool.png differ
diff --git a/Grinder/res/icons/signpost-1.png b/Grinder/res/icons/signpost-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..ca362cba40a9c718587fb8545077e6ffefae7947
Binary files /dev/null and b/Grinder/res/icons/signpost-1.png differ
diff --git a/Grinder/res/icons/signpost-with-three-arrows.png b/Grinder/res/icons/signpost-with-three-arrows.png
new file mode 100644
index 0000000000000000000000000000000000000000..184289f80665f32b6a8103639caa075c7cd1c6f0
Binary files /dev/null and b/Grinder/res/icons/signpost-with-three-arrows.png differ
diff --git a/Grinder/res/icons/signpost.png b/Grinder/res/icons/signpost.png
new file mode 100644
index 0000000000000000000000000000000000000000..64903e93cf2cc650f9e7ead2cd6f04af71514b4b
Binary files /dev/null and b/Grinder/res/icons/signpost.png differ
diff --git a/Grinder/res/icons/speech-balloon-outline-for-conversation.png b/Grinder/res/icons/speech-balloon-outline-for-conversation.png
new file mode 100644
index 0000000000000000000000000000000000000000..bf0c1ebbf2070bdc4e7ec8f0aa193052ec833281
Binary files /dev/null and b/Grinder/res/icons/speech-balloon-outline-for-conversation.png differ
diff --git a/Grinder/res/icons/speech-balloon-outline-with-exclamation-mark.png b/Grinder/res/icons/speech-balloon-outline-with-exclamation-mark.png
new file mode 100644
index 0000000000000000000000000000000000000000..6b1ba06507819c8e30d8084576cab8a02ba465c2
Binary files /dev/null and b/Grinder/res/icons/speech-balloon-outline-with-exclamation-mark.png differ
diff --git a/Grinder/res/icons/speech-balloon-outline-with-question-mark.png b/Grinder/res/icons/speech-balloon-outline-with-question-mark.png
new file mode 100644
index 0000000000000000000000000000000000000000..7d1b35658200db8f73179678249d932646848785
Binary files /dev/null and b/Grinder/res/icons/speech-balloon-outline-with-question-mark.png differ
diff --git a/Grinder/res/icons/spell-check-interface-symbol.png b/Grinder/res/icons/spell-check-interface-symbol.png
new file mode 100644
index 0000000000000000000000000000000000000000..23f11ce7efaf336890aa45b4a00792abed5f3bbf
Binary files /dev/null and b/Grinder/res/icons/spell-check-interface-symbol.png differ
diff --git a/Grinder/res/icons/split-type-window.png b/Grinder/res/icons/split-type-window.png
new file mode 100644
index 0000000000000000000000000000000000000000..b8a4b7cbf8a85da8ab3b76b15ea4318cacf380fb
Binary files /dev/null and b/Grinder/res/icons/split-type-window.png differ
diff --git a/Grinder/res/icons/square-root-of-x-math-formula.png b/Grinder/res/icons/square-root-of-x-math-formula.png
new file mode 100644
index 0000000000000000000000000000000000000000..84282ad44d2a1cf165e7534db5952b523cf4b526
Binary files /dev/null and b/Grinder/res/icons/square-root-of-x-math-formula.png differ
diff --git a/Grinder/res/icons/ssd-drive-part.png b/Grinder/res/icons/ssd-drive-part.png
new file mode 100644
index 0000000000000000000000000000000000000000..1ff62df7248fe7862d4887c188bef85c5ea965bf
Binary files /dev/null and b/Grinder/res/icons/ssd-drive-part.png differ
diff --git a/Grinder/res/icons/stairs-down.png b/Grinder/res/icons/stairs-down.png
new file mode 100644
index 0000000000000000000000000000000000000000..93f1740f1d70f096c101efaa32f2ef9d2ec36643
Binary files /dev/null and b/Grinder/res/icons/stairs-down.png differ
diff --git a/Grinder/res/icons/stairs-up.png b/Grinder/res/icons/stairs-up.png
new file mode 100644
index 0000000000000000000000000000000000000000..f6f79fb8d9bb112366376a2e5bc350fe70c67edf
Binary files /dev/null and b/Grinder/res/icons/stairs-up.png differ
diff --git a/Grinder/res/icons/star-in-black-of-five-points-shape.png b/Grinder/res/icons/star-in-black-of-five-points-shape.png
new file mode 100644
index 0000000000000000000000000000000000000000..f9b2a350f17caa7b21b2f5a02d7fe1d5c93dda1d
Binary files /dev/null and b/Grinder/res/icons/star-in-black-of-five-points-shape.png differ
diff --git a/Grinder/res/icons/star-outline.png b/Grinder/res/icons/star-outline.png
new file mode 100644
index 0000000000000000000000000000000000000000..f96b6dacc5c52a3649a418ae22227e328a09c9ab
Binary files /dev/null and b/Grinder/res/icons/star-outline.png differ
diff --git a/Grinder/res/icons/start.png b/Grinder/res/icons/start.png
new file mode 100644
index 0000000000000000000000000000000000000000..b46ccf151ce08b384380b5bd31fd9e5d7b164893
Binary files /dev/null and b/Grinder/res/icons/start.png differ
diff --git a/Grinder/res/icons/stop-sign-variant.png b/Grinder/res/icons/stop-sign-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..d12fbfb0b613587eb99efac1b160986e9096d0a9
Binary files /dev/null and b/Grinder/res/icons/stop-sign-variant.png differ
diff --git a/Grinder/res/icons/strikethrough-font-variant.png b/Grinder/res/icons/strikethrough-font-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..209dc2b939852df7d34393505198a0ecd55b66f7
Binary files /dev/null and b/Grinder/res/icons/strikethrough-font-variant.png differ
diff --git a/Grinder/res/icons/sum-sign.png b/Grinder/res/icons/sum-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..0d0103e6fc61e6c7c65cdd518c3c5f64affaa68d
Binary files /dev/null and b/Grinder/res/icons/sum-sign.png differ
diff --git a/Grinder/res/icons/tag-black.png b/Grinder/res/icons/tag-black.png
new file mode 100644
index 0000000000000000000000000000000000000000..65ece66db8b6d01678aa5f0a15a94aa672081d6a
Binary files /dev/null and b/Grinder/res/icons/tag-black.png differ
diff --git a/Grinder/res/icons/tags-black-couple-with-rings.png b/Grinder/res/icons/tags-black-couple-with-rings.png
new file mode 100644
index 0000000000000000000000000000000000000000..4b6bce5af4b61dee24796980af1cc181b0838068
Binary files /dev/null and b/Grinder/res/icons/tags-black-couple-with-rings.png differ
diff --git a/Grinder/res/icons/text-align-left.png b/Grinder/res/icons/text-align-left.png
new file mode 100644
index 0000000000000000000000000000000000000000..79fdd6c1ce3e7a74a31e01230a1730abc225dfef
Binary files /dev/null and b/Grinder/res/icons/text-align-left.png differ
diff --git a/Grinder/res/icons/text-alignment-at-the-center.png b/Grinder/res/icons/text-alignment-at-the-center.png
new file mode 100644
index 0000000000000000000000000000000000000000..0b8a3ef5df07a26465aa3cc6c8e47c7467901f24
Binary files /dev/null and b/Grinder/res/icons/text-alignment-at-the-center.png differ
diff --git a/Grinder/res/icons/text-document.png b/Grinder/res/icons/text-document.png
new file mode 100644
index 0000000000000000000000000000000000000000..c2192d50b51e73080c624c09a58f01c49e105873
Binary files /dev/null and b/Grinder/res/icons/text-document.png differ
diff --git a/Grinder/res/icons/ticket.png b/Grinder/res/icons/ticket.png
new file mode 100644
index 0000000000000000000000000000000000000000..be33c49540dbc7165211141a390d4885aec96856
Binary files /dev/null and b/Grinder/res/icons/ticket.png differ
diff --git a/Grinder/res/icons/toxic-warning-sign.png b/Grinder/res/icons/toxic-warning-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..7515328d638cf54eab476dd3d415306d3d186b52
Binary files /dev/null and b/Grinder/res/icons/toxic-warning-sign.png differ
diff --git a/Grinder/res/icons/traffic-light-in-red-signal.png b/Grinder/res/icons/traffic-light-in-red-signal.png
new file mode 100644
index 0000000000000000000000000000000000000000..cc898200e460d2273159338dc3b3941794ae3a55
Binary files /dev/null and b/Grinder/res/icons/traffic-light-in-red-signal.png differ
diff --git a/Grinder/res/icons/traffic-light-silhouette-variant.png b/Grinder/res/icons/traffic-light-silhouette-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..2d05aa14dbaea5b638a0f845d899d356ce64d837
Binary files /dev/null and b/Grinder/res/icons/traffic-light-silhouette-variant.png differ
diff --git a/Grinder/res/icons/traffic-light.png b/Grinder/res/icons/traffic-light.png
new file mode 100644
index 0000000000000000000000000000000000000000..513ba96552a5582b2d6a90e9bb98f04299d4f9b4
Binary files /dev/null and b/Grinder/res/icons/traffic-light.png differ
diff --git a/Grinder/res/icons/trafficlight-in-green.png b/Grinder/res/icons/trafficlight-in-green.png
new file mode 100644
index 0000000000000000000000000000000000000000..64d84a0182f297dbdba8d02721abd0dc719d394a
Binary files /dev/null and b/Grinder/res/icons/trafficlight-in-green.png differ
diff --git a/Grinder/res/icons/trafficlight-in-yellow.png b/Grinder/res/icons/trafficlight-in-yellow.png
new file mode 100644
index 0000000000000000000000000000000000000000..f074f605f051c9147cd9dd39814e74a74727886f
Binary files /dev/null and b/Grinder/res/icons/trafficlight-in-yellow.png differ
diff --git a/Grinder/res/icons/trafficlight-off.png b/Grinder/res/icons/trafficlight-off.png
new file mode 100644
index 0000000000000000000000000000000000000000..a0d1b7e9cf6327800e5a8dc4c686e730d72d48cc
Binary files /dev/null and b/Grinder/res/icons/trafficlight-off.png differ
diff --git a/Grinder/res/icons/triangular-warning-sign.png b/Grinder/res/icons/triangular-warning-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..220cb1f680ae900836dd10ee9004875b0921b2d1
Binary files /dev/null and b/Grinder/res/icons/triangular-warning-sign.png differ
diff --git a/Grinder/res/icons/triple-arrow-merging-to-one.png b/Grinder/res/icons/triple-arrow-merging-to-one.png
new file mode 100644
index 0000000000000000000000000000000000000000..cfb595dba064c3c7b2f8b36477797b292c14b247
Binary files /dev/null and b/Grinder/res/icons/triple-arrow-merging-to-one.png differ
diff --git a/Grinder/res/icons/undo-arrow.png b/Grinder/res/icons/undo-arrow.png
new file mode 100644
index 0000000000000000000000000000000000000000..a4672b08e10aa08d1913821a0ccdb4c94bc9c42c
Binary files /dev/null and b/Grinder/res/icons/undo-arrow.png differ
diff --git a/Grinder/res/icons/undo-navigational-arrow-in-a-circle.png b/Grinder/res/icons/undo-navigational-arrow-in-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..168319e8fedef471b4f79170beb87650a1a853d5
Binary files /dev/null and b/Grinder/res/icons/undo-navigational-arrow-in-a-circle.png differ
diff --git a/Grinder/res/icons/up-right-arrow-in-a-circle.png b/Grinder/res/icons/up-right-arrow-in-a-circle.png
new file mode 100644
index 0000000000000000000000000000000000000000..222cc04e5b6137eb67e521662b41b0ffc81a22fa
Binary files /dev/null and b/Grinder/res/icons/up-right-arrow-in-a-circle.png differ
diff --git a/Grinder/res/icons/warning-flammable-sign.png b/Grinder/res/icons/warning-flammable-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..f191eac47b0f67e473da716b73ad9994c9037a90
Binary files /dev/null and b/Grinder/res/icons/warning-flammable-sign.png differ
diff --git a/Grinder/res/icons/warning-harmful-sign.png b/Grinder/res/icons/warning-harmful-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..571dff333129416f190dc8e7a8d1f3f6c770803a
Binary files /dev/null and b/Grinder/res/icons/warning-harmful-sign.png differ
diff --git a/Grinder/res/icons/warning-voltage-sign-of-a-bolt-inside-a-triangle.png b/Grinder/res/icons/warning-voltage-sign-of-a-bolt-inside-a-triangle.png
new file mode 100644
index 0000000000000000000000000000000000000000..61a2e627963f9a3697398180c0adc7adcbc53118
Binary files /dev/null and b/Grinder/res/icons/warning-voltage-sign-of-a-bolt-inside-a-triangle.png differ
diff --git a/Grinder/res/icons/warning-window-with-exclamation-sign-inside-a-triangle.png b/Grinder/res/icons/warning-window-with-exclamation-sign-inside-a-triangle.png
new file mode 100644
index 0000000000000000000000000000000000000000..f79ba64666276bdb921b1e319b870786b5187a57
Binary files /dev/null and b/Grinder/res/icons/warning-window-with-exclamation-sign-inside-a-triangle.png differ
diff --git a/Grinder/res/icons/window-close.png b/Grinder/res/icons/window-close.png
new file mode 100644
index 0000000000000000000000000000000000000000..d158881e5259d02e7e8951f3397d8c4cdf5c5442
Binary files /dev/null and b/Grinder/res/icons/window-close.png differ
diff --git a/Grinder/res/icons/window-networking-option.png b/Grinder/res/icons/window-networking-option.png
new file mode 100644
index 0000000000000000000000000000000000000000..0810d8a2f56545967ea0efa4a66a59da2ff4546c
Binary files /dev/null and b/Grinder/res/icons/window-networking-option.png differ
diff --git a/Grinder/res/icons/window-of-dialog.png b/Grinder/res/icons/window-of-dialog.png
new file mode 100644
index 0000000000000000000000000000000000000000..a80513bed048fca684dbce57cc6c65710b58ec1e
Binary files /dev/null and b/Grinder/res/icons/window-of-dialog.png differ
diff --git a/Grinder/res/icons/window-of-test-card.png b/Grinder/res/icons/window-of-test-card.png
new file mode 100644
index 0000000000000000000000000000000000000000..17923cd61e6abf2d1dd5ddcc03fc7ccde1f6afe3
Binary files /dev/null and b/Grinder/res/icons/window-of-test-card.png differ
diff --git a/Grinder/res/icons/window-size.png b/Grinder/res/icons/window-size.png
new file mode 100644
index 0000000000000000000000000000000000000000..58c90c68d40d5c67a356a155c8f93d19dfc786fd
Binary files /dev/null and b/Grinder/res/icons/window-size.png differ
diff --git a/Grinder/res/icons/window-width.png b/Grinder/res/icons/window-width.png
new file mode 100644
index 0000000000000000000000000000000000000000..431fe387088be547966354969d71b30caccc2193
Binary files /dev/null and b/Grinder/res/icons/window-width.png differ
diff --git a/Grinder/res/icons/window-with-earth-image.png b/Grinder/res/icons/window-with-earth-image.png
new file mode 100644
index 0000000000000000000000000000000000000000..f54ed3990be5802c59f5f997c591981c27e58984
Binary files /dev/null and b/Grinder/res/icons/window-with-earth-image.png differ
diff --git a/Grinder/res/icons/window-with-side-bar-selection.png b/Grinder/res/icons/window-with-side-bar-selection.png
new file mode 100644
index 0000000000000000000000000000000000000000..d147c7d2ec4e1657e6391af4d265079cecd834cb
Binary files /dev/null and b/Grinder/res/icons/window-with-side-bar-selection.png differ
diff --git a/Grinder/res/icons/windows-close.png b/Grinder/res/icons/windows-close.png
new file mode 100644
index 0000000000000000000000000000000000000000..b9c028c061c73474f317fb8092f256135f2857a3
Binary files /dev/null and b/Grinder/res/icons/windows-close.png differ
diff --git a/Grinder/res/icons/windows-couple.png b/Grinder/res/icons/windows-couple.png
new file mode 100644
index 0000000000000000000000000000000000000000..ad02b6679c7aa09a47c1161338721e64e70e14d5
Binary files /dev/null and b/Grinder/res/icons/windows-couple.png differ
diff --git a/Grinder/res/icons/yield-sign.png b/Grinder/res/icons/yield-sign.png
new file mode 100644
index 0000000000000000000000000000000000000000..7e7bf7f4f5250c2a3f8d45f8cfce49ee3d00f089
Binary files /dev/null and b/Grinder/res/icons/yield-sign.png differ
diff --git a/Grinder/res/icons/zip-file-document-variant.png b/Grinder/res/icons/zip-file-document-variant.png
new file mode 100644
index 0000000000000000000000000000000000000000..809490b1da4cfc442002948d51e8f06d84da4a39
Binary files /dev/null and b/Grinder/res/icons/zip-file-document-variant.png differ
diff --git a/Grinder/res/icons/zoom-fit.png b/Grinder/res/icons/zoom-fit.png
new file mode 100644
index 0000000000000000000000000000000000000000..8361e6f095b050b3234ca3fb91e0b9bbc286e5b9
Binary files /dev/null and b/Grinder/res/icons/zoom-fit.png differ
diff --git a/Grinder/res/icons/zoom-in.png b/Grinder/res/icons/zoom-in.png
new file mode 100644
index 0000000000000000000000000000000000000000..d09026423382c44cfa86b8ea64ba8a6d85055e15
Binary files /dev/null and b/Grinder/res/icons/zoom-in.png differ
diff --git a/Grinder/res/icons/zoom-orig.png b/Grinder/res/icons/zoom-orig.png
new file mode 100644
index 0000000000000000000000000000000000000000..b22143236f34bd022189b16c64bc0426f824198c
Binary files /dev/null and b/Grinder/res/icons/zoom-orig.png differ
diff --git a/Grinder/res/icons/zoom-out.png b/Grinder/res/icons/zoom-out.png
new file mode 100644
index 0000000000000000000000000000000000000000..304b0dcb76b3be71c913b08b5c8e9155e576fa54
Binary files /dev/null and b/Grinder/res/icons/zoom-out.png differ
diff --git a/Grinder/res/misc/checkerboard.png b/Grinder/res/misc/checkerboard.png
new file mode 100644
index 0000000000000000000000000000000000000000..4a5b02465dd67226e8898b9f2892bedb1cdffd23
Binary files /dev/null and b/Grinder/res/misc/checkerboard.png differ
diff --git a/Grinder/ui/StyleSheet.cpp b/Grinder/ui/StyleSheet.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f7b2f20398faed56586216a9157c7fb443464f60
--- /dev/null
+++ b/Grinder/ui/StyleSheet.cpp
@@ -0,0 +1,43 @@
+/******************************************************************************
+ * File: StyleSheet.cpp
+ * Date: 02.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "StyleSheet.h"
+
+QString StyleSheet::loadStyleSheet(QString name)
+{
+	QFile file(":/css/" + name);
+
+	if (file.open(QIODevice::ReadOnly|QIODevice::Text))
+	{
+		QTextStream	stream(&file);
+		QString		sheet = stream.readAll();
+
+		replacePlaceholders(sheet);
+		return sheet;
+	}
+	else
+		return "";
+}
+
+void StyleSheet::replacePlaceholders(QString& styleSheet)
+{
+	QColor clrBorder = QPalette{}.color(QPalette::Dark);
+	QColor clrBase = QPalette{}.color(QPalette::Base);
+	QColor clrLightBg = QPalette{}.color(QPalette::Button).lighter(104);
+	QColor clrLightGray = QPalette{}.color(QPalette::Dark).lighter(120);
+	QColor clrHighlight = QPalette{}.color(QPalette::Highlight);
+
+	replaceColorPlaceholder(styleSheet, "%BORDER%", clrBorder);
+	replaceColorPlaceholder(styleSheet, "%BASE%", clrBase);
+	replaceColorPlaceholder(styleSheet, "%LIGHTBG%", clrLightBg);
+	replaceColorPlaceholder(styleSheet, "%LIGHTGRAY%", clrLightGray);
+	replaceColorPlaceholder(styleSheet, "%HIGHLIGHT%", clrHighlight);
+}
+
+void StyleSheet::replaceColorPlaceholder(QString& styleSheet, QString name, QColor color)
+{
+	styleSheet.replace(name, color.name());
+}
diff --git a/Grinder/ui/StyleSheet.h b/Grinder/ui/StyleSheet.h
new file mode 100644
index 0000000000000000000000000000000000000000..ca78ff9fdd07e21c07fe396917e478c125d3beca
--- /dev/null
+++ b/Grinder/ui/StyleSheet.h
@@ -0,0 +1,28 @@
+/******************************************************************************
+ * File: StyleSheet.h
+ * Date: 02.2.2018
+ *****************************************************************************/
+
+#ifndef GLOBALSTYLESHEET_H
+#define GLOBALSTYLESHEET_H
+
+#include <QString>
+#include <QColor>
+
+namespace grndr
+{
+	class StyleSheet final
+	{
+	public:
+		static QString loadStyleSheet(QString name);
+
+	private:
+		StyleSheet() { }
+
+	private:
+		static void replacePlaceholders(QString& styleSheet);
+		static void replaceColorPlaceholder(QString& styleSheet, QString name, QColor color);
+	};
+}
+
+#endif
diff --git a/Grinder/ui/dlg/OptionsDialog.cpp b/Grinder/ui/dlg/OptionsDialog.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..655ba9df15c0925d88916d80bc08be21d466a73c
--- /dev/null
+++ b/Grinder/ui/dlg/OptionsDialog.cpp
@@ -0,0 +1,95 @@
+/******************************************************************************
+ * File: OptionsDialog.cpp
+ * Date: 02.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "OptionsDialog.h"
+#include "ui_OptionsDialog.h"
+#include "core/GrinderApplication.h"
+
+OptionsDialog::OptionsDialog(QWidget *parent) : QDialog(parent, Qt::Dialog|Qt::WindowTitleHint|Qt::WindowCloseButtonHint),
+	ui{new Ui::OptionsDialog}
+{
+	_generalOptions = grinder()->settings().generalOptions();
+	_startupOptions = grinder()->settings().startupOptions();
+	_imageEditorOptions = grinder()->settings().imageEditorOptions();
+
+	setupUi();
+}
+
+OptionsDialog::~OptionsDialog()
+{
+	delete ui;
+}
+
+void grndr::OptionsDialog::setupUi()
+{
+	ui->setupUi(this);
+
+	applyGeneralOptions(false);
+	applyStartupOptions(false);
+	applyImageEditorOptions(false);
+}
+
+void OptionsDialog::applyGeneralOptions(bool save)
+{
+	if (save)
+		_generalOptions.askOnUnsavedChanges = ui->chkAskOnUnsavedChanges->isChecked();
+	else
+		ui->chkAskOnUnsavedChanges->setChecked(_generalOptions.askOnUnsavedChanges);
+}
+
+void OptionsDialog::applyStartupOptions(bool save)
+{
+	if (save)
+		_startupOptions.loadLastProject = ui->chkLoadLastProject->isChecked();
+	else
+		ui->chkLoadLastProject->setChecked(_startupOptions.loadLastProject);
+}
+
+void OptionsDialog::applyImageEditorOptions(bool save)
+{
+	if (save)
+	{
+		_imageEditorOptions.groupIntoTabs = ui->chkGroupIntoTabs->isChecked();
+		_imageEditorOptions.startUndocked = ui->chkStartUndocked->isChecked();
+		_imageEditorOptions.autoFitToWindow = ui->chkAutoFitToWindow->isChecked();
+	}
+	else
+	{
+		ui->chkGroupIntoTabs->setChecked(_imageEditorOptions.groupIntoTabs);
+		ui->chkStartUndocked->setChecked(_imageEditorOptions.startUndocked);
+		ui->chkAutoFitToWindow->setChecked(_imageEditorOptions.autoFitToWindow);
+	}
+}
+
+void OptionsDialog::accept()
+{
+	applyGeneralOptions(true);
+	applyStartupOptions(true);
+	applyImageEditorOptions(true);
+
+	grinder()->settings().generalOptions() = _generalOptions;
+	grinder()->settings().startupOptions() = _startupOptions;
+	grinder()->settings().imageEditorOptions() = _imageEditorOptions;
+	grinder()->settings().saveSettings();
+
+	QDialog::accept();
+}
+
+void OptionsDialog::on_chkGroupIntoTabs_toggled(bool checked)
+{
+	ui->chkStartUndocked->setEnabled(!checked);
+
+	if (checked)
+		ui->chkStartUndocked->setChecked(false);
+}
+
+void OptionsDialog::on_chkStartUndocked_toggled(bool checked)
+{
+	ui->chkGroupIntoTabs->setEnabled(!checked);
+
+	if (checked)
+		ui->chkGroupIntoTabs->setChecked(false);
+}
diff --git a/Grinder/ui/dlg/OptionsDialog.h b/Grinder/ui/dlg/OptionsDialog.h
new file mode 100644
index 0000000000000000000000000000000000000000..9811b785c1fc33fc0e4b77e03d87f50b82d91d87
--- /dev/null
+++ b/Grinder/ui/dlg/OptionsDialog.h
@@ -0,0 +1,51 @@
+/******************************************************************************
+ * File: OptionsDialog.h
+ * Date: 02.3.2018
+ *****************************************************************************/
+
+#ifndef OPTIONSDIALOG_H
+#define OPTIONSDIALOG_H
+
+#include <QDialog>
+
+#include "core/GrinderSettings.h"
+
+namespace Ui
+{
+	class OptionsDialog;
+}
+
+namespace grndr
+{
+	class OptionsDialog : public QDialog
+	{
+		Q_OBJECT
+
+	public:
+		OptionsDialog(QWidget *parent = nullptr);
+		~OptionsDialog();
+
+	public slots:
+		virtual void accept() override;
+
+	private slots:
+		void on_chkGroupIntoTabs_toggled(bool checked);
+		void on_chkStartUndocked_toggled(bool checked);
+
+	private:
+		void setupUi();
+		Ui::OptionsDialog *ui;
+
+	private:
+		void applyGeneralOptions(bool save);
+		void applyStartupOptions(bool save);
+		void applyImageEditorOptions(bool save);
+
+	private:
+		GrinderSettings::GeneralOptions _generalOptions;
+		GrinderSettings::StartupOptions _startupOptions;
+		GrinderSettings::ImageEditorOptions _imageEditorOptions;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/dlg/OptionsDialog.ui b/Grinder/ui/dlg/OptionsDialog.ui
new file mode 100644
index 0000000000000000000000000000000000000000..3d593fd182387338242bdb9f8166965dd769987e
--- /dev/null
+++ b/Grinder/ui/dlg/OptionsDialog.ui
@@ -0,0 +1,168 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>OptionsDialog</class>
+ <widget class="QDialog" name="OptionsDialog">
+  <property name="windowModality">
+   <enum>Qt::ApplicationModal</enum>
+  </property>
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>326</width>
+    <height>297</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Options</string>
+  </property>
+  <property name="modal">
+   <bool>true</bool>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <property name="sizeConstraint">
+    <enum>QLayout::SetFixedSize</enum>
+   </property>
+   <item>
+    <widget class="QGroupBox" name="groupBox_2">
+     <property name="title">
+      <string>General options</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_3">
+      <item>
+       <widget class="QCheckBox" name="chkAskOnUnsavedChanges">
+        <property name="text">
+         <string>&amp;Ask on unsaved project changes</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <widget class="QGroupBox" name="groupBox">
+     <property name="title">
+      <string>Startup options</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_2">
+      <item>
+       <widget class="QCheckBox" name="chkLoadLastProject">
+        <property name="text">
+         <string>&amp;Load last project on startup</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <widget class="QGroupBox" name="groupBox_3">
+     <property name="title">
+      <string>Image editor options</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_4">
+      <item>
+       <widget class="QCheckBox" name="chkGroupIntoTabs">
+        <property name="text">
+         <string>&amp;Group editors into tabs</string>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="QCheckBox" name="chkStartUndocked">
+        <property name="text">
+         <string>Start new editors &amp;undocked</string>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="Line" name="line">
+        <property name="styleSheet">
+         <string notr="true">color: lightgray;</string>
+        </property>
+        <property name="frameShadow">
+         <enum>QFrame::Plain</enum>
+        </property>
+        <property name="orientation">
+         <enum>Qt::Horizontal</enum>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="QCheckBox" name="chkAutoFitToWindow">
+        <property name="text">
+         <string>Automatically &amp;fit images to window</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <spacer name="verticalSpacer">
+     <property name="orientation">
+      <enum>Qt::Vertical</enum>
+     </property>
+     <property name="sizeType">
+      <enum>QSizePolicy::MinimumExpanding</enum>
+     </property>
+     <property name="sizeHint" stdset="0">
+      <size>
+       <width>0</width>
+       <height>15</height>
+      </size>
+     </property>
+    </spacer>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <tabstops>
+  <tabstop>chkAskOnUnsavedChanges</tabstop>
+  <tabstop>chkLoadLastProject</tabstop>
+ </tabstops>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>OptionsDialog</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>257</x>
+     <y>208</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>217</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>OptionsDialog</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>316</x>
+     <y>208</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>217</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>
diff --git a/Grinder/ui/graph/GraphBlockList.cpp b/Grinder/ui/graph/GraphBlockList.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ab01f8265c3b305921a27c17c5303fdd8e307734
--- /dev/null
+++ b/Grinder/ui/graph/GraphBlockList.cpp
@@ -0,0 +1,178 @@
+/******************************************************************************
+ * File: GraphBlockList.cpp
+ * Date: 03.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphBlockList.h"
+#include "core/GrinderApplication.h"
+#include "pipeline/PipelineManager.h"
+#include "pipeline/BlockCatalog.h"
+#include "ui/StyleSheet.h"
+#include "ui/graph/GraphView.h"
+#include "ui/graph/GraphBlockNode.h"
+#include "res/Resources.h"
+
+#define BLOCKTYPEBUTTON_TYPE_PROPERTY		"blockType"
+
+GraphBlockList::GraphBlockList(QWidget* parent) : QFrame(parent),
+	_layout{new QVBoxLayout{this}}
+{
+	setObjectName("BlockStockpileCtrl");
+	setStyleSheet(StyleSheet::loadStyleSheet(FILE_STYLESHEET_BLOCKLIST));
+
+	_layout->setContentsMargins(2, 2, 2, 2);	
+
+	_dragInfo.dragImage = QImage{FILE_ICON_BLOCK}.scaled(32, 32, Qt::KeepAspectRatio);
+
+	createBlockList();
+
+	// Listen for pipeline switching in order to update the button states
+	connect(&grinder()->pipelineController(), &PipelineController::pipelineSwitched, this, &GraphBlockList::pipelineSwitched);
+}
+
+bool GraphBlockList::eventFilter(QObject* obj, QEvent* event)
+{
+	auto button = dynamic_cast<QToolButton*>(obj);
+
+	if (button && button->isEnabled() && event->type() == QEvent::MouseButtonPress)	// Got a click on a type button
+	{
+		QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event);
+
+		if (mouseEvent->buttons().testFlag(Qt::LeftButton))
+		{
+			_dragInfo.dragInitiated = true;
+			_dragInfo.mousePressPos = mouseEvent->pos();
+			_dragInfo.blockType = button->property(BLOCKTYPEBUTTON_TYPE_PROPERTY).toString();
+		}
+	}
+
+	return QFrame::eventFilter(obj, event);
+}
+
+void GraphBlockList::mouseMoveEvent(QMouseEvent* event)
+{
+	// If a drag has been initiated and the mouse has been moved far enough, start a drag
+	if (_dragInfo.dragInitiated)
+	{
+		if (event->buttons().testFlag(Qt::LeftButton))
+		{
+			if ((event->pos() - _dragInfo.mousePressPos).manhattanLength() >= QApplication::startDragDistance())
+			{
+				startBlockTypeDrag();
+				_dragInfo.dragInitiated = false;
+			}
+		}
+		else
+			_dragInfo.dragInitiated = false;
+	}
+
+	QFrame::mouseMoveEvent(event);
+}
+
+void GraphBlockList::createBlockList()
+{	
+	// Add block categories and their associated types
+	for (auto category : BlockCatalog::getCategories())
+		createBlockCategoryEntry(category);
+
+	_layout->addStretch();
+
+	updateBlockTypeButtonStates(false);
+}
+
+void GraphBlockList::createBlockCategoryEntry(BlockCategory category)
+{
+	auto catLabel = new QLabel{category};
+	catLabel->setAlignment(Qt::AlignHCenter);
+	catLabel->setFont(GrinderApplication::boldFont(catLabel));
+
+	// Take fill & border color from the graph style and apply it using CSS
+	GraphStyle graphStyle;
+	QColor borderColor = graphStyle.getBlockNodeStyle().getBlockCategoryColor(category);
+	QColor fillColor = borderColor;
+
+	fillColor.setHsl(fillColor.hslHue(), fillColor.hslSaturation(), 255 - (255 - fillColor.lightness()) * 0.4);
+
+	QString css = QString{"QLabel { border: 1px solid %1; background-color: %2; }"}.arg(borderColor.name()).arg(fillColor.name());
+	catLabel->setStyleSheet(css);
+
+	_layout->addWidget(catLabel);
+
+	// Create all types under this category
+	for (auto type : BlockCatalog::getTypes(category))
+		createBlockTypeEntry(type);
+}
+
+void GraphBlockList::createBlockTypeEntry(BlockType type)
+{
+	auto typeButton = new QToolButton{};
+
+	// Get the block's description
+	QString description = BlockCatalog::getDescription(type);
+
+	if (description.isEmpty())
+		description = "No description available";
+
+	typeButton->setText(type);
+	typeButton->setToolTip(description);
+	typeButton->setStatusTip(QString{"Create a new %1 block"}.arg(type));
+	typeButton->setIcon(QIcon{FILE_ICON_BLOCK});
+	typeButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+	typeButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred);
+	typeButton->setAutoRaise(true);
+	typeButton->setProperty(BLOCKTYPEBUTTON_TYPE_PROPERTY, type);
+
+	typeButton->connect(typeButton, SIGNAL(clicked(bool)), this, SLOT(blockTypeButtonPressed()));
+	typeButton->installEventFilter(this);	// Need to capture mouse events of the button
+
+	_layout->addWidget(typeButton);
+}
+
+void GraphBlockList::blockTypeButtonPressed()
+{
+	BlockType type = sender()->property(BLOCKTYPEBUTTON_TYPE_PROPERTY).toString();
+
+	if (type != BlockType::Undefined)
+	{
+		if (auto pipeline = grinder()->pipelineController().activePipeline())
+		{
+			QString newBlockName = StringUtils::generateUniqueItemName(pipeline->blocks(), type, &Block::getName);
+			auto block = grinder()->pipelineController().createBlock(type, newBlockName);
+
+			if (auto scene = grinder()->pipelineController().activeScene())
+			{
+				// Find the just-created block node and center on it
+				auto blockNode = scene->findBlockNode(block.get());
+
+				if (blockNode)
+				{
+					scene->view()->centerOn(blockNode);
+
+					scene->clearSelection();
+					blockNode->setSelected(true);
+					blockNode->setFocus();
+				}
+			}
+		}
+	}
+}
+
+void GraphBlockList::updateBlockTypeButtonStates(bool enable) const
+{
+	for (auto button : findChildren<QToolButton*>())
+		button->setEnabled(enable);
+}
+
+void GraphBlockList::startBlockTypeDrag()
+{
+	// Set the block type as the mime data
+	QMimeData* mimeData = new QMimeData{};
+	mimeData->setData(GRAPHBLOCKLIST_DRAG_MIMETYPE, _dragInfo.blockType.toLatin1());
+
+	QDrag* drag = new QDrag{this};
+	drag->setMimeData(mimeData);
+	drag->setPixmap(QPixmap::fromImage(_dragInfo.dragImage));
+
+	drag->exec(Qt::CopyAction, Qt::CopyAction);
+}
diff --git a/Grinder/ui/graph/GraphBlockList.h b/Grinder/ui/graph/GraphBlockList.h
new file mode 100644
index 0000000000000000000000000000000000000000..a1b5f168405a697d2bdb7810a2e37dec6ba0b311
--- /dev/null
+++ b/Grinder/ui/graph/GraphBlockList.h
@@ -0,0 +1,64 @@
+/******************************************************************************
+ * File: GraphBlockList.h
+ * Date: 03.2.2018
+ *****************************************************************************/
+
+#ifndef GRAPHBLOCKLIST_H
+#define GRAPHBLOCKLIST_H
+
+#include <QFrame>
+#include <QLabel>
+#include <QLayout>
+
+#include "pipeline/BlockCategory.h"
+#include "pipeline/BlockType.h"
+
+#define GRAPHBLOCKLIST_DRAG_MIMETYPE	"application/x-blocktype"
+
+namespace grndr
+{
+	class PipelineManager;
+	class Pipeline;
+
+	class GraphBlockList : public QFrame
+	{
+		Q_OBJECT
+
+	public:
+		GraphBlockList(QWidget* parent = nullptr);
+
+	protected:
+		virtual bool eventFilter(QObject *obj, QEvent* event) override;
+
+		virtual void mouseMoveEvent(QMouseEvent *event) override;
+
+	private:
+		void createBlockList();
+		void createBlockCategoryEntry(BlockCategory category);
+		void createBlockTypeEntry(BlockType type);
+
+	private slots:
+		void pipelineSwitched(Pipeline* pipeline) const { updateBlockTypeButtonStates(pipeline != nullptr); }
+
+		void blockTypeButtonPressed();
+
+	private:
+		void updateBlockTypeButtonStates(bool enable) const;
+
+		void startBlockTypeDrag();
+
+	private:
+		QVBoxLayout* _layout{nullptr};
+
+		struct
+		{
+			bool dragInitiated{false};
+			QPoint mousePressPos;
+			BlockType blockType{BlockType::Undefined};
+
+			QImage dragImage;
+		} _dragInfo;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphBlockNode.cpp b/Grinder/ui/graph/GraphBlockNode.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..207defbab22c653fcfba7e397c5dd1fb4d59cbb0
--- /dev/null
+++ b/Grinder/ui/graph/GraphBlockNode.cpp
@@ -0,0 +1,460 @@
+/******************************************************************************
+ * File: GraphBlockNode.cpp
+ * Date: 22.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphBlockNode.h"
+#include "GraphStyle.h"
+#include "GraphScene.h"
+#include "GraphPortNode.h"
+#include "pipeline/Block.h"
+#include "pipeline/BlockCatalog.h"
+#include "core/GrinderApplication.h"
+#include "controller/PipelineController.h"
+#include "res/Resources.h"
+
+#include <QFontMetricsF>
+
+GraphBlockNode::GraphBlockNode(grndr::GraphScene* scene, const std::shared_ptr<Block>& block, QGraphicsItem* parent) : GraphNode(scene, parent),
+	_block{block}, _blockStyle{_style.getBlockNodeStyle()}, _nameEdit{new QLineEdit{}}
+{
+	if (!block)
+		throw std::invalid_argument{_EXCPT("block may not be null")};
+
+	setFlag(ItemIsMovable);
+
+	// Add all ports (in & out) as node children; must be created before updating the geometry
+	createPorts();
+
+	updateGeometry();	
+
+	_nameEdit->setStyleSheet("QLineEdit { background: transparent; border: none; }");
+	_nameEdit->setAlignment(Qt::AlignCenter);
+	_nameEdit->setFont(_blockStyle.nameFont);
+	_nameEdit->setContextMenuPolicy(Qt::NoContextMenu);
+	_nameEdit->setVisible(false);
+
+	_nameEditWidget = new QGraphicsProxyWidget{this};
+	_nameEditWidget->setWidget(_nameEdit);	
+
+	// Create node actions
+	_renameAction = createNodeAction("&Rename block", FILE_ICON_EDIT, SLOT(beginRenameBlock()), "Rename the current block", "F2");
+	_deleteAction->setText("&Delete block");
+
+	// Set the tooltip to the block's description
+	QString description = BlockCatalog::getDescription(block->getType());
+	setToolTip(!description.isEmpty() ? description : "No description available");
+}
+
+void GraphBlockNode::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
+{
+	Q_UNUSED(option);
+	Q_UNUSED(widget);
+
+	painter->setRenderHints(QPainter::Antialiasing|QPainter::TextAntialiasing|QPainter::HighQualityAntialiasing|QPainter::SmoothPixmapTransform);
+
+	// Draw the node background (including outer glow if selected)
+	if (isSelected())
+	{
+		bool inactive = !_scene->hasFocus();
+
+		painter->save();
+		painter->setPen(QPen{Qt::NoPen});
+		painter->setBrush(inactive ? _blockStyle.selectionColorInactive : _blockStyle.selectionColor);
+		painter->setOpacity(_blockStyle.selectionOpacity);
+		painter->drawRoundedRect(_nodeRectSelected, _blockStyle.borderRadius, _blockStyle.borderRadius);
+		painter->restore();
+	}
+
+	painter->fillRect(_nodeRect, _blockStyle.lightBackgroundColor);
+
+	// Draw node contents
+	if (auto block = _block.lock())	// Make sure that the underlying block still exists
+		drawHeader(painter, block.get());
+
+	// Draw node borders
+	auto borderWidth = _blockStyle.borderWidth;
+	auto borderWidthHalf = borderWidth * 0.5;
+
+	QPen pen{_blockStyle.borderColor, borderWidth};
+	pen.setCapStyle(Qt::RoundCap);
+	pen.setJoinStyle(Qt::RoundJoin);
+
+	painter->setPen(pen);
+	painter->drawRoundedRect(_nodeRect, _blockStyle.borderRadius, _blockStyle.borderRadius);
+
+	qreal y = _nodeRect.top() + _geometry.headerRect.height() - borderWidthHalf;
+	painter->drawLine(_nodeRect.left() + borderWidthHalf, y, _nodeRect.right() - borderWidthHalf, y);
+}
+
+void GraphBlockNode::beginRenameBlock()
+{
+	if (_isEditingName)
+		return;
+
+	if (auto block = _block.lock())	// Make sure that the underlying block still exists
+	{
+		// Show in-place edit field
+		connect(_nameEdit, &QLineEdit::editingFinished, this, &GraphBlockNode::endRenameBlock);
+
+		auto headerRect = _nodeRect;
+		headerRect.setHeight(_geometry.headerNameSize.height());
+
+		_nameEditWidget->setGeometry(headerRect);
+		_nameEditWidget->setVisible(true);
+
+		_nameEdit->setText(block->getName());
+		_nameEdit->selectAll();
+		_nameEdit->setFocus();
+
+		_isEditingName = true;
+
+		update();
+	}
+}
+
+void GraphBlockNode::endRenameBlock()
+{
+	if (!_isEditingName)
+		return;
+
+	disconnect(_nameEdit, nullptr, this, nullptr);
+
+	if (auto block = _block.lock())	// Make sure that the underlying block still exists
+	{
+		// Hide in-place edit field and commit new name
+		auto name = _nameEdit->text().trimmed();
+		_nameEditWidget->setVisible(false);
+
+		if (!name.isEmpty())
+		{
+			grinder()->pipelineController().renameBlock(block.get(), name);
+
+			prepareGeometryChange();
+			updateGeometry();
+		}
+	}
+
+	_isEditingName = false;
+
+	update();
+	setFocus();
+}
+
+void GraphBlockNode::cancelRenameBlock()
+{
+	if (!_isEditingName)
+		return;
+
+	// Hide in-place edit field w/o committing new name
+	disconnect(_nameEdit, nullptr, this, nullptr);
+
+	_nameEditWidget->setVisible(false);
+
+	_isEditingName = false;
+
+	update();
+	setFocus();
+}
+
+void GraphBlockNode::keyReleaseEvent(QKeyEvent* event)
+{
+	if (event->modifiers() == Qt::NoModifier)
+	{
+		if (event->key() == Qt::Key_F2 && !_isEditingName)	// Begin rename
+		{
+			beginRenameBlock();
+			return;
+		}
+		else if (event->key() == Qt::Key_Escape)
+		{
+			if (_isEditingName)	// Cancel name edit
+				cancelRenameBlock();
+			else if (_connectionBlueprint)	// Cancel connection initiation
+				endConnectionInitiation(false);
+
+			return;
+		}
+	}
+
+	GraphNode::keyReleaseEvent(event);
+}
+
+void GraphBlockNode::mousePressEvent(QGraphicsSceneMouseEvent* event)
+{
+	if (event->button() == Qt::LeftButton)
+	{
+		if (!_connectionBlueprint)	// Check if a new connection initiation must be started
+		{
+			// See if a port rect has been clicked; if so, initiate a new connection
+			if (auto portNode = findPortByPosition(event->pos()))
+				beginConnectionInitiation(portNode);
+
+			updateConnectionInitiation(event->scenePos());
+		}
+	}
+
+	if (!_connectionBlueprint)
+		GraphNode::mousePressEvent(event);
+}
+
+void GraphBlockNode::mouseMoveEvent(QGraphicsSceneMouseEvent* event)
+{
+	if (_connectionBlueprint)
+		updateConnectionInitiation(event->scenePos());
+	else
+		GraphNode::mouseMoveEvent(event);
+}
+
+void GraphBlockNode::mouseReleaseEvent(QGraphicsSceneMouseEvent* event)
+{
+	// Mouse-button has been released after a drag operation, so end any connection initiation
+	if (_connectionBlueprint)
+		endConnectionInitiation();
+
+	GraphNode::mouseReleaseEvent(event);
+}
+
+void GraphBlockNode::mouseDoubleClickEvent(QGraphicsSceneMouseEvent* event)
+{
+	// If the header is double-click, initiate rename
+	if (_geometry.headerRect.contains(event->pos()))
+		beginRenameBlock();
+}
+
+void GraphBlockNode::updateGeometry()
+{
+	if (auto block = _block.lock())	// Make sure that the underlying block still exists
+	{
+		_geometry.headerNameSize = calcHeaderNameSize(block.get());
+		_geometry.headerTypeSize = calcHeaderTypeSize(block.get());
+		_geometry.inPortsSize = calcPortsSize(_inPortNodes);
+		_geometry.outPortsSize = calcPortsSize(_outPortNodes);
+
+		auto totalWidth = std::max({_geometry.headerNameSize.width(), _geometry.headerTypeSize.width(), _geometry.inPortsSize.width() + _geometry.outPortsSize.width()});
+		auto totalHeight = _geometry.headerNameSize.height() + _geometry.headerTypeSize.height() + std::max(_geometry.inPortsSize.height(), _geometry.outPortsSize.height());
+
+		auto minSize = _blockStyle.minimumSize;
+		_nodeRect = QRectF{0, 0, std::max(totalWidth, minSize.width()), std::max(totalHeight, minSize.height())};
+		_nodeRectSelected = _nodeRect + QMarginsF{_blockStyle.selectionMargin, _blockStyle.selectionMargin, _blockStyle.selectionMargin, _blockStyle.selectionMargin};
+
+		_geometry.headerRect.setRect(_nodeRect.left(), _nodeRect.top(), _nodeRect.width(), _geometry.headerNameSize.height() + _geometry.headerTypeSize.height());
+		_geometry.portsRect.setRect(_nodeRect.left(), _geometry.headerRect.bottom(), _nodeRect.width(), std::max(_geometry.inPortsSize.height(), _geometry.outPortsSize.height()));
+	}
+
+	// The geometry has been updated to make enough room for all ports, so bring them into layout
+	layoutPorts(_inPortNodes, false);
+	layoutPorts(_outPortNodes, true);
+
+	GraphNode::updateGeometry();
+}
+
+std::vector<QAction*> GraphBlockNode::getNodeActions(QMenu& menu) const
+{
+	std::vector<QAction*> actions = GraphNode::getNodeActions(menu);
+	actions.insert(actions.begin(), _renameAction);
+	return actions;
+}
+
+QSizeF GraphBlockNode::calcHeaderNameSize(const Block* block) const
+{
+	auto marginsX = _blockStyle.textMargins.left() + _blockStyle.textMargins.right();
+	auto marginsY = _blockStyle.textMargins.top() + _blockStyle.textMargins.bottom();
+
+	QFontMetricsF fontNameMetrics{_blockStyle.nameFont};
+	auto nameWidth = fontNameMetrics.width(block->getName()) + marginsX;
+	auto nameHeight = fontNameMetrics.height() + marginsY;
+
+	return QSizeF{nameWidth, nameHeight};
+}
+
+QSizeF GraphBlockNode::calcHeaderTypeSize(const Block* block) const
+{
+	auto marginsX = _blockStyle.textMargins.left() + _blockStyle.textMargins.right();
+	auto marginsY = _blockStyle.textMargins.top() + _blockStyle.textMargins.bottom();
+
+	QFontMetricsF fontTypeMetrics{_blockStyle.typeFont};
+	auto typeWidth = fontTypeMetrics.width(block->getType()) + marginsX;
+	auto typeHeight = fontTypeMetrics.height() + marginsY;
+
+	return QSizeF{typeWidth, typeHeight};
+}
+
+QSizeF GraphBlockNode::calcPortsSize(const std::vector<GraphPortNode*>& ports) const
+{
+	QSizeF size{0.0, 0.0};
+
+	for (auto portNode : ports)
+	{
+		auto rect = portNode->boundingRect() + _blockStyle.portMargins;
+
+		size.setWidth(std::max(size.width(), rect.width()));
+		size.setHeight(size.height() + rect.height());
+	}
+
+	return size;
+}
+
+void GraphBlockNode::drawHeader(QPainter* painter, const Block* block)
+{
+	painter->setPen(_blockStyle.textColor);
+
+	auto rect = _geometry.headerRect;
+
+	// Draw block name
+	rect.setBottom(rect.top() + _geometry.headerNameSize.height());
+
+	QLinearGradient grad{0.0, rect.top(), 0.0, rect.bottom()};
+	grad.setColorAt(0.0, _blockStyle.getBlockCategoryColor(block->getCategory()));
+	grad.setColorAt(1.0, _blockStyle.lightBackgroundColor);
+
+	painter->fillRect(rect, QBrush{grad});
+
+	if (!_isEditingName)
+	{
+		painter->setFont(_blockStyle.nameFont);
+		painter->drawText(rect, Qt::AlignHCenter|Qt::AlignVCenter, block->getName());
+	}
+
+	// Draw block type
+	rect.setTop(rect.bottom());
+	rect.setBottom(rect.top() + _geometry.headerTypeSize.height());
+
+	painter->setFont(_blockStyle.typeFont);
+	painter->drawText(rect, Qt::AlignHCenter|Qt::AlignTop, block->getType());
+}
+
+void GraphBlockNode::createPorts()
+{
+	if (auto block = _block.lock())	// Make sure that the underlying block still exists
+	{
+		// Add incoming ports
+		auto inPorts = block->ports().selectByDirection(Port::Direction::In);
+
+		for (const auto& port : inPorts)
+		{
+			GraphPortNode* portNode = new GraphPortNode{this, _scene, port, this};
+			_inPortNodes.push_back(portNode);
+		}
+
+		// Add outgoing ports
+		auto outPorts = block->ports().selectByDirection(Port::Direction::Out);
+
+		for (const auto& port : outPorts)
+		{
+			GraphPortNode* portNode = new GraphPortNode{this, _scene, port, this};
+			_outPortNodes.push_back(portNode);
+		}
+	}
+}
+
+void GraphBlockNode::layoutPorts(const std::vector<grndr::GraphPortNode*>& ports, bool alignRight)
+{
+	QPointF pos{alignRight ? _geometry.portsRect.topRight() : _geometry.portsRect.topLeft()};
+
+	// Move all ports into their proper place; in-ports on the left, out-ports on the right
+	for (const auto& portNode : ports)
+	{
+		auto rect = portNode->boundingRect();
+		auto x = alignRight ? pos.x() - rect.width() - _blockStyle.portMargins.right() : pos.x() + _blockStyle.portMargins.left();
+		auto y = pos.y() + _blockStyle.portMargins.top();
+
+		portNode->setPos(x, y);
+		pos.setY(pos.y() + rect.height() + _blockStyle.portMargins.top() + _blockStyle.portMargins.bottom());
+	}
+}
+
+GraphPortNode* GraphBlockNode::findPortByPosition(QPointF pos)
+{
+	// See if there is a port at position pos
+	auto findPort = [pos](const auto& nodes) -> GraphPortNode* {
+		for (const auto& node : nodes)
+		{
+			auto rect = node->mapRectToParent(node->getPortRect());
+
+			if (rect.contains(pos))
+				return node;
+		}
+
+		return nullptr;
+	};
+
+	auto port = findPort(_inPortNodes);
+
+	if (!port)
+		port = findPort(_outPortNodes);
+
+	return port;
+}
+
+void GraphBlockNode::beginConnectionInitiation(GraphPortNode* portNode)
+{
+	if (!_connectionBlueprint)
+	{
+		_connectionBlueprint = std::make_unique<GraphConnectionBlueprint>(_scene, portNode);
+
+		_scene->addItem(_connectionBlueprint.get());
+		_scene->update();
+	}
+}
+
+void GraphBlockNode::updateConnectionInitiation(QPointF mousePos)
+{
+	if (_connectionBlueprint)
+	{
+		_connectionBlueprint->setTargetPos(mousePos);
+
+		// See if there is a block node under the cursor
+		bool lockOnPort = false;
+
+		for (auto item : _scene->items(mousePos))
+		{
+			if (auto blockNode = dynamic_cast<GraphBlockNode*>(item))
+			{
+				if (blockNode)
+				{
+					// We're above a block, so let's see if we are also above a port
+					if (auto portNode = blockNode->findPortByPosition(blockNode->mapFromScene(mousePos)))
+					{
+						_connectionBlueprint->setTargetPortNode(portNode);
+
+						lockOnPort = true;
+						break;
+					}
+				}
+			}
+		}
+
+		if (!lockOnPort)
+			_connectionBlueprint->setTargetPortNode(nullptr);
+
+		_scene->update();
+	}
+}
+
+void GraphBlockNode::endConnectionInitiation(bool createConnection)
+{
+	if (_connectionBlueprint)
+	{
+		if (createConnection)
+		{
+			auto sourcePort = _connectionBlueprint->connectionPort(Port::Direction::Out);
+			auto destPort = _connectionBlueprint->connectionPort(Port::Direction::In);
+
+			try {
+				grinder()->pipelineController().validateConnection(sourcePort, destPort);
+
+				// We're fine, the connection is valid
+				grinder()->pipelineController().createConnection(sourcePort, destPort);
+			} catch (...) {
+				// Ignore any exceptions (simply don't add the invalid connection)
+			}
+		}
+
+		_scene->removeItem(_connectionBlueprint.get());
+		_scene->update();
+
+		_connectionBlueprint.reset(nullptr);
+	}
+}
diff --git a/Grinder/ui/graph/GraphBlockNode.h b/Grinder/ui/graph/GraphBlockNode.h
new file mode 100644
index 0000000000000000000000000000000000000000..8d6e0d61f9ea7f6b989050447e8ee5db99ad7f02
--- /dev/null
+++ b/Grinder/ui/graph/GraphBlockNode.h
@@ -0,0 +1,109 @@
+/******************************************************************************
+ * File: GraphBlockNode.h
+ * Date: 22.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHBLOCKNODE_H
+#define GRAPHBLOCKNODE_H
+
+#include <memory>
+#include <QLineEdit>
+#include <QGraphicsProxyWidget>
+
+#include "GraphNode.h"
+#include "GraphStyle.h"
+#include "GraphConnectionBlueprint.h"
+
+namespace grndr
+{
+	class Block;
+	class GraphPortNode;
+	class GraphConnectionBlueprint;
+
+	class GraphBlockNode : public GraphNode
+	{
+		Q_OBJECT
+
+		friend class GraphLayout;
+
+	public:
+		GraphBlockNode(GraphScene* scene, const std::shared_ptr<Block>& block, QGraphicsItem* parent = nullptr);
+
+	public:		
+		std::weak_ptr<Block>& block() { return _block; }
+		const std::weak_ptr<Block>& block() const { return _block; }
+
+		const std::vector<GraphPortNode*>& inPortNodes() const { return _inPortNodes; }
+		const std::vector<GraphPortNode*>& outPortNodes() const { return _outPortNodes; }
+
+	public:
+		virtual void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override;
+
+	public slots:
+		void beginRenameBlock();
+		void endRenameBlock();
+		void cancelRenameBlock();
+
+	protected:
+		virtual void keyReleaseEvent(QKeyEvent* event) override;
+
+		virtual void mousePressEvent(QGraphicsSceneMouseEvent* event) override;
+		virtual void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override;
+		virtual void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override;
+		virtual void mouseDoubleClickEvent(QGraphicsSceneMouseEvent* event) override;
+
+	protected:
+		virtual void updateGeometry() override;
+
+		virtual std::vector<QAction*> getNodeActions(QMenu& menu) const override;
+
+	private:
+		QSizeF calcHeaderNameSize(const Block* block) const;
+		QSizeF calcHeaderTypeSize(const Block* block) const;
+		QSizeF calcPortsSize(const std::vector<GraphPortNode*>& ports) const;
+
+		struct
+		{
+			QRectF headerRect;
+			QSizeF headerNameSize;
+			QSizeF headerTypeSize;
+
+			QRectF portsRect;
+			QSizeF inPortsSize;
+			QSizeF outPortsSize;
+		} _geometry;
+
+	private:
+		void drawHeader(QPainter* painter, const Block* block);
+
+	private:
+		void createPorts();
+		void layoutPorts(const std::vector<GraphPortNode*>& ports, bool alignRight);
+
+		GraphPortNode* findPortByPosition(QPointF pos);
+
+	protected:
+		std::weak_ptr<Block> _block;
+
+		const GraphStyle::BlockNodeStyle& _blockStyle;
+
+		std::vector<GraphPortNode*> _inPortNodes;
+		std::vector<GraphPortNode*> _outPortNodes;
+
+	private:
+		QLineEdit* _nameEdit{nullptr};
+		QGraphicsProxyWidget* _nameEditWidget{nullptr};
+		bool _isEditingName{false};
+
+		QAction* _renameAction;
+
+	private:
+		void beginConnectionInitiation(GraphPortNode* portNode);
+		void updateConnectionInitiation(QPointF mousePos);
+		void endConnectionInitiation(bool createConnection = true);
+
+		std::unique_ptr<GraphConnectionBlueprint> _connectionBlueprint;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphConnectionBase.cpp b/Grinder/ui/graph/GraphConnectionBase.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..1380a4d8969aa9e7464af96dabae5c2096eae373
--- /dev/null
+++ b/Grinder/ui/graph/GraphConnectionBase.cpp
@@ -0,0 +1,84 @@
+/******************************************************************************
+ * File: GraphConnectionBase.cpp
+ * Date: 27.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphConnectionBase.h"
+
+GraphConnectionBase::GraphConnectionBase(GraphScene* scene, QGraphicsItem* parent) : GraphNode(scene, parent),
+	_connectionStyle{_style.getConnectionNodeStyle()}
+{
+
+}
+
+QPainterPath GraphConnectionBase::shape() const
+{
+	// Create a shape that is slightly thicker than the shown connection line
+	QPen pen;
+	pen.setJoinStyle(Qt::RoundJoin);
+	pen.setWidthF(_connectionStyle.lineWidth + _connectionStyle.selectionMargin);
+
+	QPainterPathStroker ps{pen};
+	auto path = ps.createStroke(_geometry.connectionPath);
+	path.addPath(_geometry.connectionPath);
+
+	return path;
+}
+
+QRectF GraphConnectionBase::boundingRect() const
+{
+	return _geometry.connectionPath.controlPointRect();
+}
+
+void GraphConnectionBase::drawConnection(QPainter* painter, QColor sourceColor, QColor destColor)
+{
+	// Draw path using a gradient (source color -> dest color)
+	QLinearGradient gradient(_geometry.sourcePosition, _geometry.destPosition);
+	gradient.setColorAt(0.0, sourceColor);
+	gradient.setColorAt(1.0, destColor);
+
+	QPen connectionPen{QBrush{gradient}, _connectionStyle.lineWidth};
+	connectionPen.setCapStyle(Qt::SquareCap);
+	connectionPen.setJoinStyle(Qt::RoundJoin);
+
+	painter->setPen(connectionPen);
+	painter->drawPath(_geometry.connectionPath);
+}
+
+void GraphConnectionBase::updateGeometry()
+{
+	// Calculate the scene-relative positions of the in & out ports
+	_geometry.sourcePosition = calcSourcePosition();
+	_geometry.destPosition = calcDestPosition();
+
+	// Update the connection path
+	QPainterPath path{_geometry.sourcePosition};
+
+	// Source lies right of dest, so draw two curves
+	if (_geometry.sourcePosition.x() > _geometry.destPosition.x())
+	{
+		auto yDiff = std::abs(_geometry.destPosition.y() - _geometry.sourcePosition.y());
+		auto middleY = (_geometry.sourcePosition.y() + _geometry.destPosition.y()) / 2.0;
+
+		auto curve1End = QPointF{_geometry.sourcePosition.x(), middleY};
+		auto curve1CP1 = QPointF{_geometry.sourcePosition.x() + yDiff / 2.0, _geometry.sourcePosition.y()};
+		auto curve1CP2 = QPointF{_geometry.sourcePosition.x() + yDiff / 2.0, middleY};
+
+		auto curve2Start = QPointF{_geometry.destPosition.x(), middleY};
+		auto curve2CP1 = QPointF{_geometry.destPosition.x() - yDiff / 2.0, middleY};
+		auto curve2CP2 = QPointF{_geometry.destPosition.x() - yDiff / 2.0, _geometry.destPosition.y()};
+
+		path.cubicTo(curve1CP1, curve1CP2, curve1End);
+		path.lineTo(curve2Start);
+		path.cubicTo(curve2CP1, curve2CP2, _geometry.destPosition);
+	}
+	else	// Otherwise, draw a cubic line from source to dest
+		path.cubicTo((_geometry.sourcePosition.x() + _geometry.destPosition.x()) / 2.0, _geometry.sourcePosition.y(),
+					 (_geometry.sourcePosition.x() + _geometry.destPosition.x()) / 2.0, _geometry.destPosition.y(),
+					 _geometry.destPosition.x(), _geometry.destPosition.y());
+
+	_geometry.connectionPath = path;
+
+	GraphNode::updateGeometry();
+}
diff --git a/Grinder/ui/graph/GraphConnectionBase.h b/Grinder/ui/graph/GraphConnectionBase.h
new file mode 100644
index 0000000000000000000000000000000000000000..549ae22c5519dddc3a56e43f52f801486cb5be78
--- /dev/null
+++ b/Grinder/ui/graph/GraphConnectionBase.h
@@ -0,0 +1,47 @@
+/******************************************************************************
+ * File: GraphConnectionBase.h
+ * Date: 27.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHCONNECTIONBASE_H
+#define GRAPHCONNECTIONBASE_H
+
+#include "GraphNode.h"
+#include "GraphStyle.h"
+
+namespace grndr
+{
+	class GraphConnectionBase : public GraphNode
+	{
+		Q_OBJECT
+
+	public:
+		GraphConnectionBase(GraphScene* scene, QGraphicsItem* parent = nullptr);
+
+	public:
+		virtual QPainterPath shape() const override;
+		virtual QRectF boundingRect() const override;
+
+	protected:
+		void drawConnection(QPainter* painter, QColor sourceColor, QColor destColor);
+
+	protected:
+		virtual void updateGeometry() override;
+
+		virtual QPointF calcSourcePosition() const = 0;
+		virtual QPointF calcDestPosition() const = 0;
+
+		struct
+		{
+			QPointF sourcePosition;
+			QPointF destPosition;
+
+			QPainterPath connectionPath;
+		} _geometry;
+
+	protected:
+		const GraphStyle::ConnectionNodeStyle& _connectionStyle;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphConnectionBlueprint.cpp b/Grinder/ui/graph/GraphConnectionBlueprint.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c68508e691556ac5109934e8c1e89e8ba07a6684
--- /dev/null
+++ b/Grinder/ui/graph/GraphConnectionBlueprint.cpp
@@ -0,0 +1,150 @@
+/******************************************************************************
+ * File: GraphConnectionBlueprint.cpp
+ * Date: 27.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphConnectionBlueprint.h"
+#include "GraphBlockNode.h"
+#include "GraphPortNode.h"
+#include "GraphScene.h"
+#include "core/GrinderApplication.h"
+#include "controller/PipelineController.h"
+
+GraphConnectionBlueprint::GraphConnectionBlueprint(GraphScene* scene, grndr::GraphPortNode* startPortNode, QGraphicsItem* parent) : GraphConnectionBase(scene, parent),
+	_startPortNode{startPortNode}
+{
+	if (!startPortNode)
+		throw std::invalid_argument{_EXCPT("startPortNode may not be null")};
+
+	setFlag(ItemIsFocusable, false);
+	setFlag(ItemIsSelectable, false);
+	setFlag(ItemSendsGeometryChanges, false);
+
+	// Make sure that the blueprint is always drawn on top
+	setZValue(5.0f);
+
+	// Create a hidden text node for showing the rejection reason
+	_statusMessageNode = new GraphConnectionMessage{_scene, this};
+	_statusMessageNode->setZValue(zValue() + 1.0);
+	_statusMessageNode->setVisible(false);
+}
+
+void GraphConnectionBlueprint::setTargetPortNode(GraphPortNode* node)
+{
+	_targetPortNode = node;
+
+	if (_targetPortNode && _targetPortNode != _startPortNode)	// A (potential) connection has been established
+	{
+		validateConnection();
+	}
+	else
+	{
+		_status = Status::Pending;
+		_statusMessageNode->setVisible(false);
+	}
+
+	prepareGeometryChange();
+	updateGeometry();
+}
+
+void GraphConnectionBlueprint::setTargetPos(QPointF pos)
+{
+	_targetPos = pos;
+
+	prepareGeometryChange();
+	updateGeometry();
+}
+
+Port* GraphConnectionBlueprint::_connectionPort(Port::Direction dir) const
+{
+	// Find the connection port that matches the given direction
+	auto startPort = _startPortNode ? _startPortNode->port().lock() : nullptr;
+	auto targetPort = _targetPortNode ? _targetPortNode->port().lock() : nullptr;
+
+	if (startPort && startPort->getDirection() == dir)
+		return startPort.get();
+	else if (targetPort && targetPort->getDirection() == dir)
+		return targetPort.get();
+	else
+		return nullptr;
+}
+
+void GraphConnectionBlueprint::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
+{
+	Q_UNUSED(option);
+	Q_UNUSED(widget);
+
+	painter->setRenderHints(QPainter::Antialiasing|QPainter::TextAntialiasing|QPainter::HighQualityAntialiasing|QPainter::SmoothPixmapTransform);
+	painter->setOpacity(_connectionStyle.blueprintOpacity);
+
+	QColor color;
+
+	switch (_status)
+	{
+	case Status::Valid:
+		color = _connectionStyle.blueprintValidColor;
+		break;
+
+	case Status::Invalid:
+		color = _connectionStyle.blueprintInvalidColor;
+		break;
+
+	default:
+		color = _connectionStyle.blueprintPendingColor;
+	}
+
+	drawConnection(painter, color, color);
+}
+
+QPointF GraphConnectionBlueprint::calcSourcePosition() const
+{
+	auto portNode = _targetPortNode;
+	auto startPort = _startPortNode->port().lock();
+
+	if (startPort && startPort->isOut())
+		portNode = _startPortNode;
+
+	if (portNode)
+		return portNode->blockNode()->mapToScene(portNode->getCenterPos(false));
+	else
+		return _targetPos;
+}
+
+QPointF GraphConnectionBlueprint::calcDestPosition() const
+{
+	auto portNode = _targetPortNode;
+	auto startPort = _startPortNode->port().lock();
+
+	if (startPort && startPort->isIn())
+		portNode = _startPortNode;
+
+	if (portNode)
+		return portNode->blockNode()->mapToScene(portNode->getCenterPos(false));
+	else
+		return _targetPos;
+}
+
+void GraphConnectionBlueprint::validateConnection()
+{
+	_status = Status::Invalid;
+	_statusMessage = "Unknown rejection reason";
+
+	auto sourcePort = connectionPort(Port::Direction::Out);
+	auto destPort = connectionPort(Port::Direction::In);
+
+	try {
+		grinder()->pipelineController().validateConnection(sourcePort, destPort);
+
+		// We're fine, the connection is valid
+		_status = Status::Valid;
+		_statusMessage = "";
+	} catch (std::exception& e) {
+		_statusMessage = GetExceptionMessage(e.what());
+	}
+
+	// Update the status message node
+	_statusMessageNode->setVisible(_status == Status::Invalid);
+	_statusMessageNode->setText(_statusMessage);
+	_statusMessageNode->setPos(boundingRect().center() - _statusMessageNode->boundingRect().center());
+}
diff --git a/Grinder/ui/graph/GraphConnectionBlueprint.h b/Grinder/ui/graph/GraphConnectionBlueprint.h
new file mode 100644
index 0000000000000000000000000000000000000000..ef48f61c2faffad9da1660beb6d2fc3c5ba30aa1
--- /dev/null
+++ b/Grinder/ui/graph/GraphConnectionBlueprint.h
@@ -0,0 +1,67 @@
+/******************************************************************************
+ * File: GraphConnectionBlueprint.h
+ * Date: 27.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHCONNECTIONBLUEPRINT_H
+#define GRAPHCONNECTIONBLUEPRINT_H
+
+#include "GraphConnectionBase.h"
+#include "GraphConnectionMessage.h"
+#include "pipeline/Port.h"
+
+namespace grndr
+{
+	class GraphPortNode;
+
+	class GraphConnectionBlueprint : public GraphConnectionBase
+	{
+	public:
+		enum class Status
+		{
+			Pending,
+			Valid,
+			Invalid,
+		};
+
+	public:
+		GraphConnectionBlueprint(GraphScene* scene, GraphPortNode* startPortNode, QGraphicsItem* parent = nullptr);
+
+	public:
+		void setTargetPortNode(GraphPortNode* node);
+		void setTargetPos(QPointF pos);
+
+		GraphPortNode* startPortNode() { return _startPortNode; }
+		const GraphPortNode* startPortNode() const { return _startPortNode; }
+		GraphPortNode* targetPortNode() { return  _targetPortNode; }
+		const GraphPortNode* targetPortNode() const { return  _targetPortNode; }
+
+		Port* connectionPort(Port::Direction dir) { return _connectionPort(dir); }
+		const Port* connectionPort(Port::Direction dir) const { return _connectionPort(dir); }
+
+	public:
+		virtual void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override;
+
+	protected:
+		virtual QPointF calcSourcePosition() const override;
+		virtual QPointF calcDestPosition() const override;
+
+	private:
+		void validateConnection();
+
+	private:
+		Port* _connectionPort(Port::Direction dir) const;
+
+	private:				
+		GraphPortNode* _startPortNode{nullptr};
+		GraphPortNode* _targetPortNode{nullptr};
+
+		QPointF _targetPos;
+
+		Status _status{Status::Pending};
+		QString _statusMessage{""};
+		GraphConnectionMessage* _statusMessageNode{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphConnectionMessage.cpp b/Grinder/ui/graph/GraphConnectionMessage.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..5623beb48b721cb0d0dab93ac6e661e9c3e4e4a3
--- /dev/null
+++ b/Grinder/ui/graph/GraphConnectionMessage.cpp
@@ -0,0 +1,49 @@
+/******************************************************************************
+ * File: GraphConnectionMessage.cpp
+ * Date: 29.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphConnectionMessage.h"
+
+GraphConnectionMessage::GraphConnectionMessage(grndr::GraphScene* scene, QGraphicsItem* parent) : GraphNode(scene, parent),
+	_connectionStyle{_style.getConnectionMessageStyle()}
+{
+	setFlag(ItemIsFocusable, false);
+	setFlag(ItemIsSelectable, false);
+	setFlag(ItemSendsGeometryChanges, false);
+}
+
+
+void GraphConnectionMessage::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
+{
+	Q_UNUSED(option);
+	Q_UNUSED(widget);
+
+	painter->setRenderHints(QPainter::Antialiasing|QPainter::TextAntialiasing|QPainter::HighQualityAntialiasing|QPainter::SmoothPixmapTransform);
+
+	// Draw the background
+	QColor color = _connectionStyle.blueprintInvalidColor;
+
+	painter->setOpacity(_connectionStyle.messageOpacity);
+
+	painter->save();
+	painter->setPen(QPen{color, 0.0});
+	painter->setBrush(color);
+	painter->drawRoundedRect(_nodeRect, _connectionStyle.borderRadius, _connectionStyle.borderRadius);
+	painter->restore();
+
+	// Draw the text
+	painter->setPen(_connectionStyle.messageColor);
+	painter->setFont(_connectionStyle.messageFont);
+	painter->drawText(_nodeRect, Qt::AlignHCenter|Qt::AlignVCenter, _text);
+}
+
+void GraphConnectionMessage::updateGeometry()
+{
+	QFontMetricsF fontMetrics{_connectionStyle.messageFont};
+	_geometry._textRect = QRectF{0.0, 0.0, fontMetrics.width(_text), fontMetrics.height()};
+
+	_nodeRect = _geometry._textRect + _connectionStyle.messageMargins;
+	_nodeRectSelected = _nodeRect;
+}
diff --git a/Grinder/ui/graph/GraphConnectionMessage.h b/Grinder/ui/graph/GraphConnectionMessage.h
new file mode 100644
index 0000000000000000000000000000000000000000..cddff0aec41f78ad1f54f1eaaefa28c3a3bca541
--- /dev/null
+++ b/Grinder/ui/graph/GraphConnectionMessage.h
@@ -0,0 +1,41 @@
+/******************************************************************************
+ * File: GraphConnectionMessage.h
+ * Date: 29.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHCONNECTIONMESSAGE_H
+#define GRAPHCONNECTIONMESSAGE_H
+
+#include "GraphNode.h"
+#include "GraphStyle.h"
+
+namespace grndr
+{
+	class GraphConnectionMessage : public GraphNode
+	{
+	public:
+		GraphConnectionMessage(GraphScene* scene, QGraphicsItem *parent = nullptr);
+
+	public:
+		QString text() const { return _text; }
+		void setText(QString text) { _text = text; prepareGeometryChange(); updateGeometry(); }
+
+	public:
+		virtual void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override;
+
+	protected:
+		virtual void updateGeometry() override;
+
+		struct
+		{
+			QRectF _textRect;
+		} _geometry;
+
+	private:
+		QString _text;
+
+		const GraphStyle::ConnectionMessageStyle& _connectionStyle;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphConnectionNode.cpp b/Grinder/ui/graph/GraphConnectionNode.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..859e4ed1f1653a2b8b0db7c913bda2013c882313
--- /dev/null
+++ b/Grinder/ui/graph/GraphConnectionNode.cpp
@@ -0,0 +1,143 @@
+/******************************************************************************
+ * File: GraphConnectionNode.cpp
+ * Date: 25.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphConnectionNode.h"
+#include "GraphBlockNode.h"
+#include "GraphPortNode.h"
+#include "GraphScene.h"
+#include "pipeline/Block.h"
+#include "pipeline/Port.h"
+#include "pipeline/Connection.h"
+
+GraphConnectionNode::GraphConnectionNode(GraphScene* scene, const std::shared_ptr<Connection>& con, QGraphicsItem* parent) : GraphConnectionBase(scene, parent),
+	_connection{con}
+{
+	if (!con)
+		throw std::invalid_argument{_EXCPT("con may not be null")};
+
+	connectPortSignals();
+
+	updateGeometry();
+
+	// Create node actions
+	_deleteAction->setText("&Remove connection");
+
+	// Set the tooltip showing information about this connection
+	updateToolTip();
+}
+
+GraphPortNode* GraphConnectionNode::_sourcePortNode() const
+{
+	if (auto con = _connection.lock())	// Make sure that the underlying connection still exists
+		return _scene->findPortNode(con->sourcePort());
+	else
+		return nullptr;
+}
+
+GraphPortNode* GraphConnectionNode::_destPortNode() const
+{
+	if (auto con = _connection.lock())	// Make sure that the underlying connection still exists
+		return _scene->findPortNode(con->destPort());
+	else
+		return nullptr;
+}
+
+void GraphConnectionNode::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
+{
+	Q_UNUSED(option);
+	Q_UNUSED(widget);
+
+	painter->setRenderHints(QPainter::Antialiasing|QPainter::TextAntialiasing);
+
+	if (auto con = _connection.lock())	// Make sure that the underlying connection still exists
+	{		
+		if (isSelected())	// Draw outer glow if selected
+		{
+			bool inactive = !_scene->hasFocus();
+			QPen selectionPen{QBrush{inactive ? _connectionStyle.selectionColorInactive : _connectionStyle.selectionColor}, _connectionStyle.lineWidth + _connectionStyle.selectionMargin};
+			selectionPen.setJoinStyle(Qt::RoundJoin);
+
+			painter->save();
+			painter->setPen(selectionPen);
+			painter->setOpacity(_connectionStyle.selectionOpacity);
+			painter->drawPath(_geometry.connectionPath);
+			painter->restore();
+		}
+
+		drawConnection(painter, getPortColor(sourcePortNode()), getPortColor(destPortNode()));
+	}
+}
+
+QPointF GraphConnectionNode::calcSourcePosition() const
+{
+	if (auto portNode = sourcePortNode())
+		return portNode->blockNode()->mapToScene(portNode->getCenterPos());
+	else
+		return QPointF{};
+}
+
+QPointF GraphConnectionNode::calcDestPosition() const
+{
+	if (auto portNode = destPortNode())
+		return portNode->blockNode()->mapToScene(portNode->getCenterPos());
+	else
+		return QPointF{};
+}
+
+void GraphConnectionNode::updateToolTip()
+{
+	if (auto con = _connection.lock())	// Make sure that the underlying connection still exists
+	{
+		QString toolTip = QString{"<b>From: </b>%1<br><b>To: </b>%2"}
+				.arg(con->sourcePort()->getFormattedName())
+				.arg(con->destPort()->getFormattedName());
+
+		setToolTip(toolTip);
+	}
+}
+
+void GraphConnectionNode::connectPortSignals()
+{
+	// Remove any previous signals
+	disconnect(nullptr, this, SLOT(connectedBlockMoved(VisualNode*)));
+
+	// Listen for movement events of both involved blocks in order to update our own shape
+	auto sourceNode = sourcePortNode();
+	auto destNode = destPortNode();
+
+	if (sourceNode)
+	{
+		connect(sourceNode->blockNode(), &GraphBlockNode::nodeMoved, this, &GraphConnectionNode::connectedBlockMoved);
+		connect(sourceNode->blockNode(), &GraphBlockNode::nodeGeometryUpdated, this, &GraphConnectionNode::connectedBlockMoved);
+	}
+
+	if (destNode)
+	{
+		connect(destNode->blockNode(), &GraphBlockNode::nodeMoved, this, &GraphConnectionNode::connectedBlockMoved);
+		connect(destNode->blockNode(), &GraphBlockNode::nodeGeometryUpdated, this, &GraphConnectionNode::connectedBlockMoved);
+	}
+}
+
+QColor GraphConnectionNode::getPortColor(const GraphPortNode* portNode) const
+{
+	// Get the color of the block associated with the given port
+	if (portNode)
+	{
+		if (auto port = portNode->port().lock())
+			return _style.getBlockNodeStyle().getBlockCategoryColor(port->block()->getCategory());
+	}
+
+	return QColor{};
+}
+
+void GraphConnectionNode::connectedBlockMoved(VisualNode* node)
+{
+	Q_UNUSED(node);
+
+	// One of the connected blocks has been moved, so we need to update our geometry
+	prepareGeometryChange();
+	updateGeometry();
+}
diff --git a/Grinder/ui/graph/GraphConnectionNode.h b/Grinder/ui/graph/GraphConnectionNode.h
new file mode 100644
index 0000000000000000000000000000000000000000..9bc1538401079ba02b21e11f517da61d915e6144
--- /dev/null
+++ b/Grinder/ui/graph/GraphConnectionNode.h
@@ -0,0 +1,59 @@
+/******************************************************************************
+ * File: GraphConnectionNode.h
+ * Date: 25.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHCONNECTIONNODE_H
+#define GRAPHCONNECTIONNODE_H
+
+#include <memory>
+
+#include "GraphConnectionBase.h"
+
+namespace grndr
+{
+	class GraphPortNode;
+	class Connection;
+
+	class GraphConnectionNode : public GraphConnectionBase
+	{
+		Q_OBJECT
+
+	public:
+		GraphConnectionNode(GraphScene* scene, const std::shared_ptr<Connection>& con, QGraphicsItem* parent = nullptr);
+
+	public:		
+		GraphPortNode* sourcePortNode() { return _sourcePortNode(); }
+		const GraphPortNode* sourcePortNode() const { return _sourcePortNode(); }
+		GraphPortNode* destPortNode() { return _destPortNode(); }
+		const GraphPortNode* destPortNode() const { return _destPortNode(); }
+
+		std::weak_ptr<Connection>& connection() { return _connection; }
+		const std::weak_ptr<Connection>& connection() const { return _connection; }
+
+	public:
+		virtual void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override;
+
+	protected:
+		virtual QPointF calcSourcePosition() const override;
+		virtual QPointF calcDestPosition() const override;
+
+	private:
+		void updateToolTip();
+		void connectPortSignals();
+
+		QColor getPortColor(const GraphPortNode* node) const;
+
+	private:
+		GraphPortNode* _sourcePortNode() const;
+		GraphPortNode* _destPortNode() const;
+
+	private slots:
+		void connectedBlockMoved(VisualNode* node);
+
+	private:
+		std::weak_ptr<Connection> _connection;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphLayout.cpp b/Grinder/ui/graph/GraphLayout.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..1c19898a95276cf033cea58f7f43afaf80a7dfad
--- /dev/null
+++ b/Grinder/ui/graph/GraphLayout.cpp
@@ -0,0 +1,146 @@
+/******************************************************************************
+ * File: GraphLayout.cpp
+ * Date: 23.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphLayout.h"
+#include "GraphBlockNode.h"
+#include "GraphView.h"
+#include "GraphScene.h"
+#include "pipeline/Pipeline.h"
+#include "util/StringConv.h"
+#include "util/SerializationUtils.h"
+
+const char* GraphLayout::Serialization_Group_Item = "Item";
+
+const char* GraphLayout::Serialization_Value_Block = "Block";
+const char* GraphLayout::Serialization_Value_Position = "Position";
+
+void GraphLayout::applyLayout(GraphScene* scene, bool centerLayout) const
+{
+	qreal maxX = 0.0;
+	qreal maxY = 0.0;
+
+	if (centerLayout)
+	{
+		// Adjust layout to be centered
+		for (const auto& layoutItem : _layout)
+		{
+			if (auto block = layoutItem->_block.lock())	// Make sure that the block still exists
+			{
+				if (auto blockNode = scene->findBlockNode(block.get()))
+				{
+					auto rect = blockNode->boundingRect(false);
+
+					maxX = std::max(maxX, layoutItem->_position.x() + rect.width()) * 0.5;
+					maxY = std::max(maxY, layoutItem->_position.y() + rect.height()) * 0.5;
+				}
+			}
+		}
+	}
+
+	for (const auto& layoutItem : _layout)
+	{
+		if (auto block = layoutItem->_block.lock())	// Make sure that the block still exists
+		{
+			if (auto blockNode = scene->findBlockNode(block.get()))
+				blockNode->setPos(QPointF{layoutItem->_position.x() - maxX, layoutItem->_position.y() - maxY});
+		}
+	}
+}
+
+void GraphLayout::fromScene(GraphScene* scene)
+{
+	if (!scene)
+		throw std::invalid_argument{_EXCPT("scene may not be null")};
+
+	_layout.clear();
+
+	for (auto blockNode : scene->getNodes<GraphBlockNode>())
+		_layout.emplace_back(std::make_shared<LayoutItem>(blockNode->_block, blockNode->pos()));
+}
+
+void GraphLayout::fromBlockHierarchy(GraphScene* scene, const BlockHierarchy& blockHierarchy)
+{
+	if (!scene)
+		throw std::invalid_argument{_EXCPT("scene may not be null")};
+
+	_layout.clear();
+
+	QPointF currentPos{0.0, 0.0};
+	auto layoutStyle = scene->view()->sceneStyle().getLayoutStyle();
+
+	// Each level is layed out on a column
+	for (const auto& level : blockHierarchy)
+	{
+		auto columnSize = addHierarchyLevel(scene, currentPos, level);
+
+		currentPos.setX(currentPos.x() + columnSize.width() + layoutStyle.layoutMargins.width());
+		currentPos.setY(0.0);
+	}
+}
+
+void GraphLayout::serialize(SerializationContext& ctx) const
+{
+	SerializationUtils::serializeContainer(_layout, Serialization_Group_Item, ctx);
+}
+
+void GraphLayout::deserialize(const Pipeline* pipeline, DeserializationContext& ctx)
+{
+	SerializationUtils::deserializeContainer<Layout>(Serialization_Group_Item, ctx, [&ctx, pipeline, this](const SettingsContainer& settings) -> std::shared_ptr<LayoutItem> {
+		int blockIndex = settings[Serialization_Value_Block].toInt();
+		QPointF position = StringConv::convertString<QPointF>(settings[Serialization_Value_Position].toString());
+		auto block = ctx.getBlock(blockIndex);
+
+		if (block)
+		{
+			// Get the shared_ptr belonging to the block
+			auto it = pipeline->blocks().find(block);
+
+			if (it != pipeline->blocks().cend())
+				_layout.emplace_back(std::make_shared<LayoutItem>(*it, position));
+		}
+
+		// We don't need to explicitly deserialize the new layout item
+		return nullptr;
+	});
+}
+
+QSizeF GraphLayout::addHierarchyLevel(GraphScene* scene, QPointF startPos, const BlockHierarchy::HierarchyLevel& blocks)
+{
+	auto layoutStyle = scene->view()->sceneStyle().getLayoutStyle();
+	auto currentPos = startPos;
+	qreal width = 0.0;
+
+	for (const auto& block : blocks)
+	{
+		auto blockNode = scene->findBlockNode(block);
+
+		if (blockNode)
+		{
+			_layout.emplace_back(std::make_shared<LayoutItem>(blockNode->_block, currentPos));
+
+			auto rect = blockNode->boundingRect(false);
+			width = std::max(width, rect.width());
+			currentPos.setY(currentPos.y() + rect.height() + layoutStyle.layoutMargins.height());
+		}
+	}
+
+	return QSizeF{width, currentPos.y()};
+}
+
+void GraphLayout::LayoutItem::serialize(SerializationContext& ctx) const
+{
+	if (auto block = _block.lock())	// Make sure that the block still exists
+	{
+		ctx.settings()[Serialization_Value_Block] = ctx.getBlockIndex(block.get());
+		ctx.settings()[Serialization_Value_Position] = StringConv::convertValue(_position);
+	}
+}
+
+void GraphLayout::LayoutItem::deserialize(DeserializationContext& ctx)
+{
+	// Nothing to deserialize (all done by the superordinate layout)
+	Q_UNUSED(ctx);
+}
diff --git a/Grinder/ui/graph/GraphLayout.h b/Grinder/ui/graph/GraphLayout.h
new file mode 100644
index 0000000000000000000000000000000000000000..8a612ca174a7aefea5bf767566767d044686beba
--- /dev/null
+++ b/Grinder/ui/graph/GraphLayout.h
@@ -0,0 +1,80 @@
+/******************************************************************************
+ * File: GraphLayout.h
+ * Date: 23.2.2018
+ *****************************************************************************/
+
+#ifndef GRAPHLAYOUT_H
+#define GRAPHLAYOUT_H
+
+#include <QPointF>
+#include <QSizeF>
+#include <memory>
+
+#include "pipeline/BlockHierarchy.h"
+#include "project/serialization/SerializationContext.h"
+#include "project/serialization/DeserializationContext.h"
+
+namespace grndr
+{
+	class BlockHierarchy;
+	class GraphScene;
+
+	class GraphLayout
+	{
+	public:
+		static const char* Serialization_Group_Item;
+
+		static const char* Serialization_Value_Block;
+		static const char* Serialization_Value_Position;
+
+	public:
+		struct LayoutItem
+		{
+			LayoutItem() { }
+			LayoutItem(std::weak_ptr<Block> block, QPointF pos) : _block{block}, _position{pos} { }
+
+			std::weak_ptr<Block> _block;
+			QPointF _position;
+
+			void serialize(SerializationContext& ctx) const;
+			void deserialize(DeserializationContext& ctx);
+		};
+
+		using Layout = std::vector<std::shared_ptr<LayoutItem>>;
+
+	public:
+		GraphLayout() { }
+		GraphLayout(const GraphLayout& graphLayout) = default;
+		GraphLayout(GraphLayout&& graphLayout) = default;
+		GraphLayout(const Layout& layout) { _layout = layout; }
+		GraphLayout(Layout&& layout) { _layout = std::move(layout); }
+
+		GraphLayout& operator =(const GraphLayout& graphLayout) = default;
+		GraphLayout& operator =(GraphLayout&& graphLayout) = default;
+		GraphLayout& operator =(const Layout& layout) { _layout = layout; return *this; }
+		GraphLayout& operator =(Layout&& layout) { _layout = std::move(layout); return *this; }
+
+	public:
+		void applyLayout(GraphScene* scene, bool centerLayout = true) const;
+
+	public:
+		void fromScene(GraphScene* scene);
+		void fromBlockHierarchy(GraphScene* scene, const BlockHierarchy& blockHierarchy);
+
+	public:
+		Layout& layout() { return _layout; }
+		const Layout& layout() const { return _layout; }
+
+	public:
+		void serialize(SerializationContext& ctx) const;
+		void deserialize(const Pipeline* pipeline, DeserializationContext& ctx);
+
+	private:
+		QSizeF addHierarchyLevel(GraphScene* scene, QPointF startPos, const BlockHierarchy::HierarchyLevel& blocks);
+
+	private:
+		Layout _layout;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphNode.cpp b/Grinder/ui/graph/GraphNode.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..6ea31edac0cfd7bc6cc656c6c1da6488e2788bac
--- /dev/null
+++ b/Grinder/ui/graph/GraphNode.cpp
@@ -0,0 +1,36 @@
+/******************************************************************************
+ * File: GraphNode.cpp
+ * Date: 22.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphNode.h"
+#include "GraphScene.h"
+#include "GraphView.h"
+#include "core/GrinderApplication.h"
+#include "controller/PipelineController.h"
+#include "util/UIUtils.h"
+#include "res/Resources.h"
+
+#include <QMenu>
+
+GraphNode::GraphNode(GraphScene* scene, QGraphicsItem* parent) : VisualNode(scene, parent),
+	_scene{scene}, _style{scene->view()->sceneStyle()}
+{
+	// Create node actions
+	_deleteAction = createNodeAction("&Delete", FILE_ICON_DELETE, SLOT(deleteNode()), "Remove the selected item", "Del");
+}
+
+std::vector<QAction*> GraphNode::getNodesActions(QMenu& menu) const
+{
+	// If multiple items are selected, only show a "delete all" action
+	auto action = UIUtils::createAction(&menu, "&Delete selected items", FILE_ICON_DELETE_SELECTED, nullptr, "Delete the selected items", "Del");
+	action->connect(action, &QAction::triggered, _scene->view(), &GraphView::removeSelectedItems);
+
+	return {action};
+}
+
+void GraphNode::deleteNode()
+{
+	grinder()->pipelineController().removeSelectedNodes();
+}
diff --git a/Grinder/ui/graph/GraphNode.h b/Grinder/ui/graph/GraphNode.h
new file mode 100644
index 0000000000000000000000000000000000000000..fa1646fcdce9c0e19d2cd63a4ba8363b0ce00008
--- /dev/null
+++ b/Grinder/ui/graph/GraphNode.h
@@ -0,0 +1,38 @@
+/******************************************************************************
+ * File: GraphNode.h
+ * Date: 22.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHNODE_H
+#define GRAPHNODE_H
+
+#include "ui/visscene/VisualNode.h"
+
+namespace grndr
+{
+	class GraphScene;
+	class GraphStyle;
+
+	class GraphNode : public VisualNode
+	{
+		Q_OBJECT
+
+	public:
+		GraphNode(GraphScene* scene, QGraphicsItem* parent = nullptr);
+
+	protected:
+		virtual std::vector<QAction*> getNodeActions(QMenu& menu) const override { Q_UNUSED(menu); return {nullptr, _deleteAction}; }
+		virtual std::vector<QAction*> getNodesActions(QMenu& menu) const override;
+
+	protected slots:
+		void deleteNode();
+
+	protected:
+		GraphScene* _scene{nullptr};
+		const GraphStyle& _style;
+
+		QAction* _deleteAction;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphNodeFactory.cpp b/Grinder/ui/graph/GraphNodeFactory.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..3cc15fc691bc57612304f21adaeb10ea890847bc
--- /dev/null
+++ b/Grinder/ui/graph/GraphNodeFactory.cpp
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: GraphNodeFactory.cpp
+ * Date: 22.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphNodeFactory.h"
+#include "GraphBlockNode.h"
+#include "pipeline/Block.h"
+
+GraphNodeFactory::GraphNodeFactory(GraphScene* scene) : VisualNodeFactory(scene)
+{
+	registerStandardBlocks();
+}
+
+GraphBlockNode* GraphNodeFactory::createDefaultNode(const std::shared_ptr<Block>& block) const
+{
+	return new GraphBlockNode{_scene, block};
+}
+
+void GraphNodeFactory::registerStandardBlocks()
+{
+
+}
diff --git a/Grinder/ui/graph/GraphNodeFactory.h b/Grinder/ui/graph/GraphNodeFactory.h
new file mode 100644
index 0000000000000000000000000000000000000000..b9442f3fd47738f3a2f61a23b0d0857a16577fc0
--- /dev/null
+++ b/Grinder/ui/graph/GraphNodeFactory.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: GraphNodeFactory.h
+ * Date: 22.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHNODEFACTORY_H
+#define GRAPHNODEFACTORY_H
+
+#include "ui/visscene/VisualNodeFactory.h"
+#include "pipeline/BlockType.h"
+
+namespace grndr
+{
+	class GraphScene;
+	class GraphBlockNode;
+	class Block;
+
+	class GraphNodeFactory : public VisualNodeFactory<GraphBlockNode, Block, BlockType, GraphScene>
+	{
+	public:
+		GraphNodeFactory(GraphScene* scene);
+
+	protected:
+		virtual GraphBlockNode* createDefaultNode(const std::shared_ptr<Block>& block) const override;
+
+	private:
+		void registerStandardBlocks();
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphPortNode.cpp b/Grinder/ui/graph/GraphPortNode.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..10f7dacc437b63616df7d0b7a428afb8b864835c
--- /dev/null
+++ b/Grinder/ui/graph/GraphPortNode.cpp
@@ -0,0 +1,217 @@
+/******************************************************************************
+ * File: GraphPortNode.cpp
+ * Date: 24.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphPortNode.h"
+#include "GraphBlockNode.h"
+#include "GraphConnectionBlueprint.h"
+#include "GraphScene.h"
+#include "core/GrinderApplication.h"
+#include "pipeline/Port.h"
+#include "controller/PipelineController.h"
+#include "controller/EngineController.h"
+#include "util/DataUtils.h"
+#include "res/Resources.h"
+
+#include <opencv2/highgui.hpp>
+
+GraphPortNode::GraphPortNode(GraphBlockNode* blockNode, grndr::GraphScene* scene, const std::shared_ptr<grndr::Port>& port, QGraphicsItem* parent) : GraphNode(scene, parent),
+	_blockNode{blockNode}, _port{port}, _portStyle{_style.getPortNodeStyle()}
+{
+	if (!blockNode)
+		throw std::invalid_argument{_EXCPT("blockNode may not be null")};
+
+	if (!port)
+		throw std::invalid_argument{_EXCPT("port may not be null")};
+
+	setFlag(ItemIsSelectable, false);
+	setFlag(ItemIsFocusable, false);
+
+	updateGeometry();
+
+	// Create node actions
+	_viewImageAction = createNodeAction("&View image", FILE_ICON_VIEWIMAGE, SLOT(viewImage()), "View the image at this port");
+	_disconnectAction = createNodeAction("&Disconnect from all", FILE_ICON_DELETE, SLOT(disconnectFromAll()), "Remove all connections to/from this port");
+
+	// Set the tooltip showing what this port is accepting/offering
+	updateToolTip();
+}
+
+QPointF GraphPortNode::getCenterPos(bool shiftCenter) const
+{
+	auto centerPos = pos() + _geometry.portRect.center();
+
+	if (shiftCenter)
+	{
+		if (auto port = _port.lock())	// Make sure that the underlying port still exists
+		{
+			// Slightly shift the center point right/left (makes creating connections easier)
+			auto shift = _portStyle.innerSize + _style.getConnectionNodeStyle().lineWidth / 2.0;
+
+			if (port->isOut())
+				centerPos.setX(centerPos.x() + shift);
+			else if (port->isIn())
+				centerPos.setX(centerPos.x() - shift);
+		}
+	}
+
+	return centerPos;
+}
+
+void GraphPortNode::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
+{
+	Q_UNUSED(option);
+	Q_UNUSED(widget);
+
+	painter->setRenderHints(QPainter::Antialiasing|QPainter::TextAntialiasing|QPainter::HighQualityAntialiasing|QPainter::SmoothPixmapTransform);
+
+	// Draw the port connector
+	QPen pen{_portStyle.borderColor, _portStyle.borderWidth};
+	pen.setCapStyle(Qt::FlatCap);
+	pen.setJoinStyle(Qt::MiterJoin);
+
+	painter->setPen(pen);
+	painter->fillRect(_geometry.portRect, _portStyle.darkBackgroundColor);
+	painter->drawRect(_geometry.portRect);
+
+	auto sizeDiff = (_portStyle.outerSize - _portStyle.innerSize) / 2.0;
+	auto innerRect = _geometry.portRect - QMarginsF{sizeDiff, sizeDiff, sizeDiff, sizeDiff};
+
+	if (auto port = _port.lock())	// Make sure that the underlying port still exists
+	{
+		if (!port->isConnected())
+			painter->fillRect(innerRect, _portStyle.lightBackgroundColor);
+
+		painter->drawRect(innerRect);
+
+		// Draw the port name
+		painter->setPen(QPen{_portStyle.textColor});
+		painter->setFont(_portStyle.nameFont);
+		painter->drawText(_geometry.portNameRect, (port->isIn() ? Qt::AlignLeft : Qt::AlignRight)|Qt::AlignVCenter, port->getName());
+	}
+}
+
+void GraphPortNode::contextMenuEvent(QGraphicsSceneContextMenuEvent* event)
+{
+	// Select the block node exclusively if it hasn't been selected
+	if (!_blockNode->isSelected())
+	{
+		_scene->clearSelection();
+		_blockNode->setSelected(true);
+	}
+
+	showContextMenu(event->screenPos());
+
+	event->accept();
+}
+
+void GraphPortNode::updateGeometry()
+{
+	if (auto port = _port.lock())	// Make sure that the underlying port still exists
+	{
+		_geometry.portRect = QRectF{0.0, 0.0, _portStyle.outerSize, _portStyle.outerSize};
+		_geometry.portNameSize = calcPortNameSize(port.get());
+
+		_nodeRect = QRectF{0.0, 0.0, _geometry.portRect.width() + _geometry.portNameSize.width(), std::max(_geometry.portRect.height(), _geometry.portNameSize.height())};
+		_nodeRectSelected = _nodeRect;
+
+		_geometry.portNameRect = _nodeRect;
+
+		if (port->isOut())
+		{
+			// Update geometry for a right-aligned port
+			_geometry.portRect.moveLeft(_nodeRect.right() - _geometry.portRect.width());
+			_geometry.portNameRect.setRight(_geometry.portNameRect.right() - (_geometry.portRect.width() + _portStyle.textMargins.right()));
+		}
+		else
+			_geometry.portNameRect.setLeft(_geometry.portNameRect.left() + _geometry.portRect.width() + _portStyle.textMargins.left());
+	}
+
+	GraphNode::updateGeometry();
+}
+
+QSizeF GraphPortNode::calcPortNameSize(const Port* port) const
+{
+	auto marginsX = _portStyle.textMargins.left() + _portStyle.textMargins.right();
+	auto marginsY = _portStyle.textMargins.top() + _portStyle.textMargins.bottom();
+
+	QFontMetricsF fontNameMetrics{_portStyle.nameFont};
+	auto nameWidth = fontNameMetrics.width(port->getName()) + marginsX;
+	auto nameHeight = fontNameMetrics.height() + marginsY;
+
+	return QSizeF{nameWidth, nameHeight};
+}
+
+void GraphPortNode::viewImage()
+{
+	if (auto port = _port.lock())	// Make sure that the underlying port still exists
+	{
+		if (auto activeLabel = grinder()->projectController().activeLabel())
+		{
+			auto portImage = grinder()->engineController().executeLabelEx(activeLabel, port.get(), Engine::ExecutionMode::Execute);
+
+			if (!portImage.empty())
+				cv::imshow(port->getFormattedName().toStdString(), portImage);
+		}
+	}
+}
+
+void GraphPortNode::disconnectFromAll()
+{
+	if (auto port = _port.lock())	// Make sure that the underlying port still exists
+		grinder()->pipelineController().disconnectPortFromAll(port.get());
+}
+
+void GraphPortNode::updateToolTip()
+{
+	if (auto port = _port.lock())	// Make sure that the underlying port still exists
+	{
+		QString toolTip = QString{"<b>%1</b>"}.arg(port->getFormattedName());
+		QStringList dataDescInfos;
+
+		for (const auto& dataDesc : port->dataDescriptors())
+		{
+			 dataDescInfos << QString{"<br><u>%1</u><br><em>&nbsp;&nbsp;Structure type: </em>%2<br><em>&nbsp;&nbsp;Field type: </em>%3<br><em>&nbsp;&nbsp;Value type: </em>%4"}
+					 .arg(dataDesc.getName())
+					 .arg(DataUtils::getDataDescriptorTypeName(dataDesc.getStructureType()))
+					 .arg(DataUtils::getDataDescriptorTypeName(dataDesc.getFieldType()))
+					 .arg(DataUtils::getDataDescriptorTypeName(dataDesc.getValueType()));
+		}
+
+		setToolTip(toolTip + dataDescInfos.join("<br>"));
+	}
+}
+
+std::vector<QAction*> GraphPortNode::getNodeActions(QMenu& menu) const
+{
+	Q_UNUSED(menu);
+
+	_viewImageAction->setEnabled(false);
+	_disconnectAction->setEnabled(false);
+
+	if (auto port = _port.lock())	// Make sure that the underlying port still exists
+	{
+		// Check if this port has at least one matrix type
+		bool hasMatrixType = false;
+
+		for (const auto& dataDesc : port->dataDescriptors())
+		{
+			if (dataDesc.getStructureType() == DataDescriptor::StructureType::Matrix)
+			{
+				hasMatrixType = true;
+				break;
+			}
+		}
+
+		if (hasMatrixType && (port->isOut() || (port->isIn() && port->isConnected())))	// In-ports must be connected to a source
+			_viewImageAction->setEnabled(true);
+
+		// Check if this port is connected to something
+		if (!port->connections().empty() || !port->getConnections(Port::Direction::In).empty())
+			_disconnectAction->setEnabled(true);
+	}
+
+	return {_viewImageAction, nullptr, _disconnectAction};
+}
diff --git a/Grinder/ui/graph/GraphPortNode.h b/Grinder/ui/graph/GraphPortNode.h
new file mode 100644
index 0000000000000000000000000000000000000000..1aaddf6b4491ae3f4c84a06c86f86ee010d948c3
--- /dev/null
+++ b/Grinder/ui/graph/GraphPortNode.h
@@ -0,0 +1,75 @@
+/******************************************************************************
+ * File: GraphPortNode.h
+ * Date: 24.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHPORTNODE_H
+#define GRAPHPORTNODE_H
+
+#include <memory>
+
+#include "GraphNode.h"
+#include "GraphStyle.h"
+
+namespace grndr
+{
+	class GraphBlockNode;
+	class GraphConnectionBlueprint;
+	class Port;
+
+	class GraphPortNode : public GraphNode
+	{
+		Q_OBJECT
+
+	public:
+		GraphPortNode(GraphBlockNode* blockNode, GraphScene* scene, const std::shared_ptr<Port>& port, QGraphicsItem* parent = nullptr);
+
+	public:
+		GraphBlockNode* blockNode() { return _blockNode; }
+		const GraphBlockNode* blockNode() const { return _blockNode; }
+		std::weak_ptr<Port>& port() { return _port; }
+		const std::weak_ptr<Port>& port() const { return _port; }
+
+		QRectF getPortRect() const { return _geometry.portRect; }		
+		QPointF getCenterPos(bool shiftCenter = true) const;
+
+	public:
+		virtual void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override;
+
+	protected:
+		virtual void contextMenuEvent(QGraphicsSceneContextMenuEvent* event) override;
+
+	protected:
+		virtual void updateGeometry() override;
+
+		virtual std::vector<QAction*> getNodeActions(QMenu& menu) const override;
+
+	private:
+		QSizeF calcPortNameSize(const Port* port) const;
+
+		struct
+		{
+			QRectF portRect;
+			QSizeF portNameSize;
+			QRectF portNameRect;
+		} _geometry;
+
+	private slots:
+		void viewImage();
+		void disconnectFromAll();
+
+	private:
+		void updateToolTip();
+
+	private:
+		GraphBlockNode* _blockNode{nullptr};
+		std::weak_ptr<Port> _port;
+
+		GraphStyle::PortNodeStyle _portStyle;
+
+		QAction* _viewImageAction;
+		QAction* _disconnectAction;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphScene.cpp b/Grinder/ui/graph/GraphScene.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a9bb12d112f6e59540d7304b8a96226874e9930a
--- /dev/null
+++ b/Grinder/ui/graph/GraphScene.cpp
@@ -0,0 +1,124 @@
+/******************************************************************************
+ * File: GraphScene.cpp
+ * Date: 18.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphScene.h"
+#include "GraphView.h"
+#include "GraphStyle.h"
+#include "GraphBlockNode.h"
+#include "GraphPortNode.h"
+#include "GraphConnectionNode.h"
+#include "pipeline/Pipeline.h"
+
+GraphScene::GraphScene(Pipeline* pipeline, GraphView* view, QSizeF initialSize) : VisualScene{view, QRectF{-(initialSize.width() / 2), -(initialSize.height() / 2), initialSize.width(), initialSize.height()}},
+	_pipeline{pipeline}, _nodeFactory{this}
+{
+	if (!pipeline)
+		throw std::invalid_argument{_EXCPT("pipeline may not be null")};
+}
+
+void GraphScene::buildScene()
+{
+	clear();
+
+	// Add all elements of the pipeline of the label to the scene
+	if (_pipeline)
+	{
+		// Add all blocks to the scene
+		for (const auto& block : _pipeline->blocks())
+			createBlockNode(block);
+
+		// Add all connections to the scene
+		for (const auto& block : _pipeline->blocks())
+		{
+			for (const auto& port : block->ports().selectByDirection(Port::Direction::Out))
+			{
+				for (const auto& con : port->connections())
+					createConnectionNode(con);
+			}
+		}
+	}
+}
+
+void GraphScene::createBlockNode(const std::shared_ptr<Block>& block)
+{
+	if (!block)
+		throw std::invalid_argument{_EXCPT("block may not be null")};
+
+	if (!findBlockNode(block.get()))
+		addItem(_nodeFactory.createNode(block));
+}
+
+void GraphScene::removeBlockNode(GraphBlockNode* blockNode)
+{
+	if (blockNode)
+		delete blockNode;
+}
+
+void GraphScene::createConnectionNode(const std::shared_ptr<Connection>& connection)
+{
+	if (!connection)
+		throw std::invalid_argument{_EXCPT("connection may not be null")};
+
+	if (!findConnectionNode(connection.get()))
+		addItem(new GraphConnectionNode{this, connection});
+}
+
+void GraphScene::removeConnectionNode(GraphConnectionNode* connectionNode)
+{
+	if (connectionNode)
+		delete connectionNode;
+}
+
+GraphBlockNode* GraphScene::_findBlockNode(const Block* block) const
+{
+	for (const auto& node : items())
+	{
+		auto blockNode = dynamic_cast<GraphBlockNode*>(node);
+
+		if (blockNode && blockNode->block().lock().get() == block)
+			return blockNode;
+	}
+
+	return nullptr;
+}
+
+GraphPortNode* GraphScene::_findPortNode(const Port* port) const
+{
+	auto blockNode = findBlockNode(port->block());
+
+	if (blockNode)
+	{
+		auto searchPorts = [port](const std::vector<GraphPortNode*>& portNodes) -> GraphPortNode* {
+			auto it = std::find_if(portNodes.cbegin(), portNodes.cend(), [port](auto portNode) { return portNode->port().lock().get() == port; });
+
+			if (it != portNodes.cend())
+				return *it;
+
+			return nullptr;
+		};
+
+		if (auto inPort = searchPorts(blockNode->inPortNodes()))
+			return inPort;
+
+		if (auto outPort = searchPorts(blockNode->outPortNodes()))
+			return outPort;
+	}
+
+	return nullptr;
+}
+
+GraphConnectionNode* GraphScene::_findConnectionNode(const Connection* con) const
+{
+	for (const auto& node : items())
+	{
+		auto conNode = dynamic_cast<GraphConnectionNode*>(node);
+
+		if (conNode && conNode->connection().lock().get() == con)
+			return conNode;
+	}
+
+	return nullptr;
+}
diff --git a/Grinder/ui/graph/GraphScene.h b/Grinder/ui/graph/GraphScene.h
new file mode 100644
index 0000000000000000000000000000000000000000..69a120e149aaa4d398790a784d2d9787bb525877
--- /dev/null
+++ b/Grinder/ui/graph/GraphScene.h
@@ -0,0 +1,65 @@
+/******************************************************************************
+ * File: GraphScene.h
+ * Date: 18.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHSCENE_H
+#define GRAPHSCENE_H
+
+#include "ui/visscene/VisualScene.h"
+#include "GraphView.h"
+#include "GraphNodeFactory.h"
+
+namespace grndr
+{
+	class Pipeline;
+	class Block;
+	class Port;
+	class Connection;
+	class GraphView;
+	class GraphBlockNode;
+	class GraphPortNode;
+	class GraphConnectionNode;
+
+	class GraphScene : public VisualScene<GraphView>
+	{
+		Q_OBJECT
+
+	public:
+		GraphScene(Pipeline* pipeline, GraphView* view, QSizeF initialSize);
+
+	public:
+		void buildScene();
+
+		void createBlockNode(const std::shared_ptr<Block>& block);
+		void removeBlockNode(const std::shared_ptr<Block>& block) { removeBlockNode(findBlockNode(block.get())); }
+		void removeBlockNode(GraphBlockNode* blockNode);	
+
+		void createConnectionNode(const std::shared_ptr<Connection>& connection);
+		void removeConnectionNode(const std::shared_ptr<Connection>& connection) { removeConnectionNode(findConnectionNode(connection.get())); }
+		void removeConnectionNode(GraphConnectionNode* connectionNode);
+
+	public:
+		GraphBlockNode* findBlockNode(const Block* block) { return _findBlockNode(block); }
+		const GraphBlockNode* findBlockNode(const Block* block) const { return _findBlockNode(block); }
+		GraphPortNode* findPortNode(const Port* port) { return _findPortNode(port); }
+		const GraphPortNode* findPortNode(const Port* port) const { return _findPortNode(port); }
+		GraphConnectionNode* findConnectionNode(const Connection* con) { return _findConnectionNode(con); }
+		const GraphConnectionNode* findConnectionNode(const Connection* con) const { return _findConnectionNode(con); }
+
+	public:
+		const GraphNodeFactory& nodeFactory() const { return _nodeFactory; }
+
+	private:
+		GraphBlockNode* _findBlockNode(const Block* block) const;
+		GraphPortNode* _findPortNode(const Port* port) const;
+		GraphConnectionNode* _findConnectionNode(const Connection* con) const;
+
+	private:
+		Pipeline* _pipeline{nullptr};
+
+		GraphNodeFactory _nodeFactory;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphStyle.cpp b/Grinder/ui/graph/GraphStyle.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9677cceccdd4f3380c9653ca8503fbf244e2bc7c
--- /dev/null
+++ b/Grinder/ui/graph/GraphStyle.cpp
@@ -0,0 +1,41 @@
+/******************************************************************************
+ * File: GraphStyle.cpp
+ * Date: 20.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphStyle.h"
+
+QColor GraphStyle::BlockNodeStyle::getBlockCategoryColor(BlockCategory category) const
+{
+	if (category == BlockCategory::Input)
+		return QColor{220, 0, 20};
+	else if (category == BlockCategory::Output)
+		return QColor{100, 0, 140};
+	else if (category == BlockCategory::Conversion)
+		return QColor{0, 80, 140};
+	else if (category == BlockCategory::Thresholding)
+		return QColor{100, 220, 0};
+	else
+		return QColor{255, 255, 255};
+}
+
+GraphStyle::NodeStyle::NodeStyle()
+{
+	font.setStyleStrategy(QFont::StyleStrategy(QFont::PreferAntialias|QFont::PreferQuality));
+}
+
+GraphStyle::BlockNodeStyle::BlockNodeStyle()
+{
+	nameFont.setPointSize(10);
+	nameFont.setBold(true);
+	nameFont.setStyleStrategy(QFont::StyleStrategy(QFont::PreferAntialias|QFont::PreferQuality));
+
+	typeFont.setPointSize(8);
+	typeFont.setStyleStrategy(QFont::StyleStrategy(QFont::PreferAntialias|QFont::PreferQuality));
+}
+
+GraphStyle::GraphStyle()
+{
+	_viewStyle.defaultSceneSize = {4000.0f, 2000.0f};
+}
diff --git a/Grinder/ui/graph/GraphStyle.h b/Grinder/ui/graph/GraphStyle.h
new file mode 100644
index 0000000000000000000000000000000000000000..9028715a5b2217e565708f259502f3e4c5d6ba12
--- /dev/null
+++ b/Grinder/ui/graph/GraphStyle.h
@@ -0,0 +1,116 @@
+/******************************************************************************
+ * File: GraphStyle.h
+ * Date: 20.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHSTYLE_H
+#define GRAPHSTYLE_H
+
+#include <QColor>
+#include <QFont>
+#include <QSizeF>
+#include <QMarginsF>
+#include <QPalette>
+
+#include "ui/visscene/VisualSceneStyle.h"
+#include "pipeline/BlockCategory.h"
+
+namespace grndr
+{
+	class GraphScene;
+
+	class GraphStyle : public VisualSceneStyle
+	{
+	public:
+		struct LayoutStyle
+		{
+			QSizeF layoutMargins{100.0, 50.0};
+		};
+
+		struct NodeStyle
+		{
+			QFont font{};
+
+			QColor darkBackgroundColor{100, 100, 100};
+			QColor lightBackgroundColor{200, 200, 200};
+			QColor borderColor{0, 0, 0};
+			float borderRadius{3.0f};
+
+			QColor selectionColor{QPalette{}.highlight().color().lighter()};
+			QColor selectionColorInactive{QPalette{}.color(QPalette::Inactive, QPalette::Highlight).darker()};
+			float selectionMargin{6.0f};
+			float selectionOpacity{0.6f};
+
+			NodeStyle();
+		};
+
+		struct BlockNodeStyle : public NodeStyle
+		{
+			QSizeF minimumSize{150.0, 60.0};
+
+			QFont nameFont{font};
+			QFont typeFont{font};
+			QColor textColor{0, 0, 0};
+			QMarginsF textMargins{20.0, 3.0, 20.0, 3.0};
+
+			float borderWidth{3.0};
+
+			QMarginsF portMargins{6.0, 6.0, 6.0, 6.0};
+
+			QColor getBlockCategoryColor(BlockCategory category) const;
+
+			BlockNodeStyle();
+		};
+
+		struct PortNodeStyle : public NodeStyle
+		{
+			float outerSize{15.0};
+			float innerSize {7.5};
+
+			QFont nameFont{font};
+			QColor textColor{0, 0, 0};
+			QMarginsF textMargins{5.0, 0.0, 5.0, 0.0};
+
+			float borderWidth{1.0};
+		};
+
+		struct ConnectionNodeStyle : public NodeStyle
+		{
+			float lineWidth{5.0f};
+
+			float blueprintOpacity{0.7f};
+			QColor blueprintPendingColor{50, 150, 255};
+			QColor blueprintValidColor{50, 255, 150};
+			QColor blueprintInvalidColor{255, 50, 50};			
+		};
+
+		struct ConnectionMessageStyle : public ConnectionNodeStyle
+		{
+			float messageOpacity{0.8f};
+			QFont messageFont{font};
+			QColor messageColor{255, 255, 255};
+			QMarginsF messageMargins{7.5, 7.5, 7.5, 7.5};
+		};
+
+	public:
+		GraphStyle();
+
+	public:		
+		const LayoutStyle& getLayoutStyle() const { return _layoutStyle; }
+
+		const BlockNodeStyle& getBlockNodeStyle() const { return _blockNodeStyle; }
+		const PortNodeStyle& getPortNodeStyle() const { return _portNodeStyle; }
+		const ConnectionNodeStyle& getConnectionNodeStyle() const { return _connectionNodeStyle; }
+		const ConnectionMessageStyle& getConnectionMessageStyle() const { return _connectionMessageStyle; }
+
+	private:		
+		LayoutStyle _layoutStyle;
+
+		BlockNodeStyle _blockNodeStyle;
+		PortNodeStyle _portNodeStyle;
+		ConnectionNodeStyle _connectionNodeStyle;
+		ConnectionMessageStyle _connectionMessageStyle;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphView.cpp b/Grinder/ui/graph/GraphView.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..958426573228efca71d2fff7587de3c345819834
--- /dev/null
+++ b/Grinder/ui/graph/GraphView.cpp
@@ -0,0 +1,188 @@
+/******************************************************************************
+ * File: GraphView.cpp
+ * Date: 18.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphView.h"
+#include "GraphScene.h"
+#include "GraphWidget.h"
+#include "GraphBlockList.h"
+#include "GraphBlockNode.h"
+#include "GraphLayout.h"
+#include "core/GrinderApplication.h"
+#include "util/UIUtils.h"
+#include "res/Resources.h"
+
+GraphView::GraphView(QWidget* parent) : VisualSceneView(parent)
+{
+	setAcceptDrops(true);
+
+	// Remove widget border
+	setStyleSheet("QGraphicsView { border: none; }");
+
+	// Create view actions
+	_layoutGraphAction = UIUtils::createAction(this, "&Layout graph", FILE_ICON_LAYOUTGRAPH, SLOT(layoutGraph()), "Automatically arrange the graph", "Ctrl+R", Qt::WindowShortcut);
+
+	// We need to save the current graph layout before saving the project
+	connect(&grinder()->projectController(), &ProjectController::projectSaving, this, &GraphView::projectSaving);
+
+	// Listen for label switches in order to store/restore graph layouts
+	connect(&grinder()->projectController(), &ProjectController::labelSwitching, this, &GraphView::labelSwitching);
+	connect(&grinder()->projectController(), &ProjectController::labelSwitched, this, &GraphView::labelSwitched);
+
+	updateActions();
+}
+
+void GraphView::setGraphScene(GraphScene* scene)
+{
+	_graphScene = scene;
+	VisualSceneView::setScene(scene);
+}
+
+void GraphView::layoutGraph()
+{
+	auto hierarchy = grinder()->pipelineController().createBlockHierarchy();
+
+	if (!hierarchy.empty())
+	{
+		GraphLayout layout;
+
+		layout.fromBlockHierarchy(_graphScene, hierarchy);
+		layout.applyLayout(_graphScene);
+		centerOn(0.0, 0.0);
+	}
+}
+
+void GraphView::removeSelectedItems() const
+{
+	if (_graphScene)
+		grinder()->pipelineController().removeSelectedNodes();
+}
+
+void GraphView::updateCurrentGraphLayout() const
+{
+	if (auto currentLabel = grinder()->projectController().activeLabel())
+		currentLabel->graphLayout().fromScene(_graphScene);
+}
+
+void GraphView::moveSelectedItems(QPoint delta)
+{
+	if (_graphScene)
+	{
+		// Only move block nodes
+		for (auto block : _graphScene->getNodes<GraphBlockNode>(true))
+			block->setPos(block->pos() + delta);
+	}
+}
+
+void GraphView::dragEnterEvent(QDragEnterEvent* event)
+{
+	if (event->mimeData()->hasFormat(GRAPHBLOCKLIST_DRAG_MIMETYPE))
+		event->acceptProposedAction();
+	else
+		event->ignore();
+}
+
+void GraphView::dragMoveEvent(QDragMoveEvent* event)
+{
+	if (event->mimeData()->hasFormat(GRAPHBLOCKLIST_DRAG_MIMETYPE))
+		event->acceptProposedAction();
+	else
+		event->ignore();
+}
+
+void GraphView::dropEvent(QDropEvent* event)
+{
+	if (event->mimeData()->hasFormat(GRAPHBLOCKLIST_DRAG_MIMETYPE))
+	{
+		BlockType type = event->mimeData()->data(GRAPHBLOCKLIST_DRAG_MIMETYPE).toStdString().data();
+
+		// Create a new block of the dropped type
+		if (auto pipeline = grinder()->pipelineController().activePipeline())
+		{
+			QString newBlockName = StringUtils::generateUniqueItemName(pipeline->blocks(), type, &Block::getName);
+			auto block = grinder()->pipelineController().createBlock(type, newBlockName);
+
+			if (block && _graphScene)
+			{
+				// Find the just-created block node and move it to the drop-position
+				auto blockNode = _graphScene->findBlockNode(block.get());
+
+				if (blockNode)
+				{
+					auto center = mapToScene(viewport()->rect().center());
+
+					blockNode->setPos(mapToScene(event->pos()));
+
+					_graphScene->clearSelection();
+					blockNode->setSelected(true);
+					blockNode->setFocus();
+
+					_graphScene->update();
+
+					centerOn(center);
+				}
+			}
+
+			event->acceptProposedAction();
+		}
+	}
+	else
+		event->ignore();
+}
+
+std::vector<QAction*> GraphView::getActions(AddActionsMode mode) const
+{
+	std::vector<QAction*> actions;
+
+	actions.push_back(_layoutGraphAction);
+	actions.push_back(nullptr);
+
+	if (mode != AddActionsMode::Toolbar)
+	{
+		actions.push_back(_selectAllAction);
+		actions.push_back(_deleteSelectedAction);
+		actions.push_back(nullptr);
+	}
+
+	actions.push_back(_zoomInAction);
+	actions.push_back(_zoomOutAction);
+	actions.push_back(_zoomFullAction);
+
+	return actions;
+}
+
+void GraphView::updateActions()
+{
+	VisualSceneView::updateActions();
+
+	_layoutGraphAction->setEnabled(_graphScene && !scene()->items().isEmpty());
+}
+
+void GraphView::projectSaving(QString fileName)
+{
+	Q_UNUSED(fileName);
+
+	// Before saving the project, save the current graph layout
+	updateCurrentGraphLayout();
+}
+
+void GraphView::labelSwitching(Label* label)
+{
+	if (label)
+	{
+		// Save the current layout
+		label->graphLayout().fromScene(_graphScene);
+	}
+}
+
+void GraphView::labelSwitched(Label* label)
+{
+	if (label)
+	{
+		// Apply the saved layout
+		label->graphLayout().applyLayout(_graphScene, false);
+		centerOn(0.0, 0.0);
+	}
+}
diff --git a/Grinder/ui/graph/GraphView.h b/Grinder/ui/graph/GraphView.h
new file mode 100644
index 0000000000000000000000000000000000000000..c46b8d62243ebb34bf4cb391be6fec36af39852c
--- /dev/null
+++ b/Grinder/ui/graph/GraphView.h
@@ -0,0 +1,68 @@
+/******************************************************************************
+ * File: GraphView.h
+ * Date: 18.1.2018
+ *****************************************************************************/
+
+#ifndef GRAPHVIEW_H
+#define GRAPHVIEW_H
+
+#include <QToolBar>
+
+#include "ui/visscene/VisualSceneView.h"
+#include "GraphStyle.h"
+
+namespace grndr
+{
+	class Label;
+
+	class GraphView : public VisualSceneView
+	{
+		Q_OBJECT
+
+	public:
+		GraphView(QWidget* parent = nullptr);
+
+	public:
+		GraphScene* graphScene() { return _graphScene; }
+		const GraphScene* graphScene() const { return _graphScene; }
+		void setGraphScene(GraphScene* scene);
+
+	public slots:
+		void layoutGraph();	
+		void updateCurrentGraphLayout() const;
+
+	public slots:
+		virtual void moveSelectedItems(QPoint delta) override;
+		virtual void removeSelectedItems() const override;
+
+	public:
+		virtual const GraphStyle& sceneStyle() const override { return _graphStyle; }
+
+	protected:		
+		virtual void dragEnterEvent(QDragEnterEvent*event) override;
+		virtual void dragMoveEvent(QDragMoveEvent* event) override;
+		virtual void dropEvent(QDropEvent* event) override;
+
+	protected:
+		virtual std::vector<QAction*> getActions(AddActionsMode mode) const override;
+
+	protected slots:
+		virtual void updateActions() override;
+
+	private slots:
+		void projectSaving(QString fileName);
+
+		void labelSwitching(Label* label);
+		void labelSwitched(Label* label);
+
+	private:
+		GraphScene* _graphScene{nullptr};
+
+		GraphStyle _graphStyle;
+
+	private:
+		QAction* _layoutGraphAction{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/graph/GraphWidget.cpp b/Grinder/ui/graph/GraphWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..3828479c01ccc2f5bcdbc61bc50efe7c2b5d79ae
--- /dev/null
+++ b/Grinder/ui/graph/GraphWidget.cpp
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: GraphWidget.cpp
+ * Date: 02.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GraphWidget.h"
+#include "GraphView.h"
+#include "GraphBlockList.h"
+#include "ui/StyleSheet.h"
+
+GraphWidget::GraphWidget(QWidget* parent) : QFrame(parent),
+	_layout{new QGridLayout{this}}
+{
+	_layout->setContentsMargins(0, 0, 0, 0);
+	_layout->setHorizontalSpacing(0);
+	_layout->setVerticalSpacing(0);
+
+	// Create the graph controls
+	_graphView = new GraphView{};
+	_layout->addWidget(_graphView, 0, 1);
+
+	_graphBlockList = new GraphBlockList{};
+	_layout->addWidget(_graphBlockList, 0, 0);
+}
diff --git a/Grinder/ui/graph/GraphWidget.h b/Grinder/ui/graph/GraphWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..a1a9c71617bf60c5fbadb11f9cfd2340c22f0860
--- /dev/null
+++ b/Grinder/ui/graph/GraphWidget.h
@@ -0,0 +1,38 @@
+/******************************************************************************
+ * File: GraphWidget.h
+ * Date: 02.2.2018
+ *****************************************************************************/
+
+#ifndef GRAPHCONTAINERWIDGET_H
+#define GRAPHCONTAINERWIDGET_H
+
+#include <QFrame>
+#include <QGridLayout>
+
+namespace grndr
+{
+	class GraphView;
+	class GraphBlockList;
+
+	class GraphWidget : public QFrame
+	{
+		Q_OBJECT
+
+	public:
+		GraphWidget(QWidget* parent = nullptr);
+
+	public:
+		GraphView* graphView() { return _graphView; }
+		const GraphView* graphView() const { return _graphView; }
+		GraphBlockList* graphBlockList() { return _graphBlockList; }
+		const GraphBlockList* graphBlockList() const { return _graphBlockList; }
+
+	private:
+		QGridLayout* _layout{nullptr};
+
+		GraphView* _graphView{nullptr};
+		GraphBlockList* _graphBlockList{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ColorPresetsWidget.cpp b/Grinder/ui/image/ColorPresetsWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..495bbcb4e77d7fea678becd650a14a347b88314e
--- /dev/null
+++ b/Grinder/ui/image/ColorPresetsWidget.cpp
@@ -0,0 +1,76 @@
+/******************************************************************************
+ * File: ColorPresetsWidget.cpp
+ * Date: 03.4.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ColorPresetsWidget.h"
+#include "util/UIUtils.h"
+
+#define PRESET_COUNT	10
+
+ColorPresetsWidget::ColorPresetsWidget(QWidget* parent) : QWidget(parent),
+	_layout{new QGridLayout{this}}
+{
+	_layout->setContentsMargins(0, 0, 0, 0);
+	_layout->setHorizontalSpacing(2);
+	_layout->setVerticalSpacing(2);
+
+	// Create all color widgets
+	createPresetWidgets();
+}
+
+void ColorPresetsWidget::assignUiComponents(QWidget* actionOwner)
+{
+	// Create all actions
+	for (unsigned int i = 0; i < _colorPresets.size(); ++i)
+	{
+		auto& presetEntry = _colorPresets[i];
+		presetEntry.presetAction = createAction(i, actionOwner);
+	}
+}
+
+void ColorPresetsWidget::createPresetWidgets()
+{
+	int row = 0;
+	int col = 0;
+
+	for (int i = 0; i < PRESET_COUNT; ++i)
+	{
+		auto colorWidget = new ColorWidget{ColorWidget::Flag::SmallBox|ColorWidget::Flag::SelectColorOnDoubleClick};
+		colorWidget->setExtraToolTip(QString{"<b>Preset %1</b> (%2)"}.arg(i + 1).arg((i + 1) % 10));
+		_colorPresets.push_back({colorWidget, nullptr});
+
+		// React to color selections
+		connect(colorWidget, &ColorWidget::colorClicked, this, &ColorPresetsWidget::presetSelected);
+
+		_layout->addWidget(colorWidget, row, col++);
+
+		if (col >= (PRESET_COUNT / 2))
+		{
+			row++;
+			col = 0;
+		}
+	}
+}
+
+QAction* ColorPresetsWidget::createAction(unsigned int index, QWidget* owner)
+{
+	auto action = UIUtils::createAction(owner, QString{"Preset %1"}.arg(index + 1), "", SLOT(presetActionTriggered()), "", QString{"%1"}.arg((index + 1) % 10), Qt::WidgetWithChildrenShortcut, this);
+	action->setData(index);
+	return action;
+}
+
+void ColorPresetsWidget::presetSelected(QColor color)
+{
+	emit colorSelected(color);
+}
+
+void ColorPresetsWidget::presetActionTriggered()
+{
+	if (auto action = dynamic_cast<QAction*>(sender()))
+	{
+		unsigned int index = action->data().toUInt();
+		emit colorSelected(getColor(index));
+	}
+}
diff --git a/Grinder/ui/image/ColorPresetsWidget.h b/Grinder/ui/image/ColorPresetsWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..f58ca2b0c613e64285b70a29780e71c713a0760e
--- /dev/null
+++ b/Grinder/ui/image/ColorPresetsWidget.h
@@ -0,0 +1,55 @@
+/******************************************************************************
+ * File: ColorPresetsWidget.h
+ * Date: 03.4.2018
+ *****************************************************************************/
+
+#ifndef COLORPRESETSWIDGET_H
+#define COLORPRESETSWIDGET_H
+
+#include <QGridLayout>
+
+#include "ui/widget/ColorWidget.h"
+
+namespace grndr
+{
+	class ColorPresetsWidget : public QWidget
+	{
+		Q_OBJECT
+
+	public:
+		ColorPresetsWidget(QWidget* parent = nullptr);
+
+	public:
+		void assignUiComponents(QWidget* actionOwner);
+
+	public:
+		QColor getColor(unsigned int index) const { return _colorPresets[index].presetWidget->getColor(); }
+		void setColor(unsigned int index, QColor color) { _colorPresets[index].presetWidget->setColor(color); }
+
+		auto getPresetsCount() const { return _colorPresets.size(); }
+
+	signals:
+		void colorSelected(QColor color);
+
+	private:
+		void createPresetWidgets();
+		QAction* createAction(unsigned int index, QWidget* owner);
+
+	private slots:
+		void presetSelected(QColor color);
+		void presetActionTriggered();
+
+	private:
+		QGridLayout* _layout{nullptr};
+
+		struct PresetEntry
+		{
+			ColorWidget* presetWidget;
+			QAction* presetAction{nullptr};
+		};
+
+		std::vector<PresetEntry> _colorPresets;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/DraftItemNode.cpp b/Grinder/ui/image/DraftItemNode.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9ef754fb7b4693b49b2bbf21ff1bb274cca8e403
--- /dev/null
+++ b/Grinder/ui/image/DraftItemNode.cpp
@@ -0,0 +1,166 @@
+/******************************************************************************
+ * File: DraftItemNode.cpp
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DraftItemNode.h"
+#include "ImageEditor.h"
+#include "image/DraftItem.h"
+#include "util/UIUtils.h"
+#include "util/MathUtils.h"
+#include "res/Resources.h"
+
+DraftItemNode::DraftItemNode(ImageEditorScene* scene, const std::shared_ptr<DraftItem>& item, QGraphicsItem* parent) : ImageEditorNode(scene, parent),
+	_draftItem{item}, _draftItemStyle{_style.getDraftItemNodeStyle()}
+{
+	if (!item)
+		throw std::invalid_argument{_EXCPT("item may not be null")};
+
+	setFlag(ItemIsMovable);
+
+	if (auto draftItem = _draftItem.lock())	// Make sure that the underlying draft item still exists
+	{
+		// Get a renderer for the draft item
+		_renderer = draftItem->createRenderer(_draftItemStyle);
+
+		// Whenever a property of the draft item changes, update the node to reflect the new values
+		for (auto& property : draftItem->properties())
+			connect(property.get(), &PropertyBase::valueChanged, this, &DraftItemNode::updateNode);
+	}
+
+	updateZOrder();
+	updateVisibility();
+
+	// Create node actions
+	_deleteAction = createNodeAction("&Delete", FILE_ICON_DELETE, SLOT(deleteNode()), "Remove the selected item", "Del");
+}
+
+void DraftItemNode::initDraftItemNode()
+{
+	_inPlaceEditor = createInPlaceEditor();
+
+	if (_inPlaceEditor)
+	{
+		_inPlaceEditor->setParentItem(this);
+		_inPlaceEditor->setVisible(false);
+	}
+}
+
+void DraftItemNode::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
+{
+	Q_UNUSED(option);
+	Q_UNUSED(widget);
+
+	painter->setRenderHints(QPainter::TextAntialiasing|QPainter::HighQualityAntialiasing|QPainter::SmoothPixmapTransform);
+	painter->setRenderHints(QPainter::Antialiasing, false);
+
+	// Use the renderer to draw the item
+	if (_renderer)
+	{
+		DraftItemRendererBase::RenderFlags flags = DraftItemRendererBase::RenderFlag::NoFlag;
+
+		if (isSelected())
+			flags |= DraftItemRendererBase::RenderFlag::Selected;
+
+		if (!_scene->hasFocus())
+			flags |= DraftItemRendererBase::RenderFlag::Inactive;
+
+		if (_imageEditor->environment().showDirectionArrows())
+			flags |= DraftItemRendererBase::RenderFlag::ShowDirections;
+
+		if (_imageEditor->environment().showTags())
+			flags |= DraftItemRendererBase::RenderFlag::ShowTags;
+
+		_renderer->render(painter, DraftItemRendererBase::RenderModeFlag::RenderToScene, flags);
+	}
+}
+
+QPainterPath DraftItemNode::shape() const
+{
+	// Use the renderer to get the item's shape
+	if (_renderer)
+	{
+		QPainterPath path = _renderer->shape();
+
+		if (!path.isEmpty())
+			return path;
+	}
+
+	return ImageEditorNode::shape();
+}
+
+void DraftItemNode::updateNode()
+{
+	if (auto draftItem = _draftItem.lock())	// Make sure that the underlying draft item still exists
+	{
+		// Reflect the draft item's position
+		QPoint pos = *draftItem->position();
+		setPos(QPointF{static_cast<qreal>(pos.x()), static_cast<qreal>(pos.y())});
+	}
+
+	prepareGeometryChange();
+	updateGeometry();
+
+	// Also update the in-place editor
+	if (_inPlaceEditor)
+		_inPlaceEditor->updateEditor();
+
+	update();
+}
+
+void DraftItemNode::updateZOrder()
+{
+	if (auto draftItem = _draftItem.lock())	// Make sure that the underlying draft item still exists
+		setZValue(draftItem->getZOrder());	// Use the z-order of the draft item
+}
+
+void DraftItemNode::updateVisibility()
+{
+	if (auto draftItem = _draftItem.lock())	// Make sure that the underlying draft item still exists
+		setVisible(draftItem->layer()->isVisible());	// Use the visibility flag of the layer
+}
+
+QVariant DraftItemNode::itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant& value)
+{
+	if (change == QGraphicsItem::ItemPositionHasChanged)
+	{
+		if (auto draftItem = _draftItem.lock())	// Make sure that the underlying draft item still exists
+		{						
+			// Update the draft item's position accordingly
+			auto newPos = value.toPointF();
+			QPoint position = MathUtils::round(newPos);
+			draftItem->position()->setValue(position);
+		}
+	}
+	else if (change == QGraphicsItem::ItemSelectedHasChanged)
+	{
+		// Show the in-place editor if the item is currently selected
+		if (_inPlaceEditor)
+			_inPlaceEditor->setVisible(value.toBool());
+	}
+
+	return ImageEditorNode::itemChange(change, value);
+}
+
+void DraftItemNode::updateGeometry()
+{
+	_nodeRect = getBoundingRect();
+	_nodeRectSelected = _nodeRect + QMarginsF{_draftItemStyle.selectionMargin, _draftItemStyle.selectionMargin, _draftItemStyle.selectionMargin, _draftItemStyle.selectionMargin};
+
+	ImageEditorNode::updateGeometry();
+}
+
+std::vector<QAction*> DraftItemNode::getNodesActions(QMenu& menu) const
+{
+	// If multiple items are selected, only show a "delete all" action
+	auto action = UIUtils::createAction(&menu, "&Delete selected items", FILE_ICON_DELETE_SELECTED, nullptr, "Delete the selected items", "Del");
+	action->connect(action, &QAction::triggered, _scene->view(), &ImageEditorView::removeSelectedItems);
+
+	return {action};
+}
+
+void DraftItemNode::deleteNode()
+{
+	_imageEditor->controller().removeSelectedNodes();
+}
diff --git a/Grinder/ui/image/DraftItemNode.h b/Grinder/ui/image/DraftItemNode.h
new file mode 100644
index 0000000000000000000000000000000000000000..e331806a0f81adf197faf397bb64cdc335d72ee4
--- /dev/null
+++ b/Grinder/ui/image/DraftItemNode.h
@@ -0,0 +1,71 @@
+/******************************************************************************
+ * File: DraftItemNode.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef DRAFTITEMNODE_H
+#define DRAFTITEMNODE_H
+
+#include "ImageEditorNode.h"
+#include "ImageEditorStyle.h"
+#include "InPlaceEditor.h"
+#include "image/DraftItemRendererBase.h"
+
+namespace grndr
+{
+	class DraftItemRendererBase;
+
+	class DraftItemNode : public ImageEditorNode
+	{
+		Q_OBJECT
+
+	public:
+		DraftItemNode(ImageEditorScene* scene, const std::shared_ptr<DraftItem>& item, QGraphicsItem* parent = nullptr);
+
+	public:
+		void initDraftItemNode();
+
+	public:
+		std::weak_ptr<DraftItem>& draftItem() { return _draftItem; }
+		const std::weak_ptr<DraftItem>& draftItem() const { return _draftItem; }
+
+	public:
+		virtual void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override;
+		virtual QPainterPath shape() const override;
+
+	public slots:
+		void updateNode();
+
+		void updateZOrder();
+		void updateVisibility();
+
+	protected:
+		virtual std::unique_ptr<InPlaceEditor> createInPlaceEditor() { return std::unique_ptr<InPlaceEditor>{}; }
+
+	protected:
+		virtual QVariant itemChange(GraphicsItemChange change, const QVariant& value) override;
+
+	protected:
+		virtual QRect getBoundingRect() const = 0;
+		virtual void updateGeometry() override;
+
+		virtual std::vector<QAction*> getNodeActions(QMenu& menu) const override { Q_UNUSED(menu); return {nullptr, _deleteAction}; }
+		virtual std::vector<QAction*> getNodesActions(QMenu& menu) const override;
+
+	protected slots:
+		void deleteNode();
+
+	protected:
+		std::weak_ptr<DraftItem> _draftItem;
+		std::unique_ptr<DraftItemRendererBase> _renderer;
+
+		const ImageEditorStyle::DraftItemNodeStyle& _draftItemStyle;
+
+		std::unique_ptr<InPlaceEditor> _inPlaceEditor;
+
+	private:
+		QAction* _deleteAction;		
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/DraftItemNodeFactory.cpp b/Grinder/ui/image/DraftItemNodeFactory.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..5f923191017a94aff9313304de17b27d1a08e967
--- /dev/null
+++ b/Grinder/ui/image/DraftItemNodeFactory.cpp
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: ImageEditorNodeFactory.cpp
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DraftItemNodeFactory.h"
+#include "ImageEditorScene.h"
+#include "image/ImageExceptions.h"
+#include "DraftItemNode.h"
+
+#include "draftitems/LineDraftItemNode.h"
+#include "draftitems/BoxDraftItemNode.h"
+
+DraftItemNodeFactory::DraftItemNodeFactory(ImageEditorScene* scene) : VisualNodeFactory(scene)
+{
+	registerStandardDraftItems();
+}
+
+DraftItemNode* DraftItemNodeFactory::createDefaultNode(const std::shared_ptr<DraftItem>& item) const
+{
+	// Each draft item must be explicitly registered, so there is no "default" node type
+	Q_UNUSED(item);
+	throw ImageEditorException{nullptr, _EXCPT("Tried to create a draft item node of an unknown type")};
+}
+
+void DraftItemNodeFactory::registerStandardDraftItems()
+{
+	REGISTER_NODEFACTORY_TYPE(LineDraftItemNode);
+	REGISTER_NODEFACTORY_TYPE(BoxDraftItemNode);
+}
diff --git a/Grinder/ui/image/DraftItemNodeFactory.h b/Grinder/ui/image/DraftItemNodeFactory.h
new file mode 100644
index 0000000000000000000000000000000000000000..da08b082693f190a5eb2068059f8d5ec2ad56704
--- /dev/null
+++ b/Grinder/ui/image/DraftItemNodeFactory.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: DraftItemNodeFactory.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef DRAFTITEMNODEFACTORY_H
+#define DRAFTITEMNODEFACTORY_H
+
+#include "ui/visscene/VisualNodeFactory.h"
+#include "image/DraftItemType.h"
+
+namespace grndr
+{
+	class ImageEditorScene;	
+	class DraftItem;
+	class DraftItemNode;
+
+	class DraftItemNodeFactory : public VisualNodeFactory<DraftItemNode, DraftItem, DraftItemType, ImageEditorScene>
+	{
+	public:
+		DraftItemNodeFactory(ImageEditorScene* scene);
+
+	protected:
+		virtual DraftItemNode* createDefaultNode(const std::shared_ptr<DraftItem>& item) const override;
+
+	private:
+		void registerStandardDraftItems();
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditor.cpp b/Grinder/ui/image/ImageEditor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e05bb36c17c3ab42493dee2eb23ba7cbed27b703
--- /dev/null
+++ b/Grinder/ui/image/ImageEditor.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: ImageEditor.cpp
+ * Date: 27.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditor.h"
+
+ImageEditor::ImageEditor(const Block* block) :
+	_editorController{this}, _editorEnvironment{&_editorController}, _editorTools{this}, _editorDockWidget{new ImageEditorDockWidget{}}, _editorWidget{new ImageEditorWidget{this, _editorDockWidget}}
+{
+	// Assign the editor widget to the dock widget
+	_editorDockWidget->setWidget(_editorWidget);
+	_editorDockWidget->updateDockName(block);
+}
diff --git a/Grinder/ui/image/ImageEditor.h b/Grinder/ui/image/ImageEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..eb51378e7d67c6b29109d3e0e3f98d490937c36c
--- /dev/null
+++ b/Grinder/ui/image/ImageEditor.h
@@ -0,0 +1,49 @@
+/******************************************************************************
+ * File: ImageEditor.h
+ * Date: 27.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITOR_H
+#define IMAGEEDITOR_H
+
+#include "controller/ImageEditorController.h"
+#include "ImageEditorEnvironment.h"
+#include "ImageEditorDockWidget.h"
+#include "ImageEditorWidget.h"
+#include "ImageEditorToolList.h"
+
+namespace grndr
+{
+	class Block;
+
+	class ImageEditor : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditor(const Block* block);
+
+	public:
+		ImageEditorController& controller() { return _editorController; }
+		const ImageEditorController& controller() const { return _editorController; }
+		ImageEditorEnvironment& environment() { return _editorEnvironment; }
+		const ImageEditorEnvironment& environment() const { return _editorEnvironment; }
+		ImageEditorToolList& editorTools() { return _editorTools; }
+		const ImageEditorToolList& editorTools() const { return _editorTools; }
+
+		ImageEditorDockWidget* dockWidget() { return _editorDockWidget; }
+		const ImageEditorDockWidget* dockWidget() const { return _editorDockWidget; }
+		ImageEditorWidget* editorWidget() { return _editorWidget; }
+		const ImageEditorWidget* editorWidget() const { return _editorWidget; }
+
+	private:
+		ImageEditorController _editorController;
+		ImageEditorEnvironment _editorEnvironment;
+		ImageEditorToolList _editorTools;
+
+		ImageEditorDockWidget* _editorDockWidget{nullptr};
+		ImageEditorWidget* _editorWidget{nullptr};		
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorComponent.cpp b/Grinder/ui/image/ImageEditorComponent.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..caaa8a7a10715867044ccf04049d397b2f378367
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorComponent.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: ImageEditorComponent.cpp
+ * Date: 28.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorComponent.h"
+
+ImageEditorComponent::ImageEditorComponent(ImageEditor* imageEditor) :
+	_imageEditor{imageEditor}
+{
+
+}
diff --git a/Grinder/ui/image/ImageEditorComponent.h b/Grinder/ui/image/ImageEditorComponent.h
new file mode 100644
index 0000000000000000000000000000000000000000..95936b61e10b87fb45ce0f9b0f452f676ab247ab
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorComponent.h
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: ImageEditorComponent.h
+ * Date: 27.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORCOMPONENT_H
+#define IMAGEEDITORCOMPONENT_H
+
+namespace grndr
+{
+	class ImageEditor;
+
+	class ImageEditorComponent
+	{
+	public:
+		ImageEditorComponent(ImageEditor* imageEditor = nullptr);
+
+	public:
+		ImageEditor* imageEditor() { return _imageEditor; }
+		const ImageEditor* imageEditor() const { return _imageEditor; }
+
+	protected:
+		ImageEditor* _imageEditor{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorDockWidget.cpp b/Grinder/ui/image/ImageEditorDockWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..456fe4110e77e5e0a9fafc5425ab6e6ed3298967
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorDockWidget.cpp
@@ -0,0 +1,44 @@
+/******************************************************************************
+ * File: ImageEditorDockWidget.cpp
+ * Date: 13.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorDockWidget.h"
+#include "ImageEditorWidget.h"
+#include "core/GrinderApplication.h"
+#include "ui/StyleSheet.h"
+#include "res/Resources.h"
+
+ImageEditorDockWidget::ImageEditorDockWidget(QWidget *parent) : QDockWidget(parent)
+{
+	// Some basic setup
+	setAttribute(Qt::WA_DeleteOnClose);
+	setFont(GrinderApplication::boldFont(this));
+	setFeatures(QDockWidget::AllDockWidgetFeatures);
+
+	setStyleSheet(StyleSheet::loadStyleSheet(FILE_STYLESHEET_IMAGEEDITORDOCKWIDGET));
+
+	connect(this, &QDockWidget::visibilityChanged, this, &ImageEditorDockWidget::dockShown);
+}
+
+void ImageEditorDockWidget::closeEvent(QCloseEvent* event)
+{
+	emit dockClosed(this);
+	QDockWidget::closeEvent(event);
+}
+
+void ImageEditorDockWidget::dockShown(bool shown)
+{
+	if (shown)
+	{
+		if (auto editorWidget = findChild<ImageEditorWidget*>())
+			editorWidget->setFocus();
+	}
+}
+
+void ImageEditorDockWidget::updateDockName(const Block* block)
+{
+	setObjectName("ImageEditor@" + block->getFormattedName());
+	setWindowTitle(block->getFormattedName());
+}
diff --git a/Grinder/ui/image/ImageEditorDockWidget.h b/Grinder/ui/image/ImageEditorDockWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..d99e1b49a8eab0634749bd97d5d970888a9a647b
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorDockWidget.h
@@ -0,0 +1,37 @@
+/******************************************************************************
+ * File: ImageEditorDockWidget.h
+ * Date: 13.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORDOCKWIDGET_H
+#define IMAGEEDITORDOCKWIDGET_H
+
+#include <QDockWidget>
+#include <QDebug>
+
+namespace grndr
+{
+	class Block;
+
+	class ImageEditorDockWidget : public QDockWidget
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorDockWidget(QWidget *parent = nullptr);
+
+	public:
+		void updateDockName(const Block* block);
+
+	signals:
+		void dockClosed(ImageEditorDockWidget*);
+
+	protected:
+		virtual void closeEvent(QCloseEvent* event) override;
+
+	private slots:
+		void dockShown(bool shown);
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorEnvironment.cpp b/Grinder/ui/image/ImageEditorEnvironment.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c07991f03b813d621cfee75c6a59a7e22084835c
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorEnvironment.cpp
@@ -0,0 +1,41 @@
+/******************************************************************************
+ * File: ImageEditorEnvironment.cpp
+ * Date: 26.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorEnvironment.h"
+
+ImageEditorEnvironment::ImageEditorEnvironment(ImageEditorController* controller) :
+	_editorController{controller}
+{
+	if (!controller)
+		throw std::invalid_argument{_EXCPT("controller may not be null")};
+}
+
+void ImageEditorEnvironment::setPrimaryColor(QColor color)
+{
+	if (color != _primaryColor)
+	{
+		_primaryColor = color;
+		emit primaryColorChanged(color);
+	}
+}
+
+void ImageEditorEnvironment::setShowDirectionArrows(bool show)
+{
+	if (show != _showDirectionArrows)
+	{
+		_showDirectionArrows = show;
+		emit showDirectionArrowsChanged(show);
+	}
+}
+
+void ImageEditorEnvironment::setShowTags(bool show)
+{
+	if (show != _showTags)
+	{
+		_showTags = show;
+		emit showTagsChanged(show);
+	}
+}
diff --git a/Grinder/ui/image/ImageEditorEnvironment.h b/Grinder/ui/image/ImageEditorEnvironment.h
new file mode 100644
index 0000000000000000000000000000000000000000..b45a5fd55840bfdbeda99360aaf638dbd408cf21
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorEnvironment.h
@@ -0,0 +1,48 @@
+/******************************************************************************
+ * File: ImageEditorEnvironment.h
+ * Date: 26.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORENVIRONMENT_H
+#define IMAGEEDITORENVIRONMENT_H
+
+#include <QObject>
+#include <QColor>
+
+namespace grndr
+{
+	class ImageEditorController;
+
+	class ImageEditorEnvironment : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorEnvironment(ImageEditorController* controller);
+
+	public:
+		QColor getPrimaryColor() const { return _primaryColor; }
+		void setPrimaryColor(QColor color);
+
+		bool showDirectionArrows() const { return _showDirectionArrows; }
+		void setShowDirectionArrows(bool show);
+		bool showTags() const { return _showTags; }
+		void setShowTags(bool show);
+
+	signals:
+		void primaryColorChanged(QColor);
+		void showDirectionArrowsChanged(bool);
+		void showTagsChanged(bool);
+
+	private:
+		ImageEditorController* _editorController{nullptr};
+
+	private:
+		QColor _primaryColor{255, 255, 255};
+
+		bool _showDirectionArrows{true};
+		bool _showTags{true};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorManager.cpp b/Grinder/ui/image/ImageEditorManager.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0a8646c5e4ed20ae4f75b53ab9c0ddc7a7b4d9be
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorManager.cpp
@@ -0,0 +1,125 @@
+/******************************************************************************
+ * File: ImageEditorManager.cpp
+ * Date: 13.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorManager.h"
+#include "ImageEditorWidget.h"
+#include "core/GrinderApplication.h"
+#include "image/ImageExceptions.h"
+#include "pipeline/Pipeline.h"
+
+ImageEditorManager::ImageEditorManager(PipelineManager* pipelineManager) : QObject(pipelineManager)
+{
+	connect(pipelineManager, &PipelineManager::pipelineRemoved, this, &ImageEditorManager::pipelineRemoved);
+}
+
+void ImageEditorManager::showEditor(const Block* block, const std::shared_ptr<grndr::ImageBuild>& imageBuild)
+{
+	if (!block)
+		throw std::invalid_argument{_EXCPT("block may not be null")};
+
+	if (!imageBuild)
+		throw std::invalid_argument{_EXCPT("imageBuild may not be null")};
+
+	auto editor = findEditor(block);
+
+	// Create an image editor if necessary
+	if (!editor)
+	{
+		if (!(editor = createEditor(block)))
+			throw ImageEditorException{nullptr, _EXCPT(QString{"Unable to create an image editor for block '%1'"}.arg(block->getName()))};
+	}
+
+	editor->editorWidget()->setImageBuild(imageBuild);
+
+	// Let whoever is interested in the newly created editor handle showing it
+	emit editorShown(editor);
+}
+
+void ImageEditorManager::closeEditor(const Block* block)
+{
+	// If an image editor exists for the block, close and remove it
+	if (auto editor = findEditor(block))
+	{
+		editor->dockWidget()->close();
+		removeEditor(block);
+	}
+}
+
+ImageEditor* ImageEditorManager::createEditor(const Block* block)
+{
+	auto editor = std::make_unique<ImageEditor>(block);
+
+	// When a dock is closed, remove its associated editor
+	connect(editor->dockWidget(), &ImageEditorDockWidget::dockClosed, this, &ImageEditorManager::dockClosed);
+
+	_editors.emplace(block, std::move(editor));
+
+	// If a block is removed, close any open associated editors
+	connect(block->pipeline(), &Pipeline::blockRemoved, this, &ImageEditorManager::blockRemoved);
+
+	// Reflect pipeline and block renamings
+	connect(block, &PipelineItem::itemRenamed, this, &ImageEditorManager::updateDockNames);
+	connect(block->pipeline(), &Pipeline::pipelineRenamed, this, &ImageEditorManager::updateDockNames);
+
+	return _editors[block].get();
+}
+
+void ImageEditorManager::removeEditor(const Block* block)
+{
+	if (auto editor = findEditor(block))
+		emit editorClosed(editor);
+
+	// Disconnect any signals from the block
+	disconnect(block, nullptr, this, nullptr);
+
+	_editors.erase(block);
+
+	// Disconnect any signals from the block's pipeline if no further blocks of the same pipeline are associated with an editor
+	if (std::none_of(_editors.cbegin(), _editors.cend(), [block](const auto& editor) { return editor.first->pipeline() == block->pipeline(); }))
+		disconnect(block->pipeline(), nullptr, this, nullptr);
+}
+
+ImageEditor* ImageEditorManager::findEditor(const Block* block) const
+{
+	auto it = _editors.find(block);
+
+	if (it != _editors.end())
+		return it->second.get();
+	else
+		return nullptr;
+}
+
+void ImageEditorManager::dockClosed(ImageEditorDockWidget* dock)
+{
+	// Find the editor associated with the dock and remove it
+	for (const auto& editor : _editors)
+	{
+		if (editor.second->dockWidget() == dock)
+		{
+			removeEditor(editor.first);
+			break;
+		}
+	}
+}
+
+void ImageEditorManager::pipelineRemoved(const std::shared_ptr<Pipeline>& pipeline)
+{
+	// Close any editors associated with one of the pipeline's blocks
+	for (const auto& block : pipeline->blocks())
+		closeEditor(block.get());
+}
+
+void ImageEditorManager::blockRemoved(const std::shared_ptr<Block>& block)
+{
+	closeEditor(block.get());
+}
+
+void ImageEditorManager::updateDockNames()
+{
+	// Update all dock names
+	for (const auto& editor : _editors)
+		editor.second->dockWidget()->updateDockName(editor.first);
+}
diff --git a/Grinder/ui/image/ImageEditorManager.h b/Grinder/ui/image/ImageEditorManager.h
new file mode 100644
index 0000000000000000000000000000000000000000..52a645fadfcac774b817dcdc1a3a6ab126e81b9e
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorManager.h
@@ -0,0 +1,53 @@
+/******************************************************************************
+ * File: ImageEditorManager.h
+ * Date: 13.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORMANAGER_H
+#define IMAGEEDITORMANAGER_H
+
+#include <memory>
+
+#include "ImageEditor.h"
+
+namespace grndr
+{
+	class PipelineManager;
+	class Pipeline;
+	class ImageBuild;
+
+	class ImageEditorManager : public QObject
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorManager(PipelineManager* pipelineManager);
+
+	public:
+		void showEditor(const Block* block, const std::shared_ptr<ImageBuild>& imageBuild);
+		void closeEditor(const Block* block);
+
+	signals:
+		void editorShown(ImageEditor*);
+		void editorClosed(ImageEditor*);
+
+	private:
+		ImageEditor* createEditor(const Block* block);
+		void removeEditor(const Block* block);
+
+		ImageEditor* findEditor(const Block* block) const;
+
+	private slots:
+		void dockClosed(ImageEditorDockWidget* dock);
+
+		void pipelineRemoved(const std::shared_ptr<Pipeline>& pipeline);
+		void blockRemoved(const std::shared_ptr<Block>& block);
+
+		void updateDockNames();
+
+	private:
+		std::map<const Block*, std::unique_ptr<ImageEditor>> _editors;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorNode.cpp b/Grinder/ui/image/ImageEditorNode.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..fa8d8cd53600f15d6f99c83babfa8e085fed25a2
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorNode.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: ImageEditorNode.cpp
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorNode.h"
+#include "ImageEditorScene.h"
+#include "ImageEditorView.h"
+
+ImageEditorNode::ImageEditorNode(ImageEditorScene* scene, QGraphicsItem* parent) : VisualNode(scene, parent), ImageEditorComponent(scene->imageEditor()),
+	_scene{scene}, _style{scene->view()->sceneStyle()}
+{
+
+}
diff --git a/Grinder/ui/image/ImageEditorNode.h b/Grinder/ui/image/ImageEditorNode.h
new file mode 100644
index 0000000000000000000000000000000000000000..1bc2d1bbd359436b9c96fbd537781e288970410d
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorNode.h
@@ -0,0 +1,34 @@
+/******************************************************************************
+ * File: ImageEditorNode.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORNODE_H
+#define IMAGEEDITORNODE_H
+
+#include <memory>
+
+#include "ui/visscene/VisualNode.h"
+#include "ImageEditorComponent.h"
+
+namespace grndr
+{
+	class ImageEditorScene;
+	class ImageEditor;
+	class ImageEditorStyle;
+	class DraftItem;
+
+	class ImageEditorNode : public VisualNode, public ImageEditorComponent
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorNode(ImageEditorScene* scene, QGraphicsItem* parent = nullptr);
+
+	protected:
+		ImageEditorScene* _scene{nullptr};
+		const ImageEditorStyle& _style;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorPropertyWidget.cpp b/Grinder/ui/image/ImageEditorPropertyWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..7f312b3a0752f0ea9d873bee3ad9e0950c906c60
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorPropertyWidget.cpp
@@ -0,0 +1,135 @@
+/******************************************************************************
+ * File: ImageEditorPropertyWidget.cpp
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorPropertyWidget.h"
+#include "ImageEditorWidget.h"
+#include "ImageEditorScene.h"
+#include "core/GrinderApplication.h"
+#include "controller/ImageEditorController.h"
+#include "util/UIUtils.h"
+
+ImageEditorPropertyWidget::ImageEditorPropertyWidget(QWidget *parent) : QWidget(parent),
+	_layout{new QGridLayout{this}}
+{
+	_layout->setContentsMargins(6, 6, 6, 6);
+	_layout->setHorizontalSpacing(6);
+	_layout->setVerticalSpacing(4);
+	setLayout(_layout);
+}
+
+void ImageEditorPropertyWidget::assignUiComponents(ImageEditor* imageEditor)
+{
+	if (!imageEditor)
+		throw std::invalid_argument{_EXCPT("imageEditor may not be null")};
+
+	_imageEditor = imageEditor;
+
+	// Listen for various events in order to update the property list accordingly
+	connect(&_imageEditor->editorTools(), &ImageEditorToolList::activeToolChanged, this, &ImageEditorPropertyWidget::imageEditorToolChanged);
+	connect(&_imageEditor->controller(), &ImageEditorController::imageBuildSwitching, this, &ImageEditorPropertyWidget::imageBuildSwitching);
+	connect(&_imageEditor->controller(), &ImageEditorController::imageBuildSwitched, this, &ImageEditorPropertyWidget::imageBuildSwitched);
+}
+
+void ImageEditorPropertyWidget::clear()
+{
+	// Remove all properties from the layout
+	UIUtils::removeChildrenFromLayout(_layout);
+}
+
+void ImageEditorPropertyWidget::addProperties(const PropertyVector* properties)
+{
+	clear();
+
+	bool propertiesEmpty = true;
+
+	if (properties)
+	{
+		for (auto& property : *properties)
+		{
+			if (!property->hasFlag(PropertyBase::Flag::ReadOnly) && !property->hasFlag(PropertyBase::Flag::Hidden))
+			{
+				addProperty(property);
+				propertiesEmpty = false;
+			}
+		}
+	}
+
+	if (propertiesEmpty)
+	{
+		auto label = new QLabel{"No properties to display"};
+		label->setEnabled(false);
+		_layout->addWidget(label, 0, 0, 1, 2, Qt::AlignCenter);
+	}
+
+	// Add a spacer item to align all properties
+	_layout->addItem(new QSpacerItem{0, 0, QSizePolicy::Expanding, QSizePolicy::Expanding}, _layout->rowCount(), 1);
+}
+
+void ImageEditorPropertyWidget::addProperty(const std::shared_ptr<PropertyBase>& property)
+{
+	auto editor = property->createEditor(nullptr);
+	auto label = new QLabel{property->getName() + ":"};
+	label->setFont(GrinderApplication::boldFont(label));
+
+	if (!editor)
+		editor = new QLabel{"No editor available"};
+
+	editor->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
+
+	int row = _layout->rowCount();
+	_layout->addWidget(label, row, 0);
+	_layout->addWidget(editor, row, 1);
+}
+
+void ImageEditorPropertyWidget::populateProperties()
+{
+	const PropertyVector* properties = &_imageEditor->editorTools().activeTool()->properties();	// Use the active tool's properties by default
+
+	// See if there are any items selected in the editor
+	if (auto scene = _imageEditor->controller().activeScene())
+	{
+		auto selectedItems = scene->getNodes<DraftItemNode>(true);
+
+		if (!selectedItems.empty())
+		{
+			// TODO: Multi-selection
+			if (selectedItems.size() == 1)
+			{
+				if (auto draftItem = selectedItems.front()->draftItem().lock())	// Make sure that the underlying draft item still exists
+					properties = &draftItem->properties();
+			}
+		}
+	}
+
+	addProperties(properties);
+}
+
+void ImageEditorPropertyWidget::imageEditorToolChanged(const ImageEditorTool* tool)
+{
+	Q_UNUSED(tool);
+	populateProperties();
+}
+
+void ImageEditorPropertyWidget::imageBuildSwitching(ImageBuild* imageBuild)
+{
+	Q_UNUSED(imageBuild);
+
+	clear();
+
+	if (auto scene = _imageEditor->controller().activeScene())
+		disconnect(scene, &ImageEditorScene::selectionChanged, this, &ImageEditorPropertyWidget::populateProperties);
+}
+
+void ImageEditorPropertyWidget::imageBuildSwitched(ImageBuild* imageBuild)
+{
+	Q_UNUSED(imageBuild);
+
+	// Listen for selection changes in the current editor scene
+	if (auto scene = _imageEditor->controller().activeScene())
+		connect(scene, &ImageEditorScene::selectionChanged, this, &ImageEditorPropertyWidget::populateProperties);
+
+	populateProperties();
+}
diff --git a/Grinder/ui/image/ImageEditorPropertyWidget.h b/Grinder/ui/image/ImageEditorPropertyWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..f2e772455234f2b4ba37376cf2b2e0afce76abf3
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorPropertyWidget.h
@@ -0,0 +1,51 @@
+/******************************************************************************
+ * File: ImageEditorPropertyWidget.h
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORPROPERTYWIDGET_H
+#define IMAGEEDITORPROPERTYWIDGET_H
+
+#include <QWidget>
+#include <QGridLayout>
+
+#include "ImageEditorComponent.h"
+#include "common/PropertyVector.h"
+
+namespace grndr
+{
+	class ImageEditor;
+	class ImageEditorTool;
+	class ImageBuild;
+
+	class ImageEditorPropertyWidget : public QWidget, public ImageEditorComponent
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorPropertyWidget(QWidget *parent = nullptr);
+
+	public:
+		void assignUiComponents(ImageEditor* imageEditor);
+
+	public:
+		void clear();
+
+	private:
+		void addProperties(const PropertyVector* properties);
+		void addProperty(const std::shared_ptr<PropertyBase>& property);
+
+	private slots:
+		void populateProperties();
+
+		void imageEditorToolChanged(const ImageEditorTool* tool);
+
+		void imageBuildSwitching(ImageBuild* imageBuild);
+		void imageBuildSwitched(ImageBuild* imageBuild);
+
+	private:
+		QGridLayout* _layout{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorScene.cpp b/Grinder/ui/image/ImageEditorScene.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..55ba57c5af2b705c5ddfa1dd9a59cd96e82e14a8
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorScene.cpp
@@ -0,0 +1,192 @@
+/******************************************************************************
+ * File: ImageEditorScene.cpp
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorScene.h"
+#include "ImageEditor.h"
+#include "DraftItemNode.h"
+#include "image/ImageExceptions.h"
+#include "controller/ImageEditorController.h"
+#include "util/CVUtils.h"
+#include "res/Resources.h"
+
+ImageEditorScene::ImageEditorScene(ImageEditor* imageEditor, ImageBuild* imageBuild, ImageEditorView* view) : VisualScene(view), ImageEditorComponent(imageEditor),
+	_imageBuild{imageBuild}, _nodeFactory{this}
+{
+	if (!imageEditor)
+		throw std::invalid_argument{_EXCPT("imageEditor may not be null")};
+
+	if (!imageBuild)
+		throw std::invalid_argument{_EXCPT("imageBuild may not be null")};
+
+	_drawGrid = false;
+}
+
+void ImageEditorScene::buildScene()
+{
+	clear();
+
+	createBackgroundItem();
+
+	// Add all draft items of each layer to the scene
+	if (_imageBuild)
+	{
+		for (const auto& layer : _imageBuild->layers())
+		{
+			for (const auto& item : layer->draftItems())
+				createDraftItemNode(item);
+		}
+	}
+}
+
+void ImageEditorScene::refreshImage(bool centerOnImage)
+{
+	// Try to get an image from the image build's data
+	auto image = CVUtils::matrixToPixmap(_imageBuild->imageData());
+
+	if (!image.isNull())
+	{
+		_imageItem->setPixmap(image);
+
+		// Calculate the new scene rectangle -> add a certain border around the image which is always positioned at (0, 0)
+		QRectF sceneRect{QPointF{0.0, 0.0}, image.size()};
+		sceneRect += _view->sceneStyle().getEditorStyle().editorMargins;
+		setSceneRect(sceneRect);
+
+		if (centerOnImage)
+			_view->centerOn(_imageItem);
+	}
+	else
+	{
+		_imageItem->setPixmap(QPixmap{FILE_ICON_EDITOR_NOIMAGE});	// If the image is invalid, show a question mark indicating an error
+		throw ImageBuildException{_imageBuild, _EXCPT("Invalid or incompatible image data")};
+	}
+
+	update();
+}
+
+void ImageEditorScene::fitViewToImage()
+{
+	_view->fitToWindow(_imageItem);
+}
+
+void ImageEditorScene::createDraftItemNode(const std::shared_ptr<DraftItem>& item)
+{
+	if (!item)
+		throw std::invalid_argument{_EXCPT("item may not be null")};
+
+	if (!findDraftItemNode(item.get()))
+	{
+		auto node = _nodeFactory.createNode(item);
+
+		try {	// Propagate initialization errors to the caller
+			node->initDraftItemNode();
+		}
+		catch (...) {
+			throw;
+		}
+
+		addItem(node);
+	}
+}
+
+void ImageEditorScene::removeDraftItemNode(DraftItemNode* node)
+{
+	if (node)
+		delete node;
+}
+
+void ImageEditorScene::updateDraftItemsOrder()
+{
+	for (auto& draftItem : findDraftItemNodes())
+		draftItem->updateZOrder();
+}
+
+void ImageEditorScene::updateDraftItemsVisibility(const Layer* layer)
+{
+	for (auto& draftItem : findDraftItemNodes(layer))
+		draftItem->updateVisibility();
+}
+
+void ImageEditorScene::mousePressEvent(QGraphicsSceneMouseEvent* mouseEvent)
+{
+	if (handleMouseEvent(mouseEvent, &ImageEditorTool::handleMousePressEvent) != ImageEditorTool::InputEventResult::Process)
+		VisualScene::mousePressEvent(mouseEvent);	// The active tool didn't process the event, so call the default handler
+}
+
+void ImageEditorScene::mouseMoveEvent(QGraphicsSceneMouseEvent* mouseEvent)
+{
+	if (handleMouseEvent(mouseEvent, &ImageEditorTool::handleMouseMoveEvent) != ImageEditorTool::InputEventResult::Process)
+		VisualScene::mouseMoveEvent(mouseEvent);	// The active tool didn't process the event, so call the default handler
+}
+
+void ImageEditorScene::mouseReleaseEvent(QGraphicsSceneMouseEvent* mouseEvent)
+{
+	if (handleMouseEvent(mouseEvent, &ImageEditorTool::handleMouseReleaseEvent) != ImageEditorTool::InputEventResult::Process)
+		VisualScene::mouseReleaseEvent(mouseEvent);	// The active tool didn't process the event, so call the default handler
+}
+
+void ImageEditorScene::keyPressEvent(QKeyEvent* keyEvent)
+{
+	if (handleKeyEvent(keyEvent, &ImageEditorTool::handleKeyPressEvent) != ImageEditorTool::InputEventResult::Process)
+		VisualScene::keyPressEvent(keyEvent);	// The active tool didn't process the event, so call the default handler
+}
+
+void ImageEditorScene::keyReleaseEvent(QKeyEvent* keyEvent)
+{
+	if (handleKeyEvent(keyEvent, &ImageEditorTool::handleKeyReleaseEvent) != ImageEditorTool::InputEventResult::Process)
+		VisualScene::keyReleaseEvent(keyEvent);	// The active tool didn't process the event, so call the default handler
+}
+
+void ImageEditorScene::createBackgroundItem()
+{
+	// Set up the background item
+	_imageItem = new QGraphicsPixmapItem{};
+	_imageItem->setPos(QPointF{0.0, 0.0});
+	_imageItem->setZValue(-1.0);	// Always the lowest item
+	_imageItem->setShapeMode(QGraphicsPixmapItem::BoundingRectShape);
+	_imageItem->setTransformationMode(Qt::SmoothTransformation);
+
+	addItem(_imageItem);
+}
+
+ImageEditorTool::InputEventResult ImageEditorScene::handleMouseEvent(QGraphicsSceneMouseEvent* mouseEvent, std::function<ImageEditorTool::InputEventResult(ImageEditorTool*, QGraphicsSceneMouseEvent*)> handler)
+{
+	mouseEvent->ignore();
+
+	// Let the active tool handle the mouse event
+	if (auto activeTool = _imageEditor->editorTools().activeTool())
+		return handler(activeTool, mouseEvent);
+
+	return ImageEditorTool::InputEventResult::Ignore;
+}
+
+VisualSceneInputHandler::InputEventResult ImageEditorScene::handleKeyEvent(QKeyEvent* keyEvent, std::function<VisualSceneInputHandler::InputEventResult (ImageEditorTool*, QKeyEvent*)> handler)
+{
+	keyEvent->ignore();
+
+	// Let the active tool handle the key event
+	if (auto activeTool = _imageEditor->editorTools().activeTool())
+		return handler(activeTool, keyEvent);
+
+	return ImageEditorTool::InputEventResult::Ignore;
+}
+
+DraftItemNode* ImageEditorScene::_findDraftItemNode(const DraftItem* item) const
+{
+	for (const auto& node : items())
+	{
+		if (auto itemNode = dynamic_cast<DraftItemNode*>(node))
+		{
+			if (auto draftItem = itemNode->draftItem().lock())
+			{
+				if (draftItem.get() == item)
+					return itemNode;
+			}
+		}
+	}
+
+	return nullptr;
+}
diff --git a/Grinder/ui/image/ImageEditorScene.h b/Grinder/ui/image/ImageEditorScene.h
new file mode 100644
index 0000000000000000000000000000000000000000..b4dc7d4049edadb995ce9e2ccc24ba7bbd50827b
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorScene.h
@@ -0,0 +1,84 @@
+/******************************************************************************
+ * File: ImageEditorScene.h
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORSCENE_H
+#define IMAGEEDITORSCENE_H
+
+#include "ui/visscene/VisualScene.h"
+#include "ImageEditorView.h"
+#include "ImageEditorComponent.h"
+#include "DraftItemNodeFactory.h"
+#include "ImageEditorTool.h"
+#include "image/ImageBuild.h"
+
+namespace grndr
+{
+	class ImageEditor;
+	class ImageEditorNode;
+
+	class ImageEditorScene : public VisualScene<ImageEditorView>, public ImageEditorComponent
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorScene(ImageEditor* imageEditor, ImageBuild* imageBuild, ImageEditorView* view);
+
+	public:
+		void buildScene();
+
+		void refreshImage(bool centerOnImage = false);
+		void fitViewToImage();
+
+		void createDraftItemNode(const std::shared_ptr<DraftItem>& item);
+		void removeDraftItemNode(const std::shared_ptr<DraftItem>& item) { removeDraftItemNode(findDraftItemNode(item.get())); }
+		void removeDraftItemNode(DraftItemNode* node);
+
+		void updateDraftItemsOrder();
+		void updateDraftItemsVisibility(const Layer* layer = nullptr);
+
+	public:
+		DraftItemNode* findDraftItemNode(const DraftItem* item) { return _findDraftItemNode(item); }
+		const DraftItemNode* findDraftItemNode(const DraftItem* item) const { return _findDraftItemNode(item); }
+		std::vector<DraftItemNode*> findDraftItemNodes(const Layer* layer = nullptr) { return _findDraftItemNodes<DraftItemNode*>(layer); }
+		std::vector<const DraftItemNode*> findDraftItemNodes(const Layer* layer = nullptr) const { return _findDraftItemNodes<const DraftItemNode*>(layer); }
+
+	public:
+		ImageBuild* imageBuild() { return _imageBuild; }
+		const ImageBuild* imageBuild() const { return _imageBuild; }
+
+		const DraftItemNodeFactory& nodeFactory() const { return _nodeFactory; }
+
+	protected:
+		virtual void mousePressEvent(QGraphicsSceneMouseEvent* mouseEvent) override;
+		virtual void mouseMoveEvent(QGraphicsSceneMouseEvent* mouseEvent) override;
+		virtual void mouseReleaseEvent(QGraphicsSceneMouseEvent* mouseEvent) override;
+
+		virtual void keyPressEvent(QKeyEvent* keyEvent) override;
+		virtual void keyReleaseEvent(QKeyEvent* keyEvent) override;
+
+	private:
+		void createBackgroundItem();
+
+		ImageEditorTool::InputEventResult handleMouseEvent(QGraphicsSceneMouseEvent* mouseEvent, std::function<ImageEditorTool::InputEventResult(ImageEditorTool*, QGraphicsSceneMouseEvent*)> handler);
+		ImageEditorTool::InputEventResult handleKeyEvent(QKeyEvent* keyEvent, std::function<ImageEditorTool::InputEventResult(ImageEditorTool*, QKeyEvent*)> handler);
+
+	private:
+		DraftItemNode* _findDraftItemNode(const DraftItem* item) const;
+		template<typename ValType>
+		std::vector<ValType> _findDraftItemNodes(const Layer* layer = nullptr) const;
+
+	private:
+		ImageBuild* _imageBuild{nullptr};
+
+		DraftItemNodeFactory _nodeFactory;
+
+	private:
+		QGraphicsPixmapItem* _imageItem{nullptr};
+	};
+}
+
+#include "ImageEditorScene.impl.h"
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorScene.impl.h b/Grinder/ui/image/ImageEditorScene.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..6473129417c5dd3d379c18a870cb99d154969542
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorScene.impl.h
@@ -0,0 +1,28 @@
+/******************************************************************************
+ * File: ImageEditorScene.impl.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorScene.h"
+#include "DraftItemNode.h"
+
+template<typename ValType>
+std::vector<ValType> ImageEditorScene::_findDraftItemNodes(const Layer* layer) const
+{
+	std::vector<ValType> nodes;
+
+	for (const auto& node : items())
+	{
+		if (auto itemNode = dynamic_cast<DraftItemNode*>(node))
+		{
+			if (auto item = itemNode->draftItem().lock())
+			{
+				if (!layer || item->layer() == layer)
+					nodes.push_back(itemNode);
+			}
+		}
+	}
+
+	return nodes;
+}
diff --git a/Grinder/ui/image/ImageEditorStyle.cpp b/Grinder/ui/image/ImageEditorStyle.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..392d7292a94914421ff5a533afff7a30d0bf814c
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorStyle.cpp
@@ -0,0 +1,12 @@
+/******************************************************************************
+ * File: ImageEditorStyle.cpp
+ * Date: 16.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorStyle.h"
+
+ImageEditorStyle::ImageEditorStyle()
+{
+	_viewStyle.maxZoomLevel = 10.0;
+}
diff --git a/Grinder/ui/image/ImageEditorStyle.h b/Grinder/ui/image/ImageEditorStyle.h
new file mode 100644
index 0000000000000000000000000000000000000000..1ea70e1e0899311ab61c90a422fc00e6cbb5aca6
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorStyle.h
@@ -0,0 +1,57 @@
+/******************************************************************************
+ * File: ImageEditorStyle.h
+ * Date: 16.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORSTYLE_H
+#define IMAGEEDITORSTYLE_H
+
+#include <QMarginsF>
+#include <QPalette>
+
+#include "ui/visscene/VisualSceneStyle.h"
+#include "image/DraftItemRendererBase.h"
+
+namespace grndr
+{
+	class ImageEditorStyle : public VisualSceneStyle
+	{
+	public:
+		struct EditorStyle
+		{
+			QMarginsF editorMargins{100.0, 100.0, 100.0, 100.0};
+		};
+
+		struct DraftItemNodeStyle : public DraftItemRendererBase::RendererStyle
+		{
+
+		};
+
+		struct DragHandleStyle
+		{
+			QSizeF handleSize{15.0, 15.0};
+			float radius{5.0};
+			QColor outerBorderColor{255, 255, 255};
+			float outerBorderWidth{3.0};
+			QColor innerBorderColor{0, 0, 0};
+			float innerBorderWidth{1.0};
+		};
+
+	public:
+		ImageEditorStyle();
+
+	public:
+		const EditorStyle& getEditorStyle() const { return _editorStyle; }
+
+		const DraftItemNodeStyle& getDraftItemNodeStyle() const { return _draftItemNodeStyle; }
+		const DragHandleStyle& getDragHandleStyle() const { return _dragHandleStyle; }
+
+	private:
+		EditorStyle _editorStyle;
+
+		DraftItemNodeStyle _draftItemNodeStyle;
+		DragHandleStyle _dragHandleStyle;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorTool.cpp b/Grinder/ui/image/ImageEditorTool.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..afa19447a8ec7ba9ca6fbd7598f38bf3ca9d7d49
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorTool.cpp
@@ -0,0 +1,19 @@
+/******************************************************************************
+ * File: ImageEditorTool.cpp
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorTool.h"
+
+ImageEditorTool::ImageEditorTool(ImageEditor* imageEditor, QString name, QString icon, QString shortcut, QCursor cursor) : ImageEditorComponent(imageEditor),
+	_name{name}, _icon{icon}, _shortcut{shortcut}, _cursor{cursor}
+{
+	if (!imageEditor)
+		throw std::invalid_argument{_EXCPT("imageEditor may not be null")};
+}
+
+void ImageEditorTool::initImageEditorTool()
+{
+	createProperties();
+}
diff --git a/Grinder/ui/image/ImageEditorTool.h b/Grinder/ui/image/ImageEditorTool.h
new file mode 100644
index 0000000000000000000000000000000000000000..845ee1087b5eced3c44241494a08c0e80ba8cc3e
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorTool.h
@@ -0,0 +1,53 @@
+/******************************************************************************
+ * File: ImageEditorTool.h
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORTOOL_H
+#define IMAGEEDITORTOOL_H
+
+#include <QIcon>
+#include <QCursor>
+#include <QKeySequence>
+
+#include "common/PropertyObject.h"
+#include "ui/visscene/VisualSceneInputHandler.h"
+#include "ImageEditorComponent.h"
+
+namespace grndr
+{
+	class ImageEditor;
+
+	class ImageEditorTool : public PropertyObject, public ImageEditorComponent, public VisualSceneInputHandler
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorTool(ImageEditor* imageEditor, QString name, QString icon, QString shortcut, QCursor cursor = QCursor{Qt::ArrowCursor});
+
+	public:
+		void initImageEditorTool();
+
+	public:
+		virtual void toolActivated(ImageEditorTool* prevTool) { Q_UNUSED(prevTool); }
+		virtual void toolDeactivated(ImageEditorTool* nextTool) { Q_UNUSED(nextTool); }
+
+	public:
+		virtual QString getToolType() const = 0;
+
+		QString getName() const { return _name; }
+		QIcon getIcon() const { return _icon; }
+		QString getShortcut() const { return _shortcut; }
+		bool isActionShortcut() const { return _isActionShortcut; }
+		QCursor getCursor() const { return _cursor; }
+
+	protected:
+		QString _name{""};
+		QIcon _icon;
+		QString _shortcut;
+		bool _isActionShortcut{true};
+		QCursor _cursor;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorToolList.cpp b/Grinder/ui/image/ImageEditorToolList.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..fce04df66499b93612c065c14ce955f6e49ede9e
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorToolList.cpp
@@ -0,0 +1,109 @@
+/******************************************************************************
+ * File: ImageEditorToolList.cpp
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorToolList.h"
+#include "ImageEditor.h"
+#include "util/UIUtils.h"
+
+#include "tools/DefaultImageEditorTool.h"
+#include "tools/BoxDraftItemTool.h"
+#include "tools/LineDraftItemTool.h"
+#include "tools/ColorPickerTool.h"
+
+ImageEditorToolList::ImageEditorToolList(ImageEditor* imageEditor) : ImageEditorComponent(imageEditor)
+{
+	_tools.reserve(3);
+
+	// Create all tools but do not create actions yet
+	_tools.push_back(ToolEntry{createTool<DefaultImageEditorTool>(imageEditor), nullptr});
+	_tools.push_back(ToolEntry{createTool<ColorPickerTool>(imageEditor), nullptr});
+	_tools.push_back(ToolEntry{createTool<BoxDraftItemTool>(imageEditor), nullptr});
+	_tools.push_back(ToolEntry{createTool<LineDraftItemTool>(imageEditor), nullptr});
+}
+
+void ImageEditorToolList::assignUiComponents(QWidget* actionOwner)
+{
+	// Create all actions
+	for (auto& toolEntry : _tools)
+		toolEntry.toolAction = createAction(toolEntry.tool.get(), actionOwner);
+
+	activateDefaultTool();
+}
+
+void ImageEditorToolList::activateTool(QString toolType)
+{
+	for (auto& toolEntry : _tools)
+	{
+		if (toolEntry.tool->getToolType() == toolType)
+		{
+			setActiveTool(toolEntry.toolAction);
+			break;
+		}
+	}
+}
+
+void ImageEditorToolList::activateDefaultTool()
+{
+	activateTool(DefaultImageEditorTool::tool_type);
+}
+
+std::vector<QAction*> ImageEditorToolList::getActions() const
+{
+	std::vector<QAction*> actions;
+
+	for (auto& toolEntry : _tools)
+		actions.push_back(toolEntry.toolAction);
+
+	return actions;
+}
+
+void ImageEditorToolList::toolSelected()
+{
+	if (auto toolAction = dynamic_cast<QAction*>(sender()))
+		setActiveTool(toolAction);
+}
+
+QAction* ImageEditorToolList::createAction(const ImageEditorTool* tool, QWidget* owner)
+{
+	QString toolTip = QString{"%1 (%2)"}.arg(tool->getName()).arg(tool->getShortcut());
+	auto action = UIUtils::createAction(owner, tool->getName(), "", SLOT(toolSelected()), toolTip, tool->isActionShortcut() ? tool->getShortcut() : "", Qt::WidgetWithChildrenShortcut, this);
+
+	action->setCheckable(true);
+	action->setIcon(tool->getIcon());
+
+	return action;
+}
+
+void ImageEditorToolList::setActiveTool(QAction* toolAction)
+{
+	for (auto& toolEntry : _tools)
+	{
+		// Restore previously unset shortcuts
+		if (toolEntry.tool->isActionShortcut())
+			toolEntry.toolAction->setShortcut(toolEntry.tool->getShortcut());
+
+		if (toolEntry.toolAction == toolAction && toolEntry.tool.get() != _activeTool)
+		{
+			auto prevTool = _activeTool;
+
+			emit activeToolChanging(toolEntry.tool.get());
+
+			if (prevTool)
+				prevTool->toolDeactivated(toolEntry.tool.get());
+
+			_activeTool = toolEntry.tool.get();
+
+			emit activeToolChanged(_activeTool);
+			_activeTool->toolActivated(prevTool);
+
+			// Remove the action shortcut from the currently active tool so that its key sequence can be handled by the image editor (necessary for deselecting all items by pressing Esc)
+			if (toolEntry.tool->isActionShortcut())
+				toolEntry.toolAction->setShortcut(QKeySequence{});
+		}
+
+		toolEntry.toolAction->setChecked(toolEntry.toolAction == toolAction);
+	}
+}
diff --git a/Grinder/ui/image/ImageEditorToolList.h b/Grinder/ui/image/ImageEditorToolList.h
new file mode 100644
index 0000000000000000000000000000000000000000..67589df613518e88ff6f8f07a54adfd03eb413c6
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorToolList.h
@@ -0,0 +1,60 @@
+/******************************************************************************
+ * File: ImageEditorToolList.h
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORTOOLLIST_H
+#define IMAGEEDITORTOOLLIST_H
+
+#include "ImageEditorTool.h"
+#include "ImageEditorComponent.h"
+
+namespace grndr
+{
+	class ImageEditorToolList : public QObject, public ImageEditorComponent
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorToolList(ImageEditor* imageEditor);
+
+	public:
+		void assignUiComponents(QWidget* actionOwner);
+
+	public:		
+		ImageEditorTool* activeTool() { return _activeTool; }
+		const ImageEditorTool* activeTool() const { return _activeTool; }
+		void activateTool(QString toolType);
+		void activateDefaultTool();
+
+		std::vector<QAction*> getActions() const;
+
+	signals:
+		void activeToolChanging(const ImageEditorTool*);
+		void activeToolChanged(const ImageEditorTool*);
+
+	private slots:
+		void toolSelected();
+
+	private:
+		template<typename ToolType>
+		std::unique_ptr<ToolType> createTool(ImageEditor* imageEditor);
+		QAction* createAction(const ImageEditorTool* tool, QWidget* owner);
+
+		void setActiveTool(QAction* toolAction);
+
+	private:
+		struct ToolEntry
+		{
+			std::unique_ptr<ImageEditorTool> tool;
+			QAction* toolAction{nullptr};
+		};
+
+		std::vector<ToolEntry> _tools;
+		ImageEditorTool* _activeTool{nullptr};
+	};
+}
+
+#include "ImageEditorToolList.impl.h"
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorToolList.impl.h b/Grinder/ui/image/ImageEditorToolList.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..59e513d6b19523ca033c150d5f31e5fa0fdc9c8b
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorToolList.impl.h
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: ImageEditorToolList.impl.h
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorToolList.h"
+
+template<typename ToolType>
+std::unique_ptr<ToolType> ImageEditorToolList::createTool(ImageEditor* imageEditor)
+{
+	auto tool = std::make_unique<ToolType>(imageEditor);
+	tool->initImageEditorTool();
+	return tool;
+}
diff --git a/Grinder/ui/image/ImageEditorView.cpp b/Grinder/ui/image/ImageEditorView.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..1b13ef3fae4a08e5453b3c08f519db394a26b017
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorView.cpp
@@ -0,0 +1,151 @@
+/******************************************************************************
+ * File: ImageEditorView.cpp
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorView.h"
+#include "ImageEditorScene.h"
+#include "ImageEditor.h"
+#include "controller/ImageEditorController.h"
+#include "util/UIUtils.h"
+#include "res/Resources.h"
+
+ImageEditorView::ImageEditorView(QWidget* parent) : VisualSceneView(parent)
+{
+	// Set the background of the view to a checkerboard pattern
+	setBackgroundBrush(QPixmap{FILE_ICON_EDITOR_BACKGROUND});
+
+	// Set widget border
+	setStyleSheet(QString{"QGraphicsView { border: 1px solid %1; }"}.arg(QPalette{}.color(QPalette::Dark).name()));
+
+	// Create view actions
+	_showDirectionsAction = UIUtils::createAction(this, "&Show direction arrows", FILE_ICON_EDITOR_SHOWDIRECTIONS, SLOT(showDirectionArrows()), "Show direction arrows on items");
+	_showDirectionsAction->setCheckable(true);
+	_showDirectionsAction->setChecked(true);
+
+	_showTagsAction = UIUtils::createAction(this, "&Show tags", FILE_ICON_EDITOR_SHOWTAGS, SLOT(showTags()), "Show tags on items");
+	_showTagsAction->setCheckable(true);
+	_showTagsAction->setChecked(true);
+
+	_zoomFitAction = UIUtils::createAction(this, "Zoom to &window", FILE_ICON_ZOOMFIT, SLOT(fitImageToWindow()), "Zoom the view to fit the image to its window", "Ctrl+Alt+A");
+
+	updateActions();
+}
+
+void ImageEditorView::setEditorScene(ImageEditorScene* scene)
+{
+	if (_editorScene)
+	{
+		disconnect(&_editorScene->imageEditor()->editorTools(), nullptr, this, nullptr);
+		disconnect(&_editorScene->imageEditor()->environment(), nullptr, this, nullptr);
+	}
+
+	_editorScene = scene;
+	VisualSceneView::setScene(scene);
+
+	if (_editorScene)
+	{
+		// Update the view's focus and cursor when the active image editor tool has changed
+		connect(&_editorScene->imageEditor()->editorTools(), &ImageEditorToolList::activeToolChanging, this, &ImageEditorView::imageEditorToolChanging);
+		connect(&_editorScene->imageEditor()->editorTools(), &ImageEditorToolList::activeToolChanged, this, &ImageEditorView::imageEditorToolChanged);
+
+		// Update the view's actions when the image editor environment has changed
+		connect(&_editorScene->imageEditor()->environment(), &ImageEditorEnvironment::showDirectionArrowsChanged, this, &ImageEditorView::showDirectionArrowsChanged);
+		connect(&_editorScene->imageEditor()->environment(), &ImageEditorEnvironment::showTagsChanged, this, &ImageEditorView::showTagsChanged);
+	}
+}
+
+void ImageEditorView::removeSelectedItems() const
+{
+	if (_editorScene)
+		_editorScene->imageEditor()->controller().removeSelectedNodes();
+}
+
+std::vector<QAction*> ImageEditorView::getActions(AddActionsMode mode) const
+{
+	std::vector<QAction*> actions;
+
+	if (mode != AddActionsMode::Toolbar)
+	{
+		actions.push_back(_selectAllAction);
+		actions.push_back(_deleteSelectedAction);
+		actions.push_back(nullptr);
+	}
+
+	actions.push_back(_showDirectionsAction);
+	actions.push_back(_showTagsAction);
+	actions.push_back(nullptr);
+	actions.push_back(_zoomInAction);
+	actions.push_back(_zoomOutAction);
+	actions.push_back(_zoomFullAction);
+	actions.push_back(_zoomFitAction);
+
+	return actions;
+}
+void ImageEditorView::drawBackground(QPainter* painter, const QRectF& rect)
+{
+	// Draw scale-invariant background
+	auto scaleFactor = painter->transform().m11();
+	auto bgRect = rect;
+
+	bgRect.setTopLeft(bgRect.topLeft() * scaleFactor);
+	bgRect.setBottomRight(bgRect.bottomRight() * scaleFactor);
+
+	painter->scale(1.0 / scaleFactor, 1.0 / scaleFactor);
+	painter->fillRect(bgRect, backgroundBrush());
+	painter->scale(scaleFactor, scaleFactor);
+}
+
+void ImageEditorView::updateActions()
+{
+	VisualSceneView::updateActions();
+
+	_zoomFitAction->setEnabled(_scene);
+}
+
+void ImageEditorView::showDirectionArrows()
+{
+	if (_editorScene)
+		_editorScene->imageEditor()->environment().setShowDirectionArrows(_showDirectionsAction->isChecked());
+}
+
+void ImageEditorView::showTags()
+{
+	if (_editorScene)
+		_editorScene->imageEditor()->environment().setShowTags(_showTagsAction->isChecked());
+}
+
+void ImageEditorView::fitImageToWindow()
+{
+	if (_editorScene)
+		_editorScene->fitViewToImage();
+}
+
+void ImageEditorView::imageEditorToolChanging(const ImageEditorTool* tool)
+{
+	Q_UNUSED(tool);
+
+	// Set the focus to the editor view when changing the current tool
+	setFocus();
+}
+
+void ImageEditorView::imageEditorToolChanged(const ImageEditorTool* tool)
+{
+	setCursor(tool->getCursor());
+
+	if (_editorScene)
+		_editorScene->clearSelection();
+}
+
+void ImageEditorView::showDirectionArrowsChanged(bool show)
+{
+	_showDirectionsAction->setChecked(show);
+	update();
+}
+
+void ImageEditorView::showTagsChanged(bool show)
+{
+	_showTagsAction->setChecked(show);
+	update();
+}
diff --git a/Grinder/ui/image/ImageEditorView.h b/Grinder/ui/image/ImageEditorView.h
new file mode 100644
index 0000000000000000000000000000000000000000..57f312e09276b0b087106e085e3df1c61528979a
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorView.h
@@ -0,0 +1,66 @@
+/******************************************************************************
+ * File: ImageEditorView.h
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORVIEW_H
+#define IMAGEEDITORVIEW_H
+
+#include "ui/visscene/VisualSceneView.h"
+#include "ImageEditorStyle.h"
+
+namespace grndr
+{
+	class ImageEditorScene;
+	class ImageEditorTool;
+
+	class ImageEditorView : public VisualSceneView
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorView(QWidget *parent = nullptr);
+
+	public:
+		ImageEditorScene* editorScene() { return _editorScene; }
+		const ImageEditorScene* editorScene() const { return _editorScene; }
+		void setEditorScene(ImageEditorScene* scene);
+
+	public slots:
+		virtual void removeSelectedItems() const override;
+
+	public:
+		virtual const ImageEditorStyle& sceneStyle() const override { return _editorStyle; }
+
+	protected:
+		virtual std::vector<QAction*> getActions(AddActionsMode mode) const override;
+
+	protected:
+		virtual void drawBackground(QPainter* painter, const QRectF& rect) override;
+
+	protected slots:
+		virtual void updateActions() override;
+
+	private slots:
+		void showDirectionArrows();
+		void showTags();
+		void fitImageToWindow();
+
+		void imageEditorToolChanging(const ImageEditorTool* tool);
+		void imageEditorToolChanged(const ImageEditorTool* tool);
+		void showDirectionArrowsChanged(bool show);
+		void showTagsChanged(bool show);
+
+	private:
+		ImageEditorScene* _editorScene{nullptr};
+
+		ImageEditorStyle _editorStyle;
+
+	private:
+		QAction* _showDirectionsAction{nullptr};
+		QAction* _showTagsAction{nullptr};
+		QAction* _zoomFitAction{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorWidget.cpp b/Grinder/ui/image/ImageEditorWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..39b3697e292caa3ffcbd80a35845d394584d8b81
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorWidget.cpp
@@ -0,0 +1,210 @@
+/******************************************************************************
+ * File: ImageEditorWidget.cpp
+ * Date: 13.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageEditorWidget.h"
+#include "ui_ImageEditorWidget.h"
+#include "tools/DefaultImageEditorTool.h"
+#include "core/GrinderApplication.h"
+#include "image/ImageBuild.h"
+#include "image/ImageExceptions.h"
+#include "ui/StyleSheet.h"
+#include "util/UIUtils.h"
+#include "res/Resources.h"
+
+ImageEditorWidget::ImageEditorWidget(ImageEditor* imageEditor, QWidget* parent) : QWidget(parent), ImageEditorComponent(imageEditor),
+	ui{new Ui::ImageEditorWidget}
+{
+	if (!imageEditor)
+		throw std::runtime_error{_EXCPT("imageEditor may not be null")};	
+
+	setupUi();
+
+	// Reflect primary color changes
+	connect(&_imageEditor->environment(), &ImageEditorEnvironment::primaryColorChanged, this, &ImageEditorWidget::primaryColorChanged);
+}
+
+ImageEditorWidget::~ImageEditorWidget()
+{
+	delete ui;
+}
+
+void ImageEditorWidget::setImageBuild(const std::shared_ptr<ImageBuild>& imageBuild)
+{
+	if (auto scene = _imageEditor->controller().activeScene())
+		disconnect(scene, nullptr, this, nullptr);
+
+	// Let the controller handle showing the new image build
+	_imageEditor->controller().showImageBuild(imageBuild.get());
+
+	// Listen for node selection changes in the new scene to update the primary color
+	if (auto scene = _imageEditor->controller().activeScene())
+		connect(scene, &ImageEditorScene::selectionChanged, this, &ImageEditorWidget::updatePrimaryColor);
+}
+
+void ImageEditorWidget::keyPressEvent(QKeyEvent* event)
+{
+	QWidget::keyPressEvent(event);
+
+	// Handle the Esc key if it hasn't been consumed
+	if (!event->isAccepted())
+	{
+		if (event->key() == Qt::Key_Escape)
+		{
+			if (_imageEditor->editorTools().activeTool()->getToolType() != DefaultImageEditorTool::tool_type)
+			{
+				_imageEditor->editorTools().activateDefaultTool();
+				return;
+			}
+		}
+	}
+}
+
+void ImageEditorWidget::setupUi()
+{
+	ui->setupUi(this);
+
+	connect(ui->primaryColorWidget, &ColorWidget::colorChanged, this, &ImageEditorWidget::primaryColorSelected);
+	connect(ui->colorPresetsWidget, &ColorPresetsWidget::colorSelected, this, &ImageEditorWidget::colorPresetSelected);
+
+	// Create editor-global actions
+	_newLayerAction = UIUtils::createAction(this, "&New layer", FILE_ICON_ADD, SLOT(newLayer()), "Add a new layer", "Ctrl+Shift+L", Qt::WidgetWithChildrenShortcut, ui->layersList);	
+
+	// Assign the UI components to the controller
+	_imageEditor->controller().assignUiComponents(ui->imageScene, ui->layersList);
+
+	// Assign the UI components to other UI components
+	ui->colorPresetsWidget->assignUiComponents(this);
+	ui->imageEditorProperties->assignUiComponents(_imageEditor);
+	ui->layersList->assignUiComponents(_imageEditor);
+	_imageEditor->editorTools().assignUiComponents(this);
+
+	setupImageControlBar();
+
+	// Set up the various UI components
+	ui->layersList->setupUi(_newLayerAction, nullptr, ui->layersControlBar);
+
+	ui->propertiesArea->setStyleSheet(StyleSheet::loadStyleSheet(FILE_STYLESHEET_IMAGEEDITORPROPERTIES));
+
+	updateSceneZoomLevel(ui->imageScene->getZoom());
+	updatePrimaryColorLabel();
+
+	grinder()->settings().getSplitterState("ImageEditorLeft", ui->splitterLeft);
+	grinder()->settings().getSplitterState("ImageEditorRight", ui->splitterRight);
+}
+
+void ImageEditorWidget::setupImageControlBar()
+{
+	ui->imageSceneControlBar->setDefaultAlignment(Qt::AlignRight);
+	ui->imageSceneControlBar->setDefaultToolButtonStyle(Qt::ToolButtonIconOnly);
+
+	// Add all image editor tool actions
+	for (auto toolAction : _imageEditor->editorTools().getActions())
+		ui->imageSceneControlBar->addAction(toolAction, Qt::ToolButtonFollowStyle, Qt::AlignLeft);
+
+	ui->imageScene->setupUi(static_cast<QMenu*>(nullptr), ui->imageSceneControlBar);
+
+	// Add the zoom level label
+	_imageSceneZoomLabel = new QLabel{""};
+	_imageSceneZoomLabel->setAlignment(Qt::AlignCenter);
+	ui->imageSceneControlBar->addSeparator();
+	ui->imageSceneControlBar->addWidget(_imageSceneZoomLabel);
+
+	QFontMetrics fontMetrics{_imageSceneZoomLabel->font()};
+	_imageSceneZoomLabel->setMinimumWidth(fontMetrics.width("9999%"));
+
+	// Show the current zoom level in the image scene control bar
+	connect(ui->imageScene, &VisualSceneView::zoomChanged, this, &ImageEditorWidget::updateSceneZoomLevel);
+}
+
+void ImageEditorWidget::updatePrimaryColorLabel()
+{
+	auto color = ui->primaryColorWidget->getColor();
+	ui->lblPrimaryColor->setText(QString{"R: %1\nG: %2\nB: %3"}.arg(color.red()).arg(color.green()).arg(color.blue()));
+}
+
+void ImageEditorWidget::on_splitterLeft_splitterMoved(int pos, int index)
+{
+	Q_UNUSED(pos);
+	Q_UNUSED(index);
+
+	grinder()->settings().setSplitterState("ImageEditorLeft", ui->splitterLeft);
+}
+
+void ImageEditorWidget::on_splitterRight_splitterMoved(int pos, int index)
+{
+	Q_UNUSED(pos);
+	Q_UNUSED(index);
+
+	grinder()->settings().setSplitterState("ImageEditorRight", ui->splitterRight);
+}
+
+void ImageEditorWidget::on_primaryColorWidget_customContextMenuRequested(const QPoint& pos)
+{
+	QMenu menu;
+	auto presetsMenu = menu.addMenu("&Assign to preset");
+
+	for (unsigned int i = 0; i < ui->colorPresetsWidget->getPresetsCount(); ++i)
+	{
+		auto action = presetsMenu->addAction(QString{"Preset &%1"}.arg(i + 1), this, SLOT(assignPrimaryColorToPreset()));
+		action->setData(i);
+	}
+
+	menu.exec(ui->primaryColorWidget->mapToGlobal(pos));
+}
+
+void ImageEditorWidget::updateSceneZoomLevel(qreal zoomLevel)
+{
+	_imageSceneZoomLabel->setText(QString{"%1%"}.arg(static_cast<int>(zoomLevel * 100.0)));
+}
+
+void ImageEditorWidget::updatePrimaryColor()
+{
+	if (auto scene = _imageEditor->controller().activeScene())
+	{
+		auto selNodes = scene->getNodes<DraftItemNode>(true);
+
+		if (!selNodes.empty())
+		{
+			if (auto item = selNodes.front()->draftItem().lock())	// Make sure that the underlying draft item still exists
+				_imageEditor->environment().setPrimaryColor(item->primaryColor()->getValue());
+		}
+	}
+}
+
+void ImageEditorWidget::primaryColorSelected(QColor color, bool byUser)
+{	
+	if (byUser)
+	{
+		_imageEditor->environment().setPrimaryColor(color);
+
+		if (_imageEditor->controller().activeScene())
+		{
+			auto nodes = _imageEditor->controller().activeScene()->getNodes<DraftItemNode>(true);
+			_imageEditor->controller().setNodesPrimaryColor(color, nodes);
+		}
+	}
+
+	updatePrimaryColorLabel();
+}
+
+void ImageEditorWidget::colorPresetSelected(QColor color)
+{
+	primaryColorSelected(color, true);
+}
+
+void ImageEditorWidget::assignPrimaryColorToPreset()
+{
+	if (auto action = dynamic_cast<QAction*>(sender()))
+	{
+		unsigned int index = action->data().toUInt();
+		ui->colorPresetsWidget->setColor(index, ui->primaryColorWidget->getColor());
+	}
+}
+
+void ImageEditorWidget::primaryColorChanged(QColor color)
+{
+	ui->primaryColorWidget->setColor(color);
+}
diff --git a/Grinder/ui/image/ImageEditorWidget.h b/Grinder/ui/image/ImageEditorWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..3a50dc88cb1ce52ecc9ebc4a59efe800ea2b22ee
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorWidget.h
@@ -0,0 +1,67 @@
+/******************************************************************************
+ * File: ImageEditorWidget.h
+ * Date: 13.3.2018
+ *****************************************************************************/
+
+#ifndef IMAGEEDITORWIDGET_H
+#define IMAGEEDITORWIDGET_H
+
+#include <QLabel>
+#include <memory>
+
+#include "ImageEditorComponent.h"
+
+namespace Ui
+{
+	class ImageEditorWidget;
+}
+
+namespace grndr
+{
+	class ImageEditor;
+	class ImageBuild;
+
+	class ImageEditorWidget : public QWidget, public ImageEditorComponent
+	{
+		Q_OBJECT
+
+	public:
+		ImageEditorWidget(ImageEditor* imageEditor, QWidget* parent = nullptr);
+		virtual ~ImageEditorWidget();
+
+	public:
+		void setImageBuild(const std::shared_ptr<ImageBuild>& imageBuild);	
+
+	protected:
+		virtual void keyPressEvent(QKeyEvent* event) override;
+
+	private:
+		void setupUi();
+		void setupImageControlBar();
+		Ui::ImageEditorWidget *ui;
+
+		void updatePrimaryColorLabel();
+
+		QLabel* _imageSceneZoomLabel{nullptr};		
+
+	private slots:
+		void on_splitterLeft_splitterMoved(int pos, int index);
+		void on_splitterRight_splitterMoved(int pos, int index);
+
+		void on_primaryColorWidget_customContextMenuRequested(const QPoint &pos);
+
+	private slots:
+		void updateSceneZoomLevel(qreal zoomLevel);
+
+		void updatePrimaryColor();
+		void primaryColorChanged(QColor color);
+		void primaryColorSelected(QColor color, bool byUser);
+		void colorPresetSelected(QColor color);
+		void assignPrimaryColorToPreset();
+
+	private:
+		QAction* _newLayerAction{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/ImageEditorWidget.ui b/Grinder/ui/image/ImageEditorWidget.ui
new file mode 100644
index 0000000000000000000000000000000000000000..2dba5dc9bde6c80cc470e7f771fec6dcc1bb376b
--- /dev/null
+++ b/Grinder/ui/image/ImageEditorWidget.ui
@@ -0,0 +1,314 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ImageEditorWidget</class>
+ <widget class="QWidget" name="ImageEditorWidget">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>783</width>
+    <height>515</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Form</string>
+  </property>
+  <layout class="QGridLayout" name="gridLayout_3">
+   <property name="leftMargin">
+    <number>6</number>
+   </property>
+   <property name="topMargin">
+    <number>6</number>
+   </property>
+   <property name="rightMargin">
+    <number>6</number>
+   </property>
+   <property name="bottomMargin">
+    <number>6</number>
+   </property>
+   <property name="spacing">
+    <number>2</number>
+   </property>
+   <item row="0" column="0">
+    <widget class="QSplitter" name="splitterRight">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="childrenCollapsible">
+      <bool>false</bool>
+     </property>
+     <widget class="QWidget" name="widget_2" native="true">
+      <layout class="QGridLayout" name="gridLayout_2">
+       <property name="leftMargin">
+        <number>0</number>
+       </property>
+       <property name="topMargin">
+        <number>0</number>
+       </property>
+       <property name="rightMargin">
+        <number>0</number>
+       </property>
+       <property name="bottomMargin">
+        <number>0</number>
+       </property>
+       <property name="spacing">
+        <number>6</number>
+       </property>
+       <item row="0" column="0">
+        <widget class="ControlBar" name="imageSceneControlBar">
+         <property name="minimumSize">
+          <size>
+           <width>0</width>
+           <height>20</height>
+          </size>
+         </property>
+         <property name="frameShape">
+          <enum>QFrame::StyledPanel</enum>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="0">
+        <widget class="QSplitter" name="splitterLeft">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="childrenCollapsible">
+          <bool>false</bool>
+         </property>
+         <widget class="QDockWidget" name="dockWidget_2">
+          <property name="font">
+           <font>
+            <weight>75</weight>
+            <bold>true</bold>
+           </font>
+          </property>
+          <property name="features">
+           <set>QDockWidget::NoDockWidgetFeatures</set>
+          </property>
+          <property name="windowTitle">
+           <string>Properties</string>
+          </property>
+          <widget class="QWidget" name="dockWidgetContents_2">
+           <layout class="QVBoxLayout" name="verticalLayout">
+            <property name="leftMargin">
+             <number>1</number>
+            </property>
+            <property name="topMargin">
+             <number>6</number>
+            </property>
+            <property name="rightMargin">
+             <number>1</number>
+            </property>
+            <property name="bottomMargin">
+             <number>0</number>
+            </property>
+            <item>
+             <widget class="QWidget" name="widget_3" native="true">
+              <property name="minimumSize">
+               <size>
+                <width>0</width>
+                <height>30</height>
+               </size>
+              </property>
+              <layout class="QHBoxLayout" name="horizontalLayout">
+               <property name="spacing">
+                <number>6</number>
+               </property>
+               <property name="leftMargin">
+                <number>0</number>
+               </property>
+               <property name="topMargin">
+                <number>0</number>
+               </property>
+               <property name="rightMargin">
+                <number>0</number>
+               </property>
+               <property name="bottomMargin">
+                <number>0</number>
+               </property>
+               <item>
+                <widget class="QLabel" name="label">
+                 <property name="text">
+                  <string>Color:</string>
+                 </property>
+                </widget>
+               </item>
+               <item>
+                <widget class="ColorWidget" name="primaryColorWidget" native="true">
+                 <property name="minimumSize">
+                  <size>
+                   <width>30</width>
+                   <height>30</height>
+                  </size>
+                 </property>
+                 <property name="contextMenuPolicy">
+                  <enum>Qt::CustomContextMenu</enum>
+                 </property>
+                </widget>
+               </item>
+               <item>
+                <widget class="QLabel" name="lblPrimaryColor">
+                 <property name="enabled">
+                  <bool>false</bool>
+                 </property>
+                 <property name="minimumSize">
+                  <size>
+                   <width>45</width>
+                   <height>0</height>
+                  </size>
+                 </property>
+                 <property name="styleSheet">
+                  <string notr="true">margin-right: 5px;</string>
+                 </property>
+                 <property name="text">
+                  <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;R: 255&lt;br/&gt;G: 255&lt;br/&gt;B: 255&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+                 </property>
+                </widget>
+               </item>
+               <item>
+                <widget class="ColorPresetsWidget" name="colorPresetsWidget" native="true"/>
+               </item>
+               <item>
+                <spacer name="horizontalSpacer">
+                 <property name="orientation">
+                  <enum>Qt::Horizontal</enum>
+                 </property>
+                 <property name="sizeHint" stdset="0">
+                  <size>
+                   <width>0</width>
+                   <height>0</height>
+                  </size>
+                 </property>
+                </spacer>
+               </item>
+              </layout>
+             </widget>
+            </item>
+            <item>
+             <widget class="QScrollArea" name="propertiesArea">
+              <property name="widgetResizable">
+               <bool>true</bool>
+              </property>
+              <widget class="ImageEditorPropertyWidget" name="imageEditorProperties">
+               <property name="geometry">
+                <rect>
+                 <x>0</x>
+                 <y>0</y>
+                 <width>163</width>
+                 <height>173</height>
+                </rect>
+               </property>
+              </widget>
+             </widget>
+            </item>
+           </layout>
+          </widget>
+         </widget>
+         <widget class="ImageEditorView" name="imageScene">
+          <property name="renderHints">
+           <set>QPainter::Antialiasing|QPainter::HighQualityAntialiasing|QPainter::SmoothPixmapTransform|QPainter::TextAntialiasing</set>
+          </property>
+          <property name="viewportUpdateMode">
+           <enum>QGraphicsView::FullViewportUpdate</enum>
+          </property>
+         </widget>
+        </widget>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QDockWidget" name="dockWidget">
+      <property name="font">
+       <font>
+        <weight>75</weight>
+        <bold>true</bold>
+       </font>
+      </property>
+      <property name="features">
+       <set>QDockWidget::NoDockWidgetFeatures</set>
+      </property>
+      <property name="windowTitle">
+       <string>Layers</string>
+      </property>
+      <widget class="QWidget" name="dockWidgetContents">
+       <layout class="QVBoxLayout" name="verticalLayout_3">
+        <property name="spacing">
+         <number>2</number>
+        </property>
+        <property name="leftMargin">
+         <number>1</number>
+        </property>
+        <property name="topMargin">
+         <number>6</number>
+        </property>
+        <property name="rightMargin">
+         <number>1</number>
+        </property>
+        <property name="bottomMargin">
+         <number>0</number>
+        </property>
+        <item>
+         <widget class="LayersListWidget" name="layersList">
+          <property name="editTriggers">
+           <set>QAbstractItemView::EditKeyPressed</set>
+          </property>
+         </widget>
+        </item>
+        <item>
+         <widget class="ControlBar" name="layersControlBar">
+          <property name="minimumSize">
+           <size>
+            <width>0</width>
+            <height>20</height>
+           </size>
+          </property>
+          <property name="frameShape">
+           <enum>QFrame::StyledPanel</enum>
+          </property>
+         </widget>
+        </item>
+       </layout>
+      </widget>
+     </widget>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <customwidgets>
+  <customwidget>
+   <class>ControlBar</class>
+   <extends>QFrame</extends>
+   <header>ui/widget/ControlBar.h</header>
+   <container>1</container>
+  </customwidget>
+  <customwidget>
+   <class>ImageEditorView</class>
+   <extends>QGraphicsView</extends>
+   <header>ui/image/ImageEditorView.h</header>
+  </customwidget>
+  <customwidget>
+   <class>LayersListWidget</class>
+   <extends>QListWidget</extends>
+   <header>ui/image/LayersListWidget.h</header>
+  </customwidget>
+  <customwidget>
+   <class>ImageEditorPropertyWidget</class>
+   <extends>QWidget</extends>
+   <header>ui/image/ImageEditorPropertyWidget.h</header>
+   <container>1</container>
+  </customwidget>
+  <customwidget>
+   <class>ColorWidget</class>
+   <extends>QWidget</extends>
+   <header>ui/widget/ColorWidget.h</header>
+   <container>1</container>
+  </customwidget>
+  <customwidget>
+   <class>ColorPresetsWidget</class>
+   <extends>QWidget</extends>
+   <header>ui/image/ColorPresetsWidget.h</header>
+   <container>1</container>
+  </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/Grinder/ui/image/InPlaceEditor.cpp b/Grinder/ui/image/InPlaceEditor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..33e56299d1429b62297e2a57291eda041fd21d6e
--- /dev/null
+++ b/Grinder/ui/image/InPlaceEditor.cpp
@@ -0,0 +1,77 @@
+/******************************************************************************
+ * File: InPlaceEditor.cpp
+ * Date: 28.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "InPlaceEditor.h"
+#include "DraftItemNode.h"
+#include "ImageEditorScene.h"
+
+InPlaceEditor::InPlaceEditor(ImageEditorScene* scene, DraftItemNode* draftItemNode) : ImageEditorNode(scene, draftItemNode),
+	_draftItemNode{draftItemNode}
+{
+	if (!draftItemNode)
+		throw std::invalid_argument{_EXCPT("draftItemNode may not be null")};
+
+	setFlag(ItemHasNoContents);
+	setFlag(ItemIsFocusable, false);
+	setFlag(ItemIsSelectable, false);
+}
+
+void InPlaceEditor::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
+{
+	Q_UNUSED(painter);
+	Q_UNUSED(option);
+	Q_UNUSED(widget);
+
+	// Don't paint anything by default
+}
+
+QVariant InPlaceEditor::itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant& value)
+{
+	if (change == QGraphicsItem::ItemVisibleHasChanged)
+		updateEditor();
+
+	return ImageEditorNode::itemChange(change, value);
+}
+
+InPlaceEditorDragHandle* InPlaceEditor::createDragHandle(InPlaceEditorDragHandle::AllowedDirections allowedDirections, QPointF position)
+{
+	auto dragHandle = new InPlaceEditorDragHandle{_scene, allowedDirections, this};
+	dragHandle->setPos(position);
+
+	// Listen for drag handle events
+	connect(dragHandle, &InPlaceEditorDragHandle::handlePressed, this, &InPlaceEditor::handlePressed);
+	connect(dragHandle, &InPlaceEditorDragHandle::handleMoved, this, &InPlaceEditor::handleMoved);
+	connect(dragHandle, &InPlaceEditorDragHandle::handleReleased, this, &InPlaceEditor::handleReleased);
+
+	return dragHandle;
+}
+
+void InPlaceEditor::dragHandleReleased(InPlaceEditorDragHandle* dragHandle)
+{
+	Q_UNUSED(dragHandle);
+
+	// When a handle has been dragged, normalize the property values (e.g., to adjust for negative sizes)
+	if (auto draftItem = _draftItemNode->draftItem().lock())	// Make sure that the underlying draft item still exists
+		draftItem->normalizePropertyValues();
+}
+
+void InPlaceEditor::handlePressed()
+{
+	if (auto dragHandle = dynamic_cast<InPlaceEditorDragHandle*>(sender()))
+		dragHandlePressed(dragHandle);
+}
+
+void InPlaceEditor::handleMoved(QPoint offset)
+{
+	if (auto dragHandle = dynamic_cast<InPlaceEditorDragHandle*>(sender()))
+		dragHandleMoved(dragHandle, offset);
+}
+
+void InPlaceEditor::handleReleased()
+{
+	if (auto dragHandle = dynamic_cast<InPlaceEditorDragHandle*>(sender()))
+		dragHandleReleased(dragHandle);
+}
diff --git a/Grinder/ui/image/InPlaceEditor.h b/Grinder/ui/image/InPlaceEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..63b8f5aaef73021defbe71ced1674220bd21850d
--- /dev/null
+++ b/Grinder/ui/image/InPlaceEditor.h
@@ -0,0 +1,49 @@
+/******************************************************************************
+ * File: InPlaceEditor.h
+ * Date: 27.3.2018
+ *****************************************************************************/
+
+#ifndef INPLACEEDITOR_H
+#define INPLACEEDITOR_H
+
+#include "ImageEditorNode.h"
+#include "InPlaceEditorDragHandle.h"
+
+namespace grndr
+{
+	class DraftItemNode;
+
+	class InPlaceEditor : public ImageEditorNode
+	{
+		Q_OBJECT
+
+	public:
+		InPlaceEditor(ImageEditorScene* scene, DraftItemNode* draftItemNode);
+
+	public:
+		virtual void updateEditor() = 0;
+
+	public:
+		virtual void paint(QPainter* painter, const QStyleOptionGraphicsItem *option, QWidget* widget) override;
+
+	protected:
+		virtual QVariant itemChange(GraphicsItemChange change, const QVariant& value) override;
+
+	protected:
+		InPlaceEditorDragHandle* createDragHandle(InPlaceEditorDragHandle::AllowedDirections allowedDirections, QPointF position = QPointF{0.0, 0.0});
+
+		virtual void dragHandlePressed(InPlaceEditorDragHandle* dragHandle) { Q_UNUSED(dragHandle); }
+		virtual void dragHandleMoved(InPlaceEditorDragHandle* dragHandle, QPoint offset) { Q_UNUSED(dragHandle); Q_UNUSED(offset); }
+		virtual void dragHandleReleased(InPlaceEditorDragHandle* dragHandle);
+
+	private slots:
+		void handlePressed();
+		void handleMoved(QPoint offset);
+		void handleReleased();
+
+	protected:
+		DraftItemNode* _draftItemNode{nullptr};	
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/InPlaceEditorDragHandle.cpp b/Grinder/ui/image/InPlaceEditorDragHandle.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..b47f95d78ae1236f5ac7bb9e24b9a6904a42493e
--- /dev/null
+++ b/Grinder/ui/image/InPlaceEditorDragHandle.cpp
@@ -0,0 +1,104 @@
+/******************************************************************************
+ * File: InPlaceEditorDragHandle.cpp
+ * Date: 28.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "InPlaceEditorDragHandle.h"
+
+InPlaceEditorDragHandle::InPlaceEditorDragHandle(ImageEditorScene* scene, AllowedDirections allowedDirections, QGraphicsItem* parent) : ImageEditorNode(scene, parent),
+	_dragHandleStyle{_style.getDragHandleStyle()}, _allowedDirections{allowedDirections}
+{
+	setFlag(ItemIgnoresTransformations);
+	setFlag(ItemIsFocusable, false);
+	setFlag(ItemIsSelectable, false);
+
+	updateGeometry();
+}
+
+void InPlaceEditorDragHandle::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
+{
+	Q_UNUSED(option);
+	Q_UNUSED(widget);
+
+	painter->setRenderHints(QPainter::Antialiasing|QPainter::TextAntialiasing|QPainter::HighQualityAntialiasing|QPainter::SmoothPixmapTransform);
+	painter->setBrush(Qt::NoBrush);
+
+	QPen outerPen{_dragHandleStyle.outerBorderColor, _dragHandleStyle.outerBorderWidth};
+	outerPen.setCapStyle(Qt::RoundCap);
+	outerPen.setJoinStyle(Qt::RoundJoin);
+
+	QPen innerPen{_dragHandleStyle.innerBorderColor, _dragHandleStyle.innerBorderWidth};
+	outerPen.setCapStyle(Qt::RoundCap);
+	outerPen.setJoinStyle(Qt::RoundJoin);
+
+	// Draw a small two-colored circle as the drag handle
+	auto circleRect = QRectF{-_dragHandleStyle.radius, -_dragHandleStyle.radius, _dragHandleStyle.radius * 2.0, _dragHandleStyle.radius * 2.0};
+
+	painter->setPen(outerPen);
+	painter->drawEllipse(circleRect);
+
+	painter->setPen(innerPen);
+	painter->drawEllipse(circleRect);
+}
+
+void InPlaceEditorDragHandle::mousePressEvent(QGraphicsSceneMouseEvent* event)
+{
+	if (event->button() == Qt::LeftButton && event->modifiers() == Qt::NoModifier)
+	{
+		_dragInfo.isDragging = true;
+		_dragInfo.lastMovementPosition = event->scenePos();
+		emit handlePressed();
+		event->accept();
+	}
+	else
+		ImageEditorNode::mousePressEvent(event);
+}
+
+void InPlaceEditorDragHandle::mouseMoveEvent(QGraphicsSceneMouseEvent* event)
+{
+	if (_dragInfo.isDragging)
+	{
+		emit handleMoved(restrictMovement(event->scenePos() - _dragInfo.lastMovementPosition));
+		_dragInfo.lastMovementPosition = event->scenePos();
+		event->accept();
+	}
+	else
+		ImageEditorNode::mouseMoveEvent(event);
+}
+
+void InPlaceEditorDragHandle::mouseReleaseEvent(QGraphicsSceneMouseEvent* event)
+{
+	if (event->button() == Qt::LeftButton)
+	{
+		_dragInfo.isDragging = false;
+		emit handleReleased();
+		event->accept();
+	}
+	else
+		ImageEditorNode::mouseReleaseEvent(event);
+}
+
+void InPlaceEditorDragHandle::updateGeometry()
+{
+	auto width = _dragHandleStyle.handleSize.width();
+	auto height = _dragHandleStyle.handleSize.height();
+
+	_nodeRect = QRectF{-width / 2.0, -height / 2.0, width, height};
+	_nodeRectSelected = _nodeRect;
+
+	ImageEditorNode::updateGeometry();
+}
+
+QPoint InPlaceEditorDragHandle::restrictMovement(QPointF offset) const
+{
+	QPoint restrictedDelta{0, 0};
+
+	if (_allowedDirections.testFlag(AllowedDirection::EastWest))
+		restrictedDelta.setX(std::lround(offset.x()));
+
+	if (_allowedDirections.testFlag(AllowedDirection::NorthSouth))
+		restrictedDelta.setY(std::lround(offset.y()));
+
+	return restrictedDelta;
+}
diff --git a/Grinder/ui/image/InPlaceEditorDragHandle.h b/Grinder/ui/image/InPlaceEditorDragHandle.h
new file mode 100644
index 0000000000000000000000000000000000000000..12984cefa5261967e34264464ccceeb503878265
--- /dev/null
+++ b/Grinder/ui/image/InPlaceEditorDragHandle.h
@@ -0,0 +1,66 @@
+/******************************************************************************
+ * File: InPlaceEditorDragHandle.h
+ * Date: 28.3.2018
+ *****************************************************************************/
+
+#ifndef INPLACEEDITORDRAGHANDLE_H
+#define INPLACEEDITORDRAGHANDLE_H
+
+#include "ImageEditorNode.h"
+#include "ImageEditorStyle.h"
+
+namespace grndr
+{
+	class InPlaceEditorDragHandle : public ImageEditorNode
+	{
+		Q_OBJECT
+
+	public:
+		enum AllowedDirection
+		{
+			NorthSouth = 0x01,
+			EastWest = 0x02,
+
+			All = NorthSouth|EastWest,
+		};
+
+		Q_DECLARE_FLAGS(AllowedDirections, AllowedDirection)
+
+	public:
+		InPlaceEditorDragHandle(ImageEditorScene* scene, AllowedDirections allowedDirections, QGraphicsItem* parent = nullptr);
+
+	public:
+		virtual void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override;
+
+	signals:
+		void handlePressed();
+		void handleMoved(QPoint);
+		void handleReleased();
+
+	protected:
+		virtual void mousePressEvent(QGraphicsSceneMouseEvent* event) override;
+		virtual void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override;
+		virtual void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override;
+
+	protected:
+		virtual void updateGeometry() override;
+
+	private:
+		QPoint restrictMovement(QPointF offset) const;
+
+	private:
+		const ImageEditorStyle::DragHandleStyle& _dragHandleStyle;
+
+		AllowedDirections _allowedDirections{AllowedDirection::All};
+
+		struct
+		{
+			bool isDragging{false};
+			QPointF lastMovementPosition;
+		} _dragInfo;
+	};
+}
+
+Q_DECLARE_OPERATORS_FOR_FLAGS(grndr::InPlaceEditorDragHandle::AllowedDirections)
+
+#endif
diff --git a/Grinder/ui/image/LayersListItem.cpp b/Grinder/ui/image/LayersListItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..3f8a9d12b3a39d5e152400576491b291be4a8f5f
--- /dev/null
+++ b/Grinder/ui/image/LayersListItem.cpp
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: LayersListItem.cpp
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LayersListItem.h"
+#include "res/Resources.h"
+
+LayersListItem::LayersListItem(Layer* layer) : ObjectListItem(layer)
+{
+	setFlags(Qt::ItemIsEnabled|Qt::ItemIsSelectable|Qt::ItemIsEditable|Qt::ItemIsUserCheckable);
+	setIcon(QIcon{FILE_ICON_LAYER});
+
+	updateItem();
+}
+
+void LayersListItem::updateItem()
+{
+	setText(getName());
+	setCheckState(isVisible() ? Qt::Checked : Qt::Unchecked);
+	base_type::updateItem();
+}
diff --git a/Grinder/ui/image/LayersListItem.h b/Grinder/ui/image/LayersListItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..7fb65d6fbd2089798473682a9f172c46829f7707
--- /dev/null
+++ b/Grinder/ui/image/LayersListItem.h
@@ -0,0 +1,28 @@
+/******************************************************************************
+ * File: LayersListItem.h
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#ifndef LAYERSLISTITEM_H
+#define LAYERSLISTITEM_H
+
+#include "ui/widget/ObjectListItem.h"
+#include "image/Layer.h"
+
+namespace grndr
+{
+	class LayersListItem : public ObjectListItem<Layer>
+	{
+	public:
+		LayersListItem(Layer* layer);
+
+	public:
+		virtual void updateItem() override;
+
+	public:
+		QString getName() const { return _object->getName(); }
+		bool isVisible() const { return _object->isVisible(); }
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/LayersListWidget.cpp b/Grinder/ui/image/LayersListWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..b7d0962b44a8b8c8dc0934951618db7ba4459b80
--- /dev/null
+++ b/Grinder/ui/image/LayersListWidget.cpp
@@ -0,0 +1,211 @@
+/******************************************************************************
+ * File: LayersListWidget.cpp
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LayersListWidget.h"
+#include "ImageEditor.h"
+#include "image/ImageBuild.h"
+#include "ui/widget/ControlBar.h"
+#include "util/UIUtils.h"
+#include "util/StringUtils.h"
+#include "res/Resources.h"
+
+LayersListWidget::LayersListWidget(QWidget* parent) : MetaWidget(parent)
+{
+	// Create labels actions
+	_renameLayerAction = UIUtils::createAction(this, "Rename layer", FILE_ICON_EDIT, SLOT(renameLayer()), "Rename the selected layer", "F2");
+	_moveUpAction = UIUtils::createAction(this, "Move up", FILE_ICON_MOVEUP, SLOT(moveLayerUp()), "Move the selected layer up", "Ctrl+Shift+Up");
+	_moveDownAction = UIUtils::createAction(this, "Move down", FILE_ICON_MOVEDOWN, SLOT(moveLayerDown()), "Move the selected layer down", "Ctrl+Shift+Down");
+	_removeLayerAction = UIUtils::createAction(this, "&Remove layer", FILE_ICON_DELETE, SLOT(removeLayer()), "Remove the selected layer", "Del");
+	_removeAllLayersAction = UIUtils::createAction(this, "Remove all layers", "", SLOT(removeAllLayers()), "Remove all layers");
+
+	_moveUpAction->setData(Qt::ToolButtonIconOnly);
+	_moveDownAction->setData(Qt::ToolButtonIconOnly);
+
+	// Update the image build if the layer has been (un)checked
+	connect(this, &LayersListWidget::itemChanged, this, &LayersListWidget::layerCheckChanged);
+
+	// Get notified when a label name has been edited
+	connect(itemDelegate(), &QAbstractItemDelegate::commitData, this, &LayersListWidget::layerRenamed, Qt::QueuedConnection);	// Must be queued to prevent issues if renaming fails
+
+	// Listen for item selections in order to update the active layer and actions
+	connect(this, &LayersListWidget::itemSelectionChanged, this, &LayersListWidget::selectedItemChanged);
+
+	updateActions();
+}
+
+void LayersListWidget::assignUiComponents(ImageEditor* imageEditor)
+{
+	if (!imageEditor)
+		throw std::invalid_argument{_EXCPT("imageEditor may not be null")};
+
+	_imageEditor = imageEditor;
+
+	// Listen for controller events in order to update the UI accordingly
+	connect(&imageEditor->controller(), &ImageEditorController::imageBuildSwitched, this, &LayersListWidget::imageBuildSwitched);
+	connect(&imageEditor->controller(), &ImageEditorController::layerSwitched, this, &LayersListWidget::layerSwitched);
+}
+
+void LayersListWidget::setupUi(QAction* newLayerAction, QMenu* menu, ControlBar* controlBar)
+{
+	_newLayerAction = newLayerAction;
+
+	MetaWidget::setupUi(menu, controlBar);
+}
+
+void LayersListWidget::swapLayers(int indexOld, int indexNew)
+{
+	auto layerSelected = currentObjectItem();
+
+	// Layers are ordered in a highest-to-lowest fashion in the list, so reverse their indices
+	indexOld = count() - 1 - indexOld;
+	indexNew = count() - 1 - indexNew;
+
+	auto item = takeItem(indexOld);
+	insertItem(indexNew, item);
+
+	switchToObjectItem(layerSelected);
+}
+
+std::vector<QAction*> LayersListWidget::getActions(MetaWidget::AddActionsMode mode) const
+{
+	std::vector<QAction*> actions;
+
+	if (mode == AddActionsMode::ContextMenu || mode == AddActionsMode::Toolbar)
+	{
+		if (mode == AddActionsMode::ContextMenu)
+		{
+			actions.push_back(_renameLayerAction);
+			actions.push_back(nullptr);
+		}
+	}
+
+	actions.push_back(_newLayerAction);	
+	actions.push_back(_removeLayerAction);
+	actions.push_back(nullptr);
+	actions.push_back(_moveUpAction);
+	actions.push_back(_moveDownAction);
+
+	if (mode == AddActionsMode::ContextMenu)
+	{
+		actions.push_back(nullptr);
+		actions.push_back(_removeAllLayersAction);
+	}
+
+	return actions;
+}
+
+void LayersListWidget::switchToObjectItem(LayersListItem* item, bool selectItem)
+{
+	if (item)
+	{
+		_imageEditor->controller().switchLayer(item->object());
+
+		if (selectItem)
+			setCurrentItem(item);
+	}
+}
+
+void LayersListWidget::newLayer()
+{
+	QString newLayerName = StringUtils::generateUniqueItemName(_imageEditor->controller().activeImageBuild()->layers(), "New layer", &Layer::getName);
+	bool ok = false;
+	auto name = QInputDialog::getText(this, "Layer name", "Enter the name of the new layer:", QLineEdit::Normal, newLayerName, &ok);
+
+	if (ok)
+	{
+		auto layer = _imageEditor->controller().createLayer(name.trimmed());
+
+		if (layer)
+		{
+			// Find the just-created layer item and switch to it
+			auto item = findObjectItem(layer.get());
+
+			if (item)
+				switchToObjectItem(item);
+		}
+	}
+}
+
+void LayersListWidget::moveLayerUp()
+{
+	if (auto layerSelected = currentObjectItem())
+		_imageEditor->controller().moveLayer(layerSelected->object(), true);
+}
+
+void LayersListWidget::moveLayerDown()
+{
+	if (auto layerSelected = currentObjectItem())
+		_imageEditor->controller().moveLayer(layerSelected->object(), false);
+}
+
+void LayersListWidget::removeLayer()
+{
+	if (auto layer = currentObject())
+	{
+		_imageEditor->controller().removeLayer(layer);
+
+		// Switch to the layer that is currently selected if no active one exists
+		if (!_imageEditor->controller().activeLayer())
+			switchToObjectItem(currentObjectItem());
+	}
+}
+
+void LayersListWidget::removeAllLayers()
+{
+	_imageEditor->controller().removeAllLayers();
+	updateActions();
+}
+
+void LayersListWidget::imageBuildSwitched(ImageBuild* imageBuild)
+{
+	// A new image build is shown in the editor, so populate its layers
+	populateList(imageBuild->layers(), true, true);
+}
+
+void LayersListWidget::layerCheckChanged(QListWidgetItem* item) const
+{
+	// Update the visibility of the layer
+	if (auto layerItem = dynamic_cast<LayersListItem*>(item))
+		_imageEditor->controller().setLayerVisibility(layerItem->object(), item->checkState() == Qt::Checked);
+}
+
+void LayersListWidget::layerSwitched(Layer* layer)
+{
+	objectSwitched(layer);
+	updateActions();
+}
+
+void LayersListWidget::layerRenamed(QWidget* editor) const
+{
+	if (auto layerItem = currentObjectItem())
+	{
+		viewport()->setUpdatesEnabled(false);
+
+		auto newName = reinterpret_cast<QLineEdit*>(editor)->text();
+		_imageEditor->controller().renameLayer(layerItem->object(), newName);
+
+		layerItem->updateItem();
+		viewport()->setUpdatesEnabled(true);
+	}
+}
+
+void LayersListWidget::selectedItemChanged()
+{
+	// The active layer switches immediately when selecting a layer item
+	switchLayer();
+	updateActions();
+}
+
+void LayersListWidget::updateActions()
+{
+	bool layerSelected = (currentObjectItem() != nullptr);
+
+	_renameLayerAction->setEnabled(layerSelected);
+	_moveUpAction->setEnabled(layerSelected && currentRow() > 0);
+	_moveDownAction->setEnabled(layerSelected && currentRow() < count() - 1);
+	_removeLayerAction->setEnabled(layerSelected);
+	_removeAllLayersAction->setEnabled(count() > 0);
+}
diff --git a/Grinder/ui/image/LayersListWidget.h b/Grinder/ui/image/LayersListWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..3d5617c941c585e63212033b34fb100916141311
--- /dev/null
+++ b/Grinder/ui/image/LayersListWidget.h
@@ -0,0 +1,69 @@
+/******************************************************************************
+ * File: LayersListWidget.h
+ * Date: 17.3.2018
+ *****************************************************************************/
+
+#ifndef LAYERSLISTWIDGET_H
+#define LAYERSLISTWIDGET_H
+
+#include "ui/widget/MetaWidget.h"
+#include "ui/widget/ObjectListWidget.h"
+#include "ImageEditorComponent.h"
+#include "LayersListItem.h"
+
+namespace grndr
+{
+	class ImageEditor;
+	class Layer;
+
+	using LayerObjectListWidget = ObjectListWidget<Layer, LayersListItem>;
+
+	class LayersListWidget : public MetaWidget<LayerObjectListWidget>, public ImageEditorComponent
+	{
+		Q_OBJECT
+
+	public:
+		LayersListWidget(QWidget* parent = nullptr);
+
+	public:
+		void assignUiComponents(ImageEditor* imageEditor);
+		void setupUi(QAction* newLayerAction, QMenu* menu, ControlBar* controlBar);
+
+	public:
+		void swapLayers(int indexOld, int indexNew);
+
+	protected:
+		virtual std::vector<QAction*> getActions(AddActionsMode mode) const override;
+
+	protected:
+		virtual void switchToObjectItem(LayersListItem* item, bool selectItem = true) override;
+
+	private slots:
+		void switchLayer() { switchToObjectItem(currentObjectItem()); }
+		void renameLayer() { editItem(currentItem()); }
+		void newLayer();
+		void moveLayerUp();
+		void moveLayerDown();
+		void removeLayer();
+		void removeAllLayers();
+
+		void imageBuildSwitched(ImageBuild* imageBuild);
+
+		void layerCheckChanged(QListWidgetItem* item) const;
+		void layerSwitched(Layer* layer);
+		void layerRenamed(QWidget* editor) const;
+
+		void selectedItemChanged();
+		void updateActions();
+
+	private:
+		QAction* _renameLayerAction{nullptr};
+		QAction* _newLayerAction{nullptr};
+		QAction* _moveUpAction{nullptr};
+		QAction* _moveDownAction{nullptr};
+		QAction* _removeLayerAction{nullptr};
+		QAction* _removeAllLayersAction{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/draftitems/BoxDraftItemNode.cpp b/Grinder/ui/image/draftitems/BoxDraftItemNode.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8fa9920ddc4540f8521e7e2978450fb7b242885a
--- /dev/null
+++ b/Grinder/ui/image/draftitems/BoxDraftItemNode.cpp
@@ -0,0 +1,37 @@
+/******************************************************************************
+ * File: BoxDraftItemNode.cpp
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BoxDraftItemNode.h"
+#include "ui/image/editors/RectangularInPlaceEditor.h"
+
+const DraftItemType BoxDraftItemNode::type_value = DraftItemType::Box;
+
+BoxDraftItemNode::BoxDraftItemNode(ImageEditorScene* scene, const std::shared_ptr<DraftItem>& item, QGraphicsItem* parent) : DraftItemNode(scene, item, parent)
+{
+	updateNode();
+}
+
+std::unique_ptr<grndr::InPlaceEditor> BoxDraftItemNode::createInPlaceEditor()
+{
+	if (auto draftItem = boxDraftItem())	// Make sure that the underlying draft item still exists
+		return std::make_unique<RectangularInPlaceEditor>(_scene, this, draftItem->position(), draftItem->boxSize());
+	else
+		return nullptr;
+}
+
+QRect BoxDraftItemNode::getBoundingRect() const
+{
+	if (auto draftItem = boxDraftItem())	// Make sure that the underlying draft item still exists
+	{
+		int lineWidth = *draftItem->lineWidth();
+		QSize size = *draftItem->boxSize() + QSize{lineWidth, lineWidth};
+
+		int margin = lineWidth * 2;
+		return QRect{QPoint{-lineWidth / 2, -lineWidth / 2}, size} + QMargins{margin, margin, margin, margin};
+	}
+	else
+		return QRect{};
+}
diff --git a/Grinder/ui/image/draftitems/BoxDraftItemNode.h b/Grinder/ui/image/draftitems/BoxDraftItemNode.h
new file mode 100644
index 0000000000000000000000000000000000000000..3d22be05e396b1e9fe9b89c839db0d338a807900
--- /dev/null
+++ b/Grinder/ui/image/draftitems/BoxDraftItemNode.h
@@ -0,0 +1,35 @@
+/******************************************************************************
+ * File: BoxDraftItemNode.h
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#ifndef BOXDRAFTITEMNODE_H
+#define BOXDRAFTITEMNODE_H
+
+#include "image/draftitems/BoxDraftItem.h"
+#include "ui/image/DraftItemNode.h"
+
+namespace grndr
+{
+	class BoxDraftItemNode : public DraftItemNode
+	{
+		Q_OBJECT
+
+	public:
+		static const DraftItemType type_value;
+
+	public:
+		BoxDraftItemNode(ImageEditorScene* scene, const std::shared_ptr<DraftItem>& item, QGraphicsItem* parent = nullptr);
+
+		auto boxDraftItem() { return std::dynamic_pointer_cast<BoxDraftItem>(_draftItem.lock()); }
+		auto boxDraftItem() const { return std::dynamic_pointer_cast<BoxDraftItem>(_draftItem.lock()); }
+
+	protected:
+		virtual std::unique_ptr<InPlaceEditor> createInPlaceEditor() override;
+
+	protected:
+		virtual QRect getBoundingRect() const override;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/draftitems/LineDraftItemNode.cpp b/Grinder/ui/image/draftitems/LineDraftItemNode.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..49376ae477b02f66d989b1dac5a054997da35ec5
--- /dev/null
+++ b/Grinder/ui/image/draftitems/LineDraftItemNode.cpp
@@ -0,0 +1,61 @@
+/******************************************************************************
+ * File: LineDraftItemNode.cpp
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LineDraftItemNode.h"
+#include "ui/image/editors/LinearInPlaceEditor.h"
+#include "util/MathUtils.h"
+
+const DraftItemType LineDraftItemNode::type_value = DraftItemType::Line;
+
+LineDraftItemNode::LineDraftItemNode(ImageEditorScene* scene, const std::shared_ptr<DraftItem>& item, QGraphicsItem* parent) : DraftItemNode(scene, item, parent)
+{
+	updateNode();
+}
+
+std::unique_ptr<grndr::InPlaceEditor> LineDraftItemNode::createInPlaceEditor()
+{
+	if (auto draftItem = lineDraftItem())	// Make sure that the underlying draft item still exists
+		return std::make_unique<LinearInPlaceEditor>(_scene, this, draftItem->position(), draftItem->endPosition());
+	else
+		return nullptr;
+}
+
+QRect LineDraftItemNode::getBoundingRect() const
+{
+	if (auto draftItem = lineDraftItem())	// Make sure that the underlying draft item still exists
+	{
+		QPoint position = *draftItem->position();
+		QPoint endPosition = *draftItem->endPosition();
+		auto width = std::abs(position.x() - endPosition.x());
+		auto height = std::abs(position.y() - endPosition.y());
+
+		int margin = *draftItem->lineWidth() * 2;
+		return QRect{QPoint{0, 0}, QSize{width, height}} + QMargins{margin, margin, margin, margin};
+	}
+	else
+		return QRect{};
+}
+
+QVariant LineDraftItemNode::itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant& value)
+{
+	QVariant result;
+
+	if (change == QGraphicsItem::ItemPositionHasChanged)
+	{
+		// Update the line draft item's end position when the item has been moved around
+		if (auto draftItem = lineDraftItem())	// Make sure that the underlying draft item still exists
+		{
+			QPoint newPosition = MathUtils::round(value.toPointF());
+			QPoint oldPosition = *draftItem->position();
+			auto movementDelta = newPosition - oldPosition;
+			auto endPosition = *draftItem->endPosition() + movementDelta;
+
+			draftItem->endPosition()->setValue(endPosition);
+		}
+	}
+
+	return DraftItemNode::itemChange(change, value);
+}
diff --git a/Grinder/ui/image/draftitems/LineDraftItemNode.h b/Grinder/ui/image/draftitems/LineDraftItemNode.h
new file mode 100644
index 0000000000000000000000000000000000000000..1c063397ce0f3fec82021e7339c638fe44f6f6c2
--- /dev/null
+++ b/Grinder/ui/image/draftitems/LineDraftItemNode.h
@@ -0,0 +1,40 @@
+/******************************************************************************
+ * File: LineDraftItemNode.h
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#ifndef LINEDRAFTITEMNODE_H
+#define LINEDRAFTITEMNODE_H
+
+#include "image/DraftItemType.h"
+#include "ui/image/DraftItemNode.h"
+#include "image/draftitems/LineDraftItem.h"
+
+namespace grndr
+{
+	class LineDraftItemNode : public DraftItemNode
+	{
+		Q_OBJECT
+
+	public:
+		static const DraftItemType type_value;
+
+	public:
+		LineDraftItemNode(ImageEditorScene* scene, const std::shared_ptr<DraftItem>& item, QGraphicsItem* parent = nullptr);
+
+	public:
+		auto lineDraftItem() { return std::dynamic_pointer_cast<LineDraftItem>(_draftItem.lock()); }
+		auto lineDraftItem() const { return std::dynamic_pointer_cast<LineDraftItem>(_draftItem.lock()); }
+
+	protected:
+		virtual std::unique_ptr<InPlaceEditor> createInPlaceEditor() override;
+
+	protected:
+		virtual QVariant itemChange(GraphicsItemChange change, const QVariant& value) override;
+
+	protected:
+		virtual QRect getBoundingRect() const override;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/editors/LinearInPlaceEditor.cpp b/Grinder/ui/image/editors/LinearInPlaceEditor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..fed21771e9fd88c2abbc32cafd2771a6829ce140
--- /dev/null
+++ b/Grinder/ui/image/editors/LinearInPlaceEditor.cpp
@@ -0,0 +1,40 @@
+/******************************************************************************
+ * File: LinearInPlaceEditor.cpp
+ * Date: 28.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LinearInPlaceEditor.h"
+
+LinearInPlaceEditor::LinearInPlaceEditor(ImageEditorScene* scene, DraftItemNode* draftItemNode, PointProperty* startPosition, PointProperty* endPosition) : InPlaceEditor(scene, draftItemNode),
+	_startPosition{startPosition}, _endPosition{endPosition}
+{
+	if (!startPosition)
+		throw std::invalid_argument{_EXCPT("startPosition may not be null")};
+
+	if (!endPosition)
+		throw std::invalid_argument{_EXCPT("endPosition may not be null")};
+
+	_startDragHandle = createDragHandle(InPlaceEditorDragHandle::AllowedDirection::All);
+	_endDragHandle = createDragHandle(InPlaceEditorDragHandle::AllowedDirection::All);
+}
+
+void LinearInPlaceEditor::updateEditor()
+{
+	updateDragHandlePositions();
+}
+
+void LinearInPlaceEditor::dragHandleMoved(InPlaceEditorDragHandle* dragHandle, QPoint offset)
+{
+	if (dragHandle == _startDragHandle)	// The start drag handle changes the start position of the line
+		_startPosition->setValue(*_startPosition + offset);
+	else if (dragHandle == _endDragHandle)	// The end drag handle changes the end position of the line
+		_endPosition->setValue(*_endPosition + offset);
+}
+
+void LinearInPlaceEditor::updateDragHandlePositions()
+{
+	// Reposition the end drag handle; the start drag handle always stays at 0,0
+	auto endPosition = *_endPosition - *_startPosition;
+	_endDragHandle->setPos(endPosition);
+}
diff --git a/Grinder/ui/image/editors/LinearInPlaceEditor.h b/Grinder/ui/image/editors/LinearInPlaceEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..8ac1ec6f8e560969c687a522b661a0c4d4ed705e
--- /dev/null
+++ b/Grinder/ui/image/editors/LinearInPlaceEditor.h
@@ -0,0 +1,39 @@
+/******************************************************************************
+ * File: LinearInPlaceEditor.h
+ * Date: 28.3.2018
+ *****************************************************************************/
+
+#ifndef LINEARINPLACEEDITOR_H
+#define LINEARINPLACEEDITOR_H
+
+#include "ui/image/InPlaceEditor.h"
+#include "common/properties/StandardProperties.h"
+
+namespace grndr
+{
+	class LinearInPlaceEditor : public InPlaceEditor
+	{
+		Q_OBJECT
+
+	public:
+		LinearInPlaceEditor(ImageEditorScene* scene, DraftItemNode* draftItemNode, PointProperty* startPosition, PointProperty* endPosition);
+
+	public:
+		virtual void updateEditor() override;
+
+	protected:
+		virtual void dragHandleMoved(InPlaceEditorDragHandle* dragHandle, QPoint offset) override;
+
+	private:
+		void updateDragHandlePositions();
+
+	private:
+		PointProperty* _startPosition{nullptr};
+		PointProperty* _endPosition{nullptr};
+
+		InPlaceEditorDragHandle* _startDragHandle{nullptr};
+		InPlaceEditorDragHandle* _endDragHandle{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/editors/RectangularInPlaceEditor.cpp b/Grinder/ui/image/editors/RectangularInPlaceEditor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..3f2a37563fbf5763cdec4f2c706377f67c40f28d
--- /dev/null
+++ b/Grinder/ui/image/editors/RectangularInPlaceEditor.cpp
@@ -0,0 +1,92 @@
+/******************************************************************************
+ * File: RectangularInPlaceEditor.cpp
+ * Date: 29.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "RectangularInPlaceEditor.h"
+
+#define DRAG_HANDLE_COUNT 8
+
+RectangularInPlaceEditor::RectangularInPlaceEditor(ImageEditorScene* scene, DraftItemNode* draftItemNode, PointProperty* position, SizeProperty* size) : InPlaceEditor(scene, draftItemNode),
+	_position{position}, _size{size}
+{
+	if (!position)
+		throw std::invalid_argument{_EXCPT("position may not be null")};
+
+	if (!size)
+		throw std::invalid_argument{_EXCPT("size may not be null")};
+
+	// Create all 8 drag handles, starting with the top-left one and advancing clockwise
+	for (int i = 1; i <= DRAG_HANDLE_COUNT; ++i)
+	{
+		InPlaceEditorDragHandle::AllowedDirection direction = InPlaceEditorDragHandle::AllowedDirection::All;
+
+		if (i % 4 == 0)
+			direction = InPlaceEditorDragHandle::AllowedDirection::EastWest;
+		else if (i % 2 == 0)
+			direction = InPlaceEditorDragHandle::AllowedDirection::NorthSouth;
+
+		_dragHandles.push_back(createDragHandle(direction));
+	}
+}
+
+void RectangularInPlaceEditor::updateEditor()
+{
+	updateDragHandlePositions();
+}
+
+void RectangularInPlaceEditor::dragHandleMoved(InPlaceEditorDragHandle* dragHandle, QPoint offset)
+{
+	QSize sizeOffset{0, 0};
+	QPoint posOffset{0, 0};
+
+	auto it = std::find(_dragHandles.cbegin(), _dragHandles.cend(), dragHandle);
+
+	if (it != _dragHandles.cend())
+	{
+		auto handleIndex = it - _dragHandles.cbegin();
+
+		// Set the offset value depending on where the drag handle is located
+		switch (handleIndex)
+		{
+		case 0: case 1: case 7:
+			posOffset = offset;
+			sizeOffset = QSize{-offset.x(), -offset.y()};
+			break;
+
+		case 2:
+			posOffset = QPoint{0, offset.y()};
+			sizeOffset = QSize{offset.x(), -offset.y()};
+			break;
+
+		case 3: case 4: case 5:
+			sizeOffset = QSize{offset.x(), offset.y()};
+			break;
+
+		case 6:
+			posOffset = QPoint{offset.x(), 0};
+			sizeOffset = QSize{-offset.x(), offset.y()};
+			break;
+		}
+	}
+
+	_position->setValue(*_position + posOffset);
+	_size->setValue(*_size + sizeOffset);
+}
+
+void RectangularInPlaceEditor::updateDragHandlePositions()
+{
+	// Create a vector containing all relative drag handle positions
+	static const std::vector<QPointF> relativePositions{{0.0, 0.0}, {0.5, 0.0}, {1.0, 0.0}, {1.0, 0.5}, {1.0, 1.0}, {0.5, 1.0}, {0.0, 1.0}, {0.0, 0.5}};
+
+	// Move all drag handles to their relative positions
+	QSize size = *_size;
+
+	for (unsigned int i = 0; i < _dragHandles.size(); ++i)
+	{
+		QPointF pos{size.width() * relativePositions[i].x(), size.height() * relativePositions[i].y()};
+		_dragHandles[i]->setPos(pos);
+	}
+}
+
diff --git a/Grinder/ui/image/editors/RectangularInPlaceEditor.h b/Grinder/ui/image/editors/RectangularInPlaceEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..4debf911253150241b9fdc37296a370831db99e2
--- /dev/null
+++ b/Grinder/ui/image/editors/RectangularInPlaceEditor.h
@@ -0,0 +1,38 @@
+/******************************************************************************
+ * File: RectangularInPlaceEditor.h
+ * Date: 29.3.2018
+ *****************************************************************************/
+
+#ifndef RECTANGULARINPLACEEDITOR_H
+#define RECTANGULARINPLACEEDITOR_H
+
+#include "ui/image/InPlaceEditor.h"
+#include "common/properties/StandardProperties.h"
+
+namespace grndr
+{
+	class RectangularInPlaceEditor : public InPlaceEditor
+	{
+		Q_OBJECT
+
+	public:
+		RectangularInPlaceEditor(ImageEditorScene* scene, DraftItemNode* draftItemNode, PointProperty* position, SizeProperty* size);
+
+	public:
+		virtual void updateEditor() override;
+
+	protected:
+		virtual void dragHandleMoved(InPlaceEditorDragHandle* dragHandle, QPoint offset) override;
+
+	private:
+		void updateDragHandlePositions();
+
+	private:
+		PointProperty* _position{nullptr};
+		SizeProperty* _size{nullptr};
+
+		std::vector<InPlaceEditorDragHandle*> _dragHandles;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/tools/BoxDraftItemTool.cpp b/Grinder/ui/image/tools/BoxDraftItemTool.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..4a52472bb0fe04cf28509e2c01b768deec0a663e
--- /dev/null
+++ b/Grinder/ui/image/tools/BoxDraftItemTool.cpp
@@ -0,0 +1,32 @@
+/******************************************************************************
+ * File: BoxDraftItemTool.cpp
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BoxDraftItemTool.h"
+#include "common/properties/RangeConstraint.h"
+#include "res/Resources.h"
+
+const char* BoxDraftItemTool::tool_type = "BoxDraftItemTool";
+
+BoxDraftItemTool::BoxDraftItemTool(ImageEditor* imageEditor) : DraftItemTool(imageEditor, DraftItemType::Box, "Bounding box", FILE_ICON_EDITOR_BOX, "B", QCursor{QPixmap{FILE_CURSOR_EDITOR_BOX}, 0, 0})
+{
+
+}
+
+void BoxDraftItemTool::createProperties()
+{
+	DraftItemTool::createProperties();
+
+	// Create specific properties
+	_boxSize = createProperty<SizeProperty>(PropertyID::Size, "Size", QSize{50, 50});
+	boxSize()->setDescription("The size of the box.");
+
+	_lineWidth = createProperty<UIntProperty>(PropertyID::LineWidth, "Line width", 5);
+	lineWidth()->createConstraint<RangeConstraint>(1, 100);
+	lineWidth()->setDescription("The width of the box lines.");	
+
+	// Override some property defaults
+	hasDirection()->setValue(true);
+}
diff --git a/Grinder/ui/image/tools/BoxDraftItemTool.h b/Grinder/ui/image/tools/BoxDraftItemTool.h
new file mode 100644
index 0000000000000000000000000000000000000000..38b528734204be9ba5554ea85a9c32d97901ae9b
--- /dev/null
+++ b/Grinder/ui/image/tools/BoxDraftItemTool.h
@@ -0,0 +1,39 @@
+/******************************************************************************
+ * File: BoxDraftItemTool.h
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#ifndef BOXDRAFTITEMTOOL_H
+#define BOXDRAFTITEMTOOL_H
+
+#include "DraftItemTool.h"
+
+namespace grndr
+{
+	class BoxDraftItemTool : public DraftItemTool
+	{
+	public:
+		static const char* tool_type;
+
+	public:
+		BoxDraftItemTool(ImageEditor* imageEditor);
+
+	public:
+		virtual QString getToolType() const override { return tool_type; }
+
+	public:
+		auto boxSize() { return dynamic_cast<SizeProperty*>(_boxSize.get()); }
+		auto boxSize() const { return dynamic_cast<SizeProperty*>(_boxSize.get()); }
+		auto lineWidth() { return dynamic_cast<UIntProperty*>(_lineWidth.get()); }
+		auto lineWidth() const { return dynamic_cast<UIntProperty*>(_lineWidth.get()); }		
+
+	protected:
+		virtual void createProperties() override;
+
+	private:
+		std::shared_ptr<PropertyBase> _boxSize;
+		std::shared_ptr<PropertyBase> _lineWidth;		
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/tools/ColorPickerTool.cpp b/Grinder/ui/image/tools/ColorPickerTool.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..834dbc10f783e44b4a36bec6448c37a59df4b00a
--- /dev/null
+++ b/Grinder/ui/image/tools/ColorPickerTool.cpp
@@ -0,0 +1,61 @@
+/******************************************************************************
+ * File: ColorPickerTool.cpp
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ColorPickerTool.h"
+#include "controller/ImageEditorController.h"
+#include "ui/image/ImageEditor.h"
+#include "res/Resources.h"
+
+const char* ColorPickerTool::tool_type = "ColorPickerTool";
+
+ColorPickerTool::ColorPickerTool(ImageEditor* imageEditor) : ImageEditorTool(imageEditor, "Color picker", FILE_ICON_EDITOR_COLORPICKER, "K", QCursor{QPixmap{FILE_CURSOR_EDITOR_COLORPICKER}, 0, 23})
+{
+
+}
+
+void ColorPickerTool::toolActivated(ImageEditorTool* prevTool)
+{
+	_previousTool = prevTool;
+	_switchToPreviousTool = false;
+}
+
+ImageEditorTool::InputEventResult ColorPickerTool::mousePressed(const QGraphicsSceneMouseEvent* event)
+{
+	if (event->widget())
+	{
+		// Grab the current scene display and get the pixel color under the cursor		
+		auto pixmap = event->widget()->grab();
+		auto image = pixmap.toImage();
+		auto pos = event->widget()->mapFromGlobal(event->screenPos());
+
+		// Set the grabbed color as the primary one and switch back to the previous tool
+		_imageEditor->environment().setPrimaryColor(image.pixelColor(pos));
+		_switchToPreviousTool = true;
+	}
+
+	return InputEventResult::Process;
+}
+
+ImageEditorTool::InputEventResult ColorPickerTool::mouseMoved(const QGraphicsSceneMouseEvent* event)
+{
+	Q_UNUSED(event);
+	return InputEventResult::Process;
+}
+
+ImageEditorTool::InputEventResult ColorPickerTool::mouseReleased(const QGraphicsSceneMouseEvent* event)
+{
+	Q_UNUSED(event);
+
+	if (_switchToPreviousTool)
+	{
+		if (_previousTool)
+			_imageEditor->editorTools().activateTool(_previousTool->getToolType());
+
+		_switchToPreviousTool = false;
+	}
+
+	return InputEventResult::Process;
+}
diff --git a/Grinder/ui/image/tools/ColorPickerTool.h b/Grinder/ui/image/tools/ColorPickerTool.h
new file mode 100644
index 0000000000000000000000000000000000000000..0df6229f97b59df754b9466f944cc770d319186c
--- /dev/null
+++ b/Grinder/ui/image/tools/ColorPickerTool.h
@@ -0,0 +1,38 @@
+/******************************************************************************
+ * File: ColorPickerTool.h
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#ifndef ColorPickerTool_H
+#define ColorPickerTool_H
+
+#include "ui/image/ImageEditorTool.h"
+
+namespace grndr
+{
+	class ColorPickerTool : public ImageEditorTool
+	{
+	public:
+		static const char* tool_type;
+
+	public:
+		ColorPickerTool(ImageEditor* imageEditor);
+
+	public:
+		virtual void toolActivated(ImageEditorTool* prevTool) override;
+
+	public:
+		virtual QString getToolType() const override { return tool_type; }
+
+	protected:
+		virtual InputEventResult mousePressed(const QGraphicsSceneMouseEvent* event) override;
+		virtual InputEventResult mouseMoved(const QGraphicsSceneMouseEvent* event) override;
+		virtual InputEventResult mouseReleased(const QGraphicsSceneMouseEvent* event) override;
+
+	private:
+		ImageEditorTool* _previousTool{nullptr};
+		bool _switchToPreviousTool{false};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/tools/DefaultImageEditorTool.cpp b/Grinder/ui/image/tools/DefaultImageEditorTool.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0964572af11c33c0352f9248b8b610c8874bf9ee
--- /dev/null
+++ b/Grinder/ui/image/tools/DefaultImageEditorTool.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: DefaultImageEditorTool.cpp
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DefaultImageEditorTool.h"
+#include "res/Resources.h"
+
+const char* DefaultImageEditorTool::tool_type = "DefaultImageEditorTool";
+
+DefaultImageEditorTool::DefaultImageEditorTool(ImageEditor* imageEditor) : ImageEditorTool(imageEditor, "Selection tool", FILE_ICON_EDITOR_DEFAULT, "Esc")
+{
+	_isActionShortcut = false;
+}
diff --git a/Grinder/ui/image/tools/DefaultImageEditorTool.h b/Grinder/ui/image/tools/DefaultImageEditorTool.h
new file mode 100644
index 0000000000000000000000000000000000000000..f09cb6bb1cc820840919644fb550789f534cd3d5
--- /dev/null
+++ b/Grinder/ui/image/tools/DefaultImageEditorTool.h
@@ -0,0 +1,28 @@
+/******************************************************************************
+ * File: DefaultImageEditorTool.h
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#ifndef DEFAULTIMAGEEDITORTOOL_H
+#define DEFAULTIMAGEEDITORTOOL_H
+
+#include "ui/image/ImageEditorTool.h"
+
+namespace grndr
+{
+	class DefaultImageEditorTool : public ImageEditorTool
+	{
+		Q_OBJECT
+
+	public:
+		static const char* tool_type;
+
+	public:
+		DefaultImageEditorTool(ImageEditor* imageEditor);
+
+	public:
+		virtual QString getToolType() const override { return tool_type; }
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/tools/DraftItemTool.cpp b/Grinder/ui/image/tools/DraftItemTool.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..52c2eedb969e8dd22a2f50e0b741acd75f20492e
--- /dev/null
+++ b/Grinder/ui/image/tools/DraftItemTool.cpp
@@ -0,0 +1,163 @@
+/******************************************************************************
+ * File: DraftItemTool.cpp
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DraftItemTool.h"
+#include "common/properties/RangeConstraint.h"
+#include "controller/ImageEditorController.h"
+#include "ui/image/ImageEditor.h"
+#include "util/MathUtils.h"
+
+#include <cmath>
+
+DraftItemTool::DraftItemTool(ImageEditor* imageEditor, DraftItemType itemType, QString name, QString icon, QString shortcut, QCursor cursor) : ImageEditorTool(imageEditor, name, icon, shortcut, cursor),
+	_itemType{itemType}
+{
+	if (itemType == DraftItemType::Undefined)
+		throw std::invalid_argument{_EXCPT("itemType may not be DraftItemType::Undefined")};
+}
+
+std::shared_ptr<DraftItem> DraftItemTool::createDraftItem(QPointF pos, bool selectItem, bool setDefaultProperties)
+{
+	// If there are no layers, create a default one and activate it
+	if (!_imageEditor->controller().activeLayer())
+	{		
+		auto layer = _imageEditor->controller().createLayer("Default layer");
+		_imageEditor->controller().switchLayer(layer.get());
+	}
+
+	// Create the draft item
+	auto draftItem = _imageEditor->controller().createDraftItem(_itemType);
+
+	// Update the position and color properties before copying all properties to the draft item
+	position()->setValue(MathUtils::round(pos));
+	primaryColor()->setValue(_imageEditor->environment().getPrimaryColor());
+
+	// Apply all property values to the new item
+	draftItem->properties().copyValues(_properties);
+
+	if (setDefaultProperties)
+		draftItem->setDefaultPropertyValues();
+
+	if (selectItem)
+		selectDraftItem(draftItem);
+
+	return draftItem;
+}
+
+void DraftItemTool::selectDraftItem(const std::shared_ptr<DraftItem>& draftItem) const
+{
+	// Automatically switch to the default editor tool in order to edit the draft item's properties
+	_imageEditor->editorTools().activateDefaultTool();
+
+	if (auto scene = _imageEditor->controller().activeScene())
+	{
+		// Find the just-created draft item node and center on it
+		auto draftItemNode = scene->findDraftItemNode(draftItem.get());
+
+		if (draftItemNode)
+		{
+			scene->clearSelection();
+			draftItemNode->setSelected(true);
+			draftItemNode->setFocus();
+		}
+	}
+}
+
+void DraftItemTool::createProperties()
+{
+	ImageEditorTool::createProperties();
+
+	// Create standard properties
+	_primaryColor = createProperty<ColorProperty>(PropertyID::PrimaryColor, "Primary color", QColor{255, 255, 255}, PropertyBase::Flag::Hidden);
+	primaryColor()->setDescription("The primary color to use.");
+
+	_position = createProperty<PointProperty>(PropertyID::Position, "Position", QPoint{0, 0}, PropertyBase::Flag::Hidden);
+	position()->setDescription("The item position.");
+
+	_hasDirection = createProperty<BoolProperty>(PropertyID::HasDirection, "Has direction", false);
+	hasDirection()->setDescription("Specifies whether the box has a direction/orientation.");
+
+	_direction = createProperty<AngleProperty>(PropertyID::Direction, "Direction", 0.0);
+	direction()->createConstraint<RangeConstraint>(0, 360);
+	direction()->setDescription("The main direction/orientation of the box contents.");
+}
+
+ImageEditorTool::InputEventResult DraftItemTool::mousePressed(const QGraphicsSceneMouseEvent* event)
+{
+	// Remember the initial position for later use
+	_dragInfo.dragInitiated = true;
+	_dragInfo.mousePressPos	= event->scenePos();
+
+	return InputEventResult::Process;
+}
+
+ImageEditorTool::InputEventResult DraftItemTool::mouseMoved(const QGraphicsSceneMouseEvent* event)
+{
+	if (_dragInfo.dragInitiated)
+	{		
+		// If the mouse has been moved far enough from the initial position, initiate a drag
+		if ((event->scenePos() - _dragInfo.mousePressPos).manhattanLength() >= QApplication::startDragDistance())
+		{
+			startDraftItemDrag();
+			_dragInfo.dragInitiated = false;
+		}
+	}
+
+	// If a drag info item has been created by dragging, update it according to the current mouse position
+	if (_dragInfo.draftItem)
+		_dragInfo.draftItem->setDragPropertyValues(MathUtils::round(_dragInfo.mousePressPos), MathUtils::round(event->scenePos()));
+
+	return InputEventResult::Process;
+}
+
+ImageEditorTool::InputEventResult DraftItemTool::mouseReleased(const QGraphicsSceneMouseEvent* event)
+{
+	if (_dragInfo.draftItem)	// Normalize the property values of the click-drag created draft item when finished dragging
+	{
+		_dragInfo.draftItem->normalizePropertyValues();
+		selectDraftItem(_dragInfo.draftItem);
+	}
+	else	// If no dragging was initiated, create a draft item with default values
+	{
+		if (!_dragInfo.dragCancelled)
+			createDraftItem(event->scenePos(), !event->modifiers().testFlag(Qt::ControlModifier), true);
+	}
+
+	_dragInfo.dragInitiated = false;
+	_dragInfo.draftItem = nullptr;
+
+	return InputEventResult::Process;
+}
+
+VisualSceneInputHandler::InputEventResult DraftItemTool::keyPressed(const QKeyEvent* event)
+{
+	// When pressing Esc, cancel any click-drag draft item creation
+	if (event->key() == Qt::Key_Escape && _dragInfo.draftItem)
+	{
+		cancelDraftItemDrag();
+		return VisualSceneInputHandler::InputEventResult::Process;
+	}
+	else
+		return VisualSceneInputHandler::InputEventResult::Ignore;
+}
+
+void DraftItemTool::startDraftItemDrag()
+{
+	// The drag has just been initiated, so create a new draft item
+	_dragInfo.dragCancelled = false;
+	_dragInfo.draftItem = createDraftItem(_dragInfo.mousePressPos, false, false);
+}
+
+void DraftItemTool::cancelDraftItemDrag()
+{
+	_dragInfo.dragCancelled = true;
+
+	if (_dragInfo.draftItem)
+	{
+		_imageEditor->controller().removeDraftItem(_dragInfo.draftItem.get());
+		_dragInfo.draftItem = nullptr;
+	}
+}
diff --git a/Grinder/ui/image/tools/DraftItemTool.h b/Grinder/ui/image/tools/DraftItemTool.h
new file mode 100644
index 0000000000000000000000000000000000000000..ee67d047db73543b56f8fe33b37315d0841c70c4
--- /dev/null
+++ b/Grinder/ui/image/tools/DraftItemTool.h
@@ -0,0 +1,72 @@
+/******************************************************************************
+ * File: DraftItemTool.h
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#ifndef DRAFTITEMTOOL_H
+#define DRAFTITEMTOOL_H
+
+#include "ui/image/ImageEditorTool.h"
+#include "image/DraftItemType.h"
+
+namespace grndr
+{
+	class DraftItem;
+
+	class DraftItemTool : public ImageEditorTool
+	{
+		Q_OBJECT
+
+	public:
+		DraftItemTool(ImageEditor* imageEditor, DraftItemType itemType, QString name, QString icon, QString shortcut, QCursor cursor = QCursor{Qt::ArrowCursor});
+
+	public:
+		DraftItemType getItemType() const { return _itemType; }
+
+	public:
+		auto primaryColor() { return dynamic_cast<ColorProperty*>(_primaryColor.get()); }
+		auto primaryColor() const { return dynamic_cast<ColorProperty*>(_primaryColor.get()); }
+		auto position() { return dynamic_cast<PointProperty*>(_position.get()); }
+		auto position() const { return dynamic_cast<PointProperty*>(_position.get()); }
+		auto hasDirection() { return dynamic_cast<BoolProperty*>(_hasDirection.get()); }
+		auto hasDirection() const { return dynamic_cast<BoolProperty*>(_hasDirection.get()); }
+		auto direction() { return dynamic_cast<AngleProperty*>(_direction.get()); }
+		auto direction() const { return dynamic_cast<AngleProperty*>(_direction.get()); }
+
+	protected:
+		std::shared_ptr<DraftItem> createDraftItem(QPointF pos, bool selectItem, bool setDefaultProperties);
+		void selectDraftItem(const std::shared_ptr<DraftItem>& draftItem) const;
+
+	protected:
+		virtual void createProperties() override;
+
+		virtual InputEventResult mousePressed(const QGraphicsSceneMouseEvent* event) override;
+		virtual InputEventResult mouseMoved(const QGraphicsSceneMouseEvent* event) override;
+		virtual InputEventResult mouseReleased(const QGraphicsSceneMouseEvent* event) override;
+
+		virtual InputEventResult keyPressed(const QKeyEvent* event) override;
+
+	private:
+		void startDraftItemDrag();
+		void cancelDraftItemDrag();
+
+	protected:
+		DraftItemType _itemType{DraftItemType::Undefined};
+
+		std::shared_ptr<PropertyBase> _primaryColor;
+		std::shared_ptr<PropertyBase> _position;
+		std::shared_ptr<PropertyBase> _hasDirection;
+		std::shared_ptr<PropertyBase> _direction;
+
+	private:
+		struct
+		{
+			bool dragInitiated{false};
+			bool dragCancelled{false};
+			QPointF mousePressPos;
+			std::shared_ptr<DraftItem> draftItem;
+		} _dragInfo;		
+	};
+}
+
+#endif
diff --git a/Grinder/ui/image/tools/LineDraftItemTool.cpp b/Grinder/ui/image/tools/LineDraftItemTool.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e90b14fd58d456a92d04a8bb3dba146a4f8f4bb8
--- /dev/null
+++ b/Grinder/ui/image/tools/LineDraftItemTool.cpp
@@ -0,0 +1,26 @@
+/******************************************************************************
+ * File: LineDraftItemTool.cpp
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LineDraftItemTool.h"
+#include "common/properties/RangeConstraint.h"
+#include "res/Resources.h"
+
+const char* LineDraftItemTool::tool_type = "LineDraftItemTool";
+
+LineDraftItemTool::LineDraftItemTool(ImageEditor* imageEditor) : DraftItemTool(imageEditor, DraftItemType::Line, "Line", FILE_ICON_EDITOR_LINE, "L", QCursor{QPixmap{FILE_CURSOR_EDITOR_LINE}, 0, 0})
+{
+
+}
+
+void LineDraftItemTool::createProperties()
+{
+	DraftItemTool::createProperties();
+
+	// Create specific properties
+	_lineWidth = createProperty<UIntProperty>(PropertyID::LineWidth, "Line width", 3);
+	lineWidth()->createConstraint<RangeConstraint>(1, 100);
+	lineWidth()->setDescription("The width of the line.");
+}
diff --git a/Grinder/ui/image/tools/LineDraftItemTool.h b/Grinder/ui/image/tools/LineDraftItemTool.h
new file mode 100644
index 0000000000000000000000000000000000000000..e0daaa51cc7411b6f2ebe21f78541ea9dbb49768
--- /dev/null
+++ b/Grinder/ui/image/tools/LineDraftItemTool.h
@@ -0,0 +1,36 @@
+/******************************************************************************
+ * File: LineDraftItemTool.h
+ * Date: 22.3.2018
+ *****************************************************************************/
+
+#ifndef LINEDRAFTITEMTOOL_H
+#define LINEDRAFTITEMTOOL_H
+
+#include "DraftItemTool.h"
+
+namespace grndr
+{
+	class LineDraftItemTool : public DraftItemTool
+	{
+	public:
+		static const char* tool_type;
+
+	public:
+		LineDraftItemTool(ImageEditor* imageEditor);
+
+	public:
+		virtual QString getToolType() const override { return tool_type; }
+
+	public:
+		auto lineWidth() { return dynamic_cast<UIntProperty*>(_lineWidth.get()); }
+		auto lineWidth() const { return dynamic_cast<UIntProperty*>(_lineWidth.get()); }
+
+	protected:
+		virtual void createProperties() override;
+
+	private:
+		std::shared_ptr<PropertyBase> _lineWidth;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/mainwnd/GrinderWindow.cpp b/Grinder/ui/mainwnd/GrinderWindow.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..53cd011d6ce81260b65cfd03b2e3fe036023aef7
--- /dev/null
+++ b/Grinder/ui/mainwnd/GrinderWindow.cpp
@@ -0,0 +1,372 @@
+/******************************************************************************
+ * File: GrinderWindow.cpp
+ * Date: 11.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GrinderWindow.h"
+#include "ui_GrinderWindow.h"
+#include "Version.h"
+#include "common/AdjacentRange.h"
+#include "ui/StyleSheet.h"
+#include "ui/graph/GraphView.h"
+#include "ui/dlg/OptionsDialog.h"
+#include "core/GrinderApplication.h"
+#include "pipeline/Pipeline.h"
+#include "project/ProjectExceptions.h"
+#include "util/UIUtils.h"
+#include "res/Resources.h"
+
+#include <QMetaType>
+Q_DECLARE_METATYPE(std::shared_ptr<Block>)
+
+GrinderWindow::GrinderWindow(QWidget* parent) : QMainWindow(parent),
+	ui{new Ui::GrinderWindow()}
+{
+	// Needed to be able to queue blockRemoved signals
+	qRegisterMetaType<std::shared_ptr<Block>>();
+
+	setupUi();
+
+	// Connect signals to update our UI
+	connect(&grinder()->pipelineController(), &PipelineController::pipelineSwitched, this, &GrinderWindow::pipelineSwitched);
+	connect(&grinder()->projectController(), &ProjectController::currentProjectFileChanged, this, &GrinderWindow::currentProjectFileChanged);
+	connect(&grinder()->projectController(), SIGNAL(projectLoaded(QString)), this, SLOT(updateRecentProjects(QString)));
+	connect(&grinder()->projectController(), SIGNAL(projectSaved(QString)), this, SLOT(updateRecentProjects(QString)));
+	connect(&grinder()->project(), SIGNAL(imageReferenceCreated(const std::shared_ptr<ImageReference>&)), this, SLOT(updateActions()));
+	connect(&grinder()->project(), SIGNAL(imageReferenceRemoved(const std::shared_ptr<ImageReference>&)), this, SLOT(updateActions()));
+
+	// Connect image editor manager signals to show/hide the image editors
+	connect(&grinder()->imageEditorManager(), &ImageEditorManager::editorShown, this, &GrinderWindow::imageEditorShown);
+	connect(&grinder()->imageEditorManager(), &ImageEditorManager::editorClosed, this, &GrinderWindow::imageEditorClosed);
+
+	updateActions();
+
+	// If the corresponding option is enabled, load the most recent project; otherwise, create a new project
+	bool createNewProject = true;
+
+	if (grinder()->settings().startupOptions().loadLastProject)
+		createNewProject = !loadProjectOnStartup();
+
+	if (createNewProject)
+		on_actNewProject_triggered();
+}
+
+GrinderWindow::~GrinderWindow()
+{
+	delete ui;
+}
+
+void GrinderWindow::closeEvent(QCloseEvent* event)
+{
+	if (!handleUnsavedProjectChanges())
+	{
+		event->ignore();
+		return;
+	}
+
+	grinder()->settings().setWindowState("MainWindow", this);
+
+	QMainWindow::closeEvent(event);
+}
+
+void GrinderWindow::on_actNewProject_triggered()
+{
+	if (!handleUnsavedProjectChanges())
+		return;
+
+	grinder()->projectController().clearProject(true);
+	ui->statusBar->showMessage("Created a new project");
+}
+
+void GrinderWindow::on_actLoadProject_triggered()
+{
+	if (!handleUnsavedProjectChanges())
+		return;
+
+	QString fileName = UIUtils::askFileName(false, "ProjectFile", this, "Load project", "Grinder projects (*.grndr);;All files (*.*)");
+
+	if (!fileName.isEmpty())
+		loadProject(fileName);
+}
+
+void GrinderWindow::on_actSaveProject_triggered()
+{
+	QString fileName = grinder()->projectController().getCurrentProjectFile();
+
+	if (!fileName.isEmpty())
+		saveProject(fileName);
+	else
+		on_actSaveProjectAs_triggered();
+}
+
+void GrinderWindow::on_actSaveProjectAs_triggered()
+{
+	QString fileName = UIUtils::askFileName(true, "ProjectFile", this, "Save project as...", "Grinder projects (*.grndr)");
+
+	if (!fileName.isEmpty())
+		saveProject(fileName);
+}
+
+void GrinderWindow::on_actExecuteLabel_triggered()
+{
+	if (const auto activeLabel = grinder()->projectController().activeLabel())
+		grinder()->engineController().executeLabel(activeLabel, Engine::ExecutionMode::View);
+}
+
+void GrinderWindow::on_actExecuteNextImage_triggered()
+{
+	ui->imageReferencesList->nextImageReference();
+	on_actExecuteLabel_triggered();
+}
+
+void GrinderWindow::on_actExecutePreviousImage_triggered()
+{
+	ui->imageReferencesList->previousImageReference();
+	on_actExecuteLabel_triggered();
+}
+
+void GrinderWindow::on_actOptions_triggered()
+{
+	OptionsDialog dlg;
+	dlg.exec();
+}
+
+void GrinderWindow::on_actAbout_triggered()
+{
+	auto title = QString{"About %1..."}.arg(GRNDR_INFO_TITLE);
+	auto text = QString{"<b>%1 %2</b><br>%3<br><br><a href='%4'>%4</a>"}.arg(GRNDR_INFO_TITLE).arg(GetVersionString(true)).arg(GRNDR_INFO_COPYRIGHT).arg(GRNDR_INFO_WEBSITE);
+
+	QMessageBox::about(this, title, text);
+}
+
+void GrinderWindow::pipelineSwitched(Pipeline* pipeline)
+{
+	static std::vector<QMetaObject::Connection> pipelineSignals;
+
+	// Remove old pipeline signals and connect new ones so that the actions can be updated when necessary
+	for (auto& signal : pipelineSignals)
+		disconnect(signal);
+
+	pipelineSignals.clear();
+
+	if (pipeline)
+	{
+		pipelineSignals.push_back(connect(pipeline, &Pipeline::blockCreated, this, &GrinderWindow::updateActions));
+		pipelineSignals.push_back(connect(pipeline, &Pipeline::blockRemoved, this, &GrinderWindow::updateActions, Qt::QueuedConnection));	// Delay this signal so that the block has been removed from the blocks list
+	}
+
+	updateActions();
+}
+
+void GrinderWindow::currentProjectFileChanged(QString fileName)
+{
+	grinder()->settings().startupOptions().lastProjectFile = fileName;
+	grinder()->settings().saveSettings(false);
+
+	updateCaption();
+}
+
+void GrinderWindow::imageEditorShown(ImageEditor* editor)
+{
+	// Add the editor to the dock area if it hasn't been added yet
+	if (!findChildren<QDockWidget*>().contains(editor->dockWidget()))
+		addDockWidget(Qt::BottomDockWidgetArea, editor->dockWidget());
+
+	if (grinder()->settings().imageEditorOptions().startUndocked)
+	{
+		// Show the editor in a floating window
+		editor->dockWidget()->setFloating(true);
+	}
+	else
+	{
+		// Group all bottom image editor docks into tabs
+		if (grinder()->settings().imageEditorOptions().groupIntoTabs)
+			tabifyImageEditorDocks();
+	}
+}
+
+void GrinderWindow::imageEditorClosed(ImageEditor* editor)
+{
+	// Remove the editor from the dock area
+	removeDockWidget(editor->dockWidget());
+}
+
+void GrinderWindow::updateCaption()
+{
+	QString caption = QString{"%1 %2"}.arg(GRNDR_INFO_TITLE).arg(GetVersionString());
+
+	if (!grinder()->projectController().getCurrentProjectFile().isEmpty())
+		caption += QString{" - %1"}.arg(grinder()->projectController().getCurrentProjectFile());
+	else
+		caption += " - New project";
+
+	setWindowTitle(caption);
+}
+
+void GrinderWindow::setupUi()
+{
+	ui->setupUi(this);
+
+	// Assign the UI components to the controllers
+	grinder()->pipelineController().assignUiComponents(ui->graphWidget->graphView());
+	grinder()->projectController().assignUiComponents(ui->labelsList, ui->imageReferencesList);
+
+	// Setup the various UI components
+	ui->graphWidget->graphView()->setupUi(ui->menu_Graph, ui->graphToolBar);
+	ui->labelsList->setupUi(ui->menu_Labels, ui->labelsControlBar);
+	ui->imageReferencesList->setupUi(ui->menu_Images, ui->imageReferencesControlBar);
+	ui->menu_Recent_Projects->setupUi(&grinder()->settings().recentProjects(), [this](QString fileName) { loadProject(fileName); });
+	ui->propertiesTree->setupUi(ui->lblPropertyDesc);
+
+	ui->frmPropertyDesc->setStyleSheet(StyleSheet::loadStyleSheet(FILE_STYLESHEET_PROPERTYDESC));
+
+	grinder()->settings().getWindowState("MainWindow", this);
+
+	updateCaption();
+}
+
+void GrinderWindow::updateActions()
+{
+	auto activePipeline = grinder()->pipelineController().activePipeline();
+	bool enableExecute = activePipeline && !activePipeline->blocks().empty();
+
+	ui->actExecuteLabel->setEnabled(enableExecute);
+	ui->actExecuteNextImage->setEnabled(enableExecute && ui->imageReferencesList->count() > 1);
+	ui->actExecutePreviousImage->setEnabled(enableExecute && ui->imageReferencesList->count() > 1);
+}
+
+bool GrinderWindow::loadProject(QString fileName)
+{
+	bool result = true;
+
+	setEnabled(false);
+
+	try {
+		grinder()->projectController().loadProject(fileName);
+		ui->statusBar->showMessage(QString{"Loaded project '%1'"}.arg(fileName));
+	} catch (ProjectException& e) {
+		ui->statusBar->showMessage(GetExceptionMessage(e.what()));
+		updateRecentProjects(fileName, false);
+
+		result = false;
+	}
+
+	setEnabled(true);
+
+	return result;
+}
+
+bool GrinderWindow::saveProject(QString fileName)
+{
+	bool result = true;
+
+	setEnabled(false);
+
+	try {
+		grinder()->projectController().saveProject(fileName);
+		ui->statusBar->showMessage(QString{"Saved the current project to '%1'"}.arg(fileName));
+	} catch (ProjectException& e) {
+		ui->statusBar->showMessage(GetExceptionMessage(e.what()));
+
+		result = false;
+	}
+
+	setEnabled(true);
+
+	return result;
+}
+
+bool GrinderWindow::loadProjectOnStartup()
+{
+	QString lastProject = grinder()->settings().startupOptions().lastProjectFile;
+
+	if (!lastProject.isEmpty() && QFile::exists(lastProject))
+		return loadProject(lastProject);
+	else
+		return false;
+}
+
+bool GrinderWindow::handleUnsavedProjectChanges()
+{
+	if (grinder()->settings().generalOptions().askOnUnsavedChanges)
+	{
+		// Save the current graph layout so that any changes will appear in the isProjectDirty call
+		ui->graphWidget->graphView()->updateCurrentGraphLayout();
+
+		if (grinder()->projectController().isProjectDirty())
+		{
+			auto answer = QMessageBox::question(this, "Unsaved project changes", "There are unsaved project changes. Do you want to save now?", QMessageBox::Yes|QMessageBox::No|QMessageBox::Cancel, QMessageBox::Yes);
+
+			if (answer == QMessageBox::Yes)
+				on_actSaveProject_triggered();
+			else if (answer == QMessageBox::Cancel)
+				return false;
+		}
+	}
+
+	return true;
+}
+
+void GrinderWindow::updateRecentProjects(QString fileName, bool addEntry)
+{
+	if (addEntry)
+		grinder()->settings().recentProjects() += fileName;
+	else
+		grinder()->settings().recentProjects() -= fileName;
+
+	ui->menu_Recent_Projects->updateUi();
+}
+
+void GrinderWindow::tabifyImageEditorDocks()
+{
+	// Gather all docks of the left, right, top and bottom areas
+	std::vector<QDockWidget*> leftArea, rightArea, topArea, bottomArea;
+	std::vector<QDockWidget*> activeDocks;
+
+	for (auto dock : findChildren<QDockWidget*>())
+	{
+		if (dynamic_cast<ImageEditorDockWidget*>(dock) && !dock->isFloating())
+		{
+			switch (dockWidgetArea(dock))
+			{
+			case Qt::LeftDockWidgetArea:
+				leftArea.push_back(dock);
+				break;
+
+			case Qt::RightDockWidgetArea:
+				rightArea.push_back(dock);
+				break;
+
+			case Qt::TopDockWidgetArea:
+				topArea.push_back(dock);
+				break;
+
+			case Qt::BottomDockWidgetArea:
+				bottomArea.push_back(dock);
+				break;
+
+			default:
+				break;
+			}
+
+			if (!dock->visibleRegion().isEmpty())
+				activeDocks.push_back(dock);
+		}
+	}
+
+	std::vector<std::vector<QDockWidget*>*> dockAreas{&leftArea, &rightArea, &topArea, &bottomArea};
+
+	// Sort all docks of each area and tabify them
+	for (auto& dockArea : dockAreas)
+	{
+		std::sort(dockArea->begin(), dockArea->end(), [](auto dock1, auto dock2) { return dock1->windowTitle() < dock2->windowTitle(); });
+
+		for (const auto& dockPair : make_adjacent_range(*dockArea))
+			tabifyDockWidget(dockPair.first, dockPair.second);
+	}
+
+	// Reactive all previously active tabs
+	for (auto& dock : activeDocks)
+		dock->raise();
+}
diff --git a/Grinder/ui/mainwnd/GrinderWindow.h b/Grinder/ui/mainwnd/GrinderWindow.h
new file mode 100644
index 0000000000000000000000000000000000000000..782c01c71c919cf10a418ba3a3e8615265c8acab
--- /dev/null
+++ b/Grinder/ui/mainwnd/GrinderWindow.h
@@ -0,0 +1,74 @@
+/******************************************************************************
+ * File: GrinderWindow.h
+ * Date: 11.1.2018
+ *****************************************************************************/
+
+#ifndef GRINDERWINDOW_H
+#define GRINDERWINDOW_H
+
+#include <QMainWindow>
+#include <memory>
+
+#include "ui/image/ImageEditorManager.h"
+#include "ui/graph/GraphWidget.h"
+
+namespace Ui
+{
+	class GrinderWindow;
+}
+
+namespace grndr
+{
+	class Pipeline;
+
+	class GrinderWindow : public QMainWindow
+	{
+		Q_OBJECT
+
+	public:
+		GrinderWindow(QWidget* parent = nullptr);
+		~GrinderWindow();
+
+	protected:
+		virtual void closeEvent(QCloseEvent* event) override;
+
+	private slots:
+		void on_actNewProject_triggered();
+		void on_actLoadProject_triggered();
+		void on_actSaveProject_triggered();
+		void on_actSaveProjectAs_triggered();
+
+		void on_actExecuteLabel_triggered();
+		void on_actExecuteNextImage_triggered();
+		void on_actExecutePreviousImage_triggered();
+
+		void on_actOptions_triggered();
+		void on_actAbout_triggered();
+
+	private slots:
+		void pipelineSwitched(Pipeline* pipeline);
+		void currentProjectFileChanged(QString fileName);
+
+		void imageEditorShown(ImageEditor* editor);
+		void imageEditorClosed(ImageEditor* editor);
+
+		void updateCaption();
+		void updateActions();
+		void updateRecentProjects(QString fileName) { updateRecentProjects(fileName, true); }		
+
+	private:
+		bool loadProject(QString fileName);
+		bool saveProject(QString fileName);
+		bool loadProjectOnStartup();
+		bool handleUnsavedProjectChanges();
+		void updateRecentProjects(QString fileName, bool addEntry);
+
+		void tabifyImageEditorDocks();
+
+	private:
+		void setupUi();
+		Ui::GrinderWindow* ui;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/mainwnd/GrinderWindow.ui b/Grinder/ui/mainwnd/GrinderWindow.ui
new file mode 100644
index 0000000000000000000000000000000000000000..151dbb8e43e3703c3762ef44ff488b2ffae03c98
--- /dev/null
+++ b/Grinder/ui/mainwnd/GrinderWindow.ui
@@ -0,0 +1,682 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>GrinderWindow</class>
+ <widget class="QMainWindow" name="GrinderWindow">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>1200</width>
+    <height>800</height>
+   </rect>
+  </property>
+  <property name="minimumSize">
+   <size>
+    <width>800</width>
+    <height>600</height>
+   </size>
+  </property>
+  <property name="windowTitle">
+   <string>Grinder</string>
+  </property>
+  <property name="dockOptions">
+   <set>QMainWindow::AllowNestedDocks|QMainWindow::AllowTabbedDocks|QMainWindow::AnimatedDocks|QMainWindow::GroupedDragging</set>
+  </property>
+  <widget class="QWidget" name="centralWidget">
+   <layout class="QGridLayout" name="gridLayout">
+    <property name="leftMargin">
+     <number>0</number>
+    </property>
+    <property name="topMargin">
+     <number>0</number>
+    </property>
+    <property name="rightMargin">
+     <number>0</number>
+    </property>
+    <property name="bottomMargin">
+     <number>0</number>
+    </property>
+    <property name="spacing">
+     <number>0</number>
+    </property>
+    <item row="0" column="0">
+     <widget class="GraphWidget" name="graphWidget">
+      <property name="frameShape">
+       <enum>QFrame::WinPanel</enum>
+      </property>
+      <property name="frameShadow">
+       <enum>QFrame::Sunken</enum>
+      </property>
+     </widget>
+    </item>
+   </layout>
+  </widget>
+  <widget class="QMenuBar" name="menuBar">
+   <property name="geometry">
+    <rect>
+     <x>0</x>
+     <y>0</y>
+     <width>1200</width>
+     <height>21</height>
+    </rect>
+   </property>
+   <widget class="QMenu" name="menu_File">
+    <property name="title">
+     <string>&amp;File</string>
+    </property>
+    <widget class="RecentProjectsMenu" name="menu_Recent_Projects">
+     <property name="title">
+      <string>&amp;Recent projects</string>
+     </property>
+     <addaction name="separator"/>
+    </widget>
+    <addaction name="actNewProject"/>
+    <addaction name="actLoadProject"/>
+    <addaction name="menu_Recent_Projects"/>
+    <addaction name="separator"/>
+    <addaction name="actSaveProject"/>
+    <addaction name="actSaveProjectAs"/>
+    <addaction name="separator"/>
+    <addaction name="actExit"/>
+   </widget>
+   <widget class="QMenu" name="menu_Help">
+    <property name="title">
+     <string>&amp;Help</string>
+    </property>
+    <addaction name="actAbout"/>
+   </widget>
+   <widget class="QMenu" name="menu_Graph">
+    <property name="title">
+     <string>&amp;Graph</string>
+    </property>
+   </widget>
+   <widget class="QMenu" name="menu_Labels">
+    <property name="title">
+     <string>&amp;Labels</string>
+    </property>
+   </widget>
+   <widget class="QMenu" name="menu_Images">
+    <property name="title">
+     <string>&amp;Images</string>
+    </property>
+   </widget>
+   <widget class="QMenu" name="menu_Run">
+    <property name="title">
+     <string>&amp;Run</string>
+    </property>
+    <addaction name="actExecuteLabel"/>
+    <addaction name="separator"/>
+    <addaction name="actExecuteNextImage"/>
+    <addaction name="actExecutePreviousImage"/>
+   </widget>
+   <widget class="QMenu" name="menu_Tools">
+    <property name="title">
+     <string>&amp;Tools</string>
+    </property>
+    <addaction name="actOptions"/>
+   </widget>
+   <addaction name="menu_File"/>
+   <addaction name="menu_Run"/>
+   <addaction name="menu_Labels"/>
+   <addaction name="menu_Images"/>
+   <addaction name="menu_Graph"/>
+   <addaction name="menu_Tools"/>
+   <addaction name="menu_Help"/>
+  </widget>
+  <widget class="QToolBar" name="mainToolBar">
+   <property name="contextMenuPolicy">
+    <enum>Qt::PreventContextMenu</enum>
+   </property>
+   <property name="windowTitle">
+    <string>Project</string>
+   </property>
+   <property name="allowedAreas">
+    <set>Qt::BottomToolBarArea|Qt::TopToolBarArea</set>
+   </property>
+   <property name="iconSize">
+    <size>
+     <width>24</width>
+     <height>24</height>
+    </size>
+   </property>
+   <property name="floatable">
+    <bool>false</bool>
+   </property>
+   <attribute name="toolBarArea">
+    <enum>TopToolBarArea</enum>
+   </attribute>
+   <attribute name="toolBarBreak">
+    <bool>false</bool>
+   </attribute>
+   <addaction name="actNewProject"/>
+   <addaction name="actLoadProject"/>
+   <addaction name="separator"/>
+   <addaction name="actSaveProject"/>
+   <addaction name="separator"/>
+   <addaction name="actExecuteLabel"/>
+   <addaction name="separator"/>
+   <addaction name="actExecutePreviousImage"/>
+   <addaction name="actExecuteNextImage"/>
+   <addaction name="separator"/>
+   <addaction name="actExit"/>
+  </widget>
+  <widget class="QStatusBar" name="statusBar"/>
+  <widget class="QToolBar" name="graphToolBar">
+   <property name="contextMenuPolicy">
+    <enum>Qt::PreventContextMenu</enum>
+   </property>
+   <property name="windowTitle">
+    <string>Graph view</string>
+   </property>
+   <property name="allowedAreas">
+    <set>Qt::BottomToolBarArea|Qt::TopToolBarArea</set>
+   </property>
+   <property name="floatable">
+    <bool>false</bool>
+   </property>
+   <attribute name="toolBarArea">
+    <enum>TopToolBarArea</enum>
+   </attribute>
+   <attribute name="toolBarBreak">
+    <bool>false</bool>
+   </attribute>
+  </widget>
+  <widget class="GrinderDockWidget" name="labelsDock">
+   <property name="font">
+    <font>
+     <weight>75</weight>
+     <bold>true</bold>
+    </font>
+   </property>
+   <property name="features">
+    <set>QDockWidget::DockWidgetFloatable|QDockWidget::DockWidgetMovable</set>
+   </property>
+   <property name="allowedAreas">
+    <set>Qt::LeftDockWidgetArea|Qt::RightDockWidgetArea</set>
+   </property>
+   <property name="windowTitle">
+    <string>Labels</string>
+   </property>
+   <attribute name="dockWidgetArea">
+    <number>1</number>
+   </attribute>
+   <widget class="QWidget" name="dockWidgetContents_2">
+    <layout class="QVBoxLayout" name="verticalLayout">
+     <property name="spacing">
+      <number>2</number>
+     </property>
+     <property name="leftMargin">
+      <number>6</number>
+     </property>
+     <property name="topMargin">
+      <number>6</number>
+     </property>
+     <property name="rightMargin">
+      <number>6</number>
+     </property>
+     <property name="bottomMargin">
+      <number>6</number>
+     </property>
+     <item>
+      <widget class="LabelsListWidget" name="labelsList">
+       <property name="editTriggers">
+        <set>QAbstractItemView::EditKeyPressed</set>
+       </property>
+       <property name="sortingEnabled">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="ControlBar" name="labelsControlBar">
+       <property name="minimumSize">
+        <size>
+         <width>0</width>
+         <height>20</height>
+        </size>
+       </property>
+       <property name="frameShape">
+        <enum>QFrame::StyledPanel</enum>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </widget>
+  </widget>
+  <widget class="GrinderDockWidget" name="imagesDock">
+   <property name="font">
+    <font>
+     <weight>75</weight>
+     <bold>true</bold>
+    </font>
+   </property>
+   <property name="features">
+    <set>QDockWidget::DockWidgetFloatable|QDockWidget::DockWidgetMovable</set>
+   </property>
+   <property name="windowTitle">
+    <string>Images</string>
+   </property>
+   <attribute name="dockWidgetArea">
+    <number>1</number>
+   </attribute>
+   <widget class="QWidget" name="dockWidgetContents">
+    <layout class="QVBoxLayout" name="verticalLayout_2">
+     <property name="spacing">
+      <number>2</number>
+     </property>
+     <property name="leftMargin">
+      <number>6</number>
+     </property>
+     <property name="topMargin">
+      <number>6</number>
+     </property>
+     <property name="rightMargin">
+      <number>6</number>
+     </property>
+     <property name="bottomMargin">
+      <number>6</number>
+     </property>
+     <item>
+      <widget class="ImageReferencesListWidget" name="imageReferencesList">
+       <property name="acceptDrops">
+        <bool>true</bool>
+       </property>
+       <property name="editTriggers">
+        <set>QAbstractItemView::NoEditTriggers</set>
+       </property>
+       <property name="sortingEnabled">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="ControlBar" name="imageReferencesControlBar">
+       <property name="minimumSize">
+        <size>
+         <width>0</width>
+         <height>20</height>
+        </size>
+       </property>
+       <property name="frameShape">
+        <enum>QFrame::StyledPanel</enum>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </widget>
+  </widget>
+  <widget class="GrinderDockWidget" name="propertiesDock">
+   <property name="font">
+    <font>
+     <weight>75</weight>
+     <bold>true</bold>
+    </font>
+   </property>
+   <property name="features">
+    <set>QDockWidget::DockWidgetFloatable|QDockWidget::DockWidgetMovable</set>
+   </property>
+   <property name="windowTitle">
+    <string>Properties</string>
+   </property>
+   <attribute name="dockWidgetArea">
+    <number>2</number>
+   </attribute>
+   <widget class="QWidget" name="dockWidgetContents_3">
+    <layout class="QVBoxLayout" name="verticalLayout_3">
+     <property name="spacing">
+      <number>2</number>
+     </property>
+     <property name="leftMargin">
+      <number>6</number>
+     </property>
+     <property name="topMargin">
+      <number>6</number>
+     </property>
+     <property name="rightMargin">
+      <number>6</number>
+     </property>
+     <property name="bottomMargin">
+      <number>6</number>
+     </property>
+     <item>
+      <widget class="PropertyTreeWidget" name="propertiesTree">
+       <property name="editTriggers">
+        <set>QAbstractItemView::NoEditTriggers</set>
+       </property>
+       <property name="uniformRowHeights">
+        <bool>true</bool>
+       </property>
+       <property name="sortingEnabled">
+        <bool>true</bool>
+       </property>
+       <property name="columnCount">
+        <number>2</number>
+       </property>
+       <attribute name="headerVisible">
+        <bool>false</bool>
+       </attribute>
+       <attribute name="headerHighlightSections">
+        <bool>true</bool>
+       </attribute>
+       <attribute name="headerMinimumSectionSize">
+        <number>35</number>
+       </attribute>
+       <column>
+        <property name="text">
+         <string>Property</string>
+        </property>
+        <property name="font">
+         <font>
+          <weight>75</weight>
+          <bold>true</bold>
+         </font>
+        </property>
+       </column>
+       <column>
+        <property name="text">
+         <string>Value</string>
+        </property>
+        <property name="font">
+         <font>
+          <weight>75</weight>
+          <bold>true</bold>
+         </font>
+        </property>
+       </column>
+      </widget>
+     </item>
+     <item>
+      <widget class="QFrame" name="frmPropertyDesc">
+       <property name="frameShape">
+        <enum>QFrame::StyledPanel</enum>
+       </property>
+       <property name="frameShadow">
+        <enum>QFrame::Plain</enum>
+       </property>
+       <layout class="QVBoxLayout" name="verticalLayout_4">
+        <property name="spacing">
+         <number>5</number>
+        </property>
+        <property name="leftMargin">
+         <number>6</number>
+        </property>
+        <property name="topMargin">
+         <number>6</number>
+        </property>
+        <property name="rightMargin">
+         <number>6</number>
+        </property>
+        <property name="bottomMargin">
+         <number>6</number>
+        </property>
+        <item>
+         <widget class="QLabel" name="lblPropertyDesc">
+          <property name="sizePolicy">
+           <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+            <horstretch>0</horstretch>
+            <verstretch>0</verstretch>
+           </sizepolicy>
+          </property>
+          <property name="minimumSize">
+           <size>
+            <width>0</width>
+            <height>80</height>
+           </size>
+          </property>
+          <property name="font">
+           <font>
+            <weight>50</weight>
+            <bold>false</bold>
+           </font>
+          </property>
+          <property name="text">
+           <string>PropertyDescription</string>
+          </property>
+          <property name="alignment">
+           <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+          </property>
+          <property name="wordWrap">
+           <bool>true</bool>
+          </property>
+         </widget>
+        </item>
+       </layout>
+      </widget>
+     </item>
+    </layout>
+   </widget>
+  </widget>
+  <action name="actExit">
+   <property name="icon">
+    <iconset resource="../../res/Grinder.qrc">
+     <normaloff>:/icons/icons/door-exit.png</normaloff>:/icons/icons/door-exit.png</iconset>
+   </property>
+   <property name="text">
+    <string>E&amp;xit</string>
+   </property>
+   <property name="toolTip">
+    <string>Exit the application</string>
+   </property>
+   <property name="statusTip">
+    <string>Exit the application</string>
+   </property>
+   <property name="shortcut">
+    <string>Ctrl+Q</string>
+   </property>
+  </action>
+  <action name="actAbout">
+   <property name="icon">
+    <iconset resource="../../res/Grinder.qrc">
+     <normaloff>:/icons/icons/question-mark-in-a-circle.png</normaloff>:/icons/icons/question-mark-in-a-circle.png</iconset>
+   </property>
+   <property name="text">
+    <string>&amp;About...</string>
+   </property>
+   <property name="toolTip">
+    <string>About this application</string>
+   </property>
+   <property name="statusTip">
+    <string>About this application</string>
+   </property>
+  </action>
+  <action name="actExecuteLabel">
+   <property name="icon">
+    <iconset resource="../../res/Grinder.qrc">
+     <normaloff>:/icons/icons/start.png</normaloff>:/icons/icons/start.png</iconset>
+   </property>
+   <property name="text">
+    <string>E&amp;xecute current label</string>
+   </property>
+   <property name="toolTip">
+    <string>Execute the currently active label</string>
+   </property>
+   <property name="statusTip">
+    <string>Execute the currently active label</string>
+   </property>
+   <property name="shortcut">
+    <string>F5</string>
+   </property>
+  </action>
+  <action name="actSaveProject">
+   <property name="icon">
+    <iconset resource="../../res/Grinder.qrc">
+     <normaloff>:/icons/icons/floppy-disk-digital-data-storage-or-save-interface-symbol.png</normaloff>:/icons/icons/floppy-disk-digital-data-storage-or-save-interface-symbol.png</iconset>
+   </property>
+   <property name="text">
+    <string>&amp;Save project</string>
+   </property>
+   <property name="toolTip">
+    <string>Save the current project</string>
+   </property>
+   <property name="statusTip">
+    <string>Save the current project</string>
+   </property>
+   <property name="shortcut">
+    <string>Ctrl+S</string>
+   </property>
+  </action>
+  <action name="actSaveProjectAs">
+   <property name="text">
+    <string>Save project &amp;as...</string>
+   </property>
+   <property name="toolTip">
+    <string>Save the project to a file</string>
+   </property>
+   <property name="statusTip">
+    <string>Save the project to a file</string>
+   </property>
+   <property name="shortcut">
+    <string>F12</string>
+   </property>
+  </action>
+  <action name="actLoadProject">
+   <property name="icon">
+    <iconset resource="../../res/Grinder.qrc">
+     <normaloff>:/icons/icons/folder-with-information.png</normaloff>:/icons/icons/folder-with-information.png</iconset>
+   </property>
+   <property name="text">
+    <string>&amp;Load project...</string>
+   </property>
+   <property name="toolTip">
+    <string>Load a saved project</string>
+   </property>
+   <property name="statusTip">
+    <string>Load a saved project</string>
+   </property>
+   <property name="shortcut">
+    <string>Ctrl+O</string>
+   </property>
+  </action>
+  <action name="actNewProject">
+   <property name="icon">
+    <iconset resource="../../res/Grinder.qrc">
+     <normaloff>:/icons/icons/document-empty.png</normaloff>:/icons/icons/document-empty.png</iconset>
+   </property>
+   <property name="text">
+    <string>&amp;New project</string>
+   </property>
+   <property name="toolTip">
+    <string>Create an empty project</string>
+   </property>
+   <property name="statusTip">
+    <string>Create an empty project</string>
+   </property>
+   <property name="shortcut">
+    <string>Ctrl+N</string>
+   </property>
+  </action>
+  <action name="actOptions">
+   <property name="icon">
+    <iconset resource="../../res/Grinder.qrc">
+     <normaloff>:/icons/icons/open-window-with-gear-sign.png</normaloff>:/icons/icons/open-window-with-gear-sign.png</iconset>
+   </property>
+   <property name="text">
+    <string>Options...</string>
+   </property>
+   <property name="toolTip">
+    <string>Open the application options</string>
+   </property>
+   <property name="statusTip">
+    <string>Open the application options</string>
+   </property>
+  </action>
+  <action name="actExecuteNextImage">
+   <property name="icon">
+    <iconset resource="../../res/Grinder.qrc">
+     <normaloff>:/icons/icons/right-thin-arrowheads.png</normaloff>:/icons/icons/right-thin-arrowheads.png</iconset>
+   </property>
+   <property name="text">
+    <string>Execute &amp;next image</string>
+   </property>
+   <property name="toolTip">
+    <string>Go to the next image and execute the currently active label</string>
+   </property>
+   <property name="statusTip">
+    <string>Execute the currently active label</string>
+   </property>
+   <property name="shortcut">
+    <string>Space</string>
+   </property>
+  </action>
+  <action name="actExecutePreviousImage">
+   <property name="icon">
+    <iconset resource="../../res/Grinder.qrc">
+     <normaloff>:/icons/icons/arrowheads-of-thin-outline-to-the-left.png</normaloff>:/icons/icons/arrowheads-of-thin-outline-to-the-left.png</iconset>
+   </property>
+   <property name="text">
+    <string>Execute &amp;previous image</string>
+   </property>
+   <property name="toolTip">
+    <string>Go to the previous image and execute the currently active label</string>
+   </property>
+   <property name="statusTip">
+    <string>Execute the currently active label</string>
+   </property>
+   <property name="shortcut">
+    <string>Ctrl+Space</string>
+   </property>
+  </action>
+ </widget>
+ <layoutdefault spacing="6" margin="11"/>
+ <customwidgets>
+  <customwidget>
+   <class>ControlBar</class>
+   <extends>QFrame</extends>
+   <header>ui/widget/ControlBar.h</header>
+   <container>1</container>
+  </customwidget>
+  <customwidget>
+   <class>GraphWidget</class>
+   <extends>QFrame</extends>
+   <header>ui/graph/GraphWidget.h</header>
+   <container>1</container>
+  </customwidget>
+  <customwidget>
+   <class>LabelsListWidget</class>
+   <extends>QListWidget</extends>
+   <header>ui/mainwnd/LabelsListWidget.h</header>
+  </customwidget>
+  <customwidget>
+   <class>ImageReferencesListWidget</class>
+   <extends>QListWidget</extends>
+   <header>ui/mainwnd/ImageReferencesListWidget.h</header>
+  </customwidget>
+  <customwidget>
+   <class>RecentProjectsMenu</class>
+   <extends>QMenu</extends>
+   <header>ui/mainwnd/RecentProjectsMenu.h</header>
+  </customwidget>
+  <customwidget>
+   <class>PropertyTreeWidget</class>
+   <extends>QTreeWidget</extends>
+   <header>ui/property/PropertyTreeWidget.h</header>
+  </customwidget>
+  <customwidget>
+   <class>GrinderDockWidget</class>
+   <extends>QDockWidget</extends>
+   <header>ui/widget/GrinderDockWidget.h</header>
+   <container>1</container>
+  </customwidget>
+ </customwidgets>
+ <resources>
+  <include location="../../res/Grinder.qrc"/>
+ </resources>
+ <connections>
+  <connection>
+   <sender>actExit</sender>
+   <signal>triggered()</signal>
+   <receiver>GrinderWindow</receiver>
+   <slot>close()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>-1</x>
+     <y>-1</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>199</x>
+     <y>149</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>
diff --git a/Grinder/ui/mainwnd/ImageReferencesListItem.cpp b/Grinder/ui/mainwnd/ImageReferencesListItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0c1fa6e12011b5d4a99291d002e836b4fb32df4f
--- /dev/null
+++ b/Grinder/ui/mainwnd/ImageReferencesListItem.cpp
@@ -0,0 +1,55 @@
+/******************************************************************************
+ * File: ImageReferencesListItem.cpp
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageReferencesListItem.h"
+#include "util/StringUtils.h"
+#include "res/Resources.h"
+
+#include <opencv2/highgui.hpp>
+
+ImageReferencesListItem::ImageReferencesListItem(ImageReference* imageRef) : ObjectListItem(imageRef)
+{
+	setFlags(Qt::ItemIsEnabled|Qt::ItemIsSelectable);
+	setIcon(QIcon{FILE_ICON_IMAGEREFERENCE});
+
+	updateItem();
+}
+
+bool ImageReferencesListItem::operator <(const QListWidgetItem& other) const
+{
+	auto imageRef = dynamic_cast<const ImageReferencesListItem&>(other);
+	return *_object < *imageRef._object;
+}
+
+void ImageReferencesListItem::viewImage() const
+{
+	// Show the image using OpenCV
+	if (_object->isValid())
+	{
+		auto image = cv::imread(_object->getImageFilePath().toStdString());
+
+		if (!image.empty())
+			cv::imshow(_object->getImageFileName().toStdString(), image);
+	}
+}
+
+void ImageReferencesListItem::updateItem()
+{
+	// Create the tooltip string
+	auto imageInfo = _object->getImageInfo();
+	QFileInfo fileInfo{_object->getImageFilePath()};
+	QString toolTip = QString{"<b>%1</b><br><em>Location: </em>%6<br><em>File size: </em>%2<br><em>File date: </em>%3<br><em>Dimensions: </em>%4 x %5 pixels"}
+			.arg(_object->getImageFileName())
+			.arg(StringUtils::fileSizeToString(imageInfo.fileSize))
+			.arg(imageInfo.fileDate.toString(Qt::SystemLocaleShortDate))
+			.arg(imageInfo.imageSize.width()).arg(imageInfo.imageSize.height())
+			.arg(fileInfo.path());
+
+	setText(getName());
+	setToolTip(toolTip);
+
+	base_type::updateItem();
+}
diff --git a/Grinder/ui/mainwnd/ImageReferencesListItem.h b/Grinder/ui/mainwnd/ImageReferencesListItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..18e9fb08f26269320e4440ba6fa59814792b30e1
--- /dev/null
+++ b/Grinder/ui/mainwnd/ImageReferencesListItem.h
@@ -0,0 +1,33 @@
+/******************************************************************************
+ * File: ImageReferencesListItem.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef IMAGEREFERENCESLISTITEM_H
+#define IMAGEREFERENCESLISTITEM_H
+
+#include "ui/widget/ObjectListItem.h"
+#include "project/ImageReference.h"
+
+namespace grndr
+{
+	class ImageReference;
+
+	class ImageReferencesListItem : public ObjectListItem<ImageReference>
+	{
+	public:
+		ImageReferencesListItem(ImageReference* imageRef);
+
+		virtual bool  operator <(const QListWidgetItem &other) const override;
+
+	public:
+		void viewImage() const;
+
+		virtual void updateItem() override;
+
+	public:
+		QString getName() const { return _object->getImageFileName(); }
+	};
+}
+
+#endif
diff --git a/Grinder/ui/mainwnd/ImageReferencesListWidget.cpp b/Grinder/ui/mainwnd/ImageReferencesListWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8359d9af6bd4539bf2c3fe4ea8947620e1471d62
--- /dev/null
+++ b/Grinder/ui/mainwnd/ImageReferencesListWidget.cpp
@@ -0,0 +1,216 @@
+/******************************************************************************
+ * File: ImageReferencesListWidget.cpp
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageReferencesListWidget.h"
+#include "core/GrinderApplication.h"
+#include "project/Project.h"
+#include "ui/widget/ControlBar.h"
+#include "util/UIUtils.h"
+#include "util/ImageUtils.h"
+#include "util/FileUtils.h"
+#include "res/Resources.h"
+
+ImageReferencesListWidget::ImageReferencesListWidget(QWidget* parent) : MetaWidget(parent)
+{
+	setAcceptDrops(true);
+
+	// Create labels actions
+	_switchImageReferenceAction = UIUtils::createAction(this, "&Activate", FILE_ICON_ACTIVATE, SLOT(switchImageReference()), "Activate the selected image", "Return");
+	_nextImageReferenceAction = UIUtils::createAction(this, "Switch to &next image", "", SLOT(nextImageReference()), "Activate the next image", "Ctrl+PgDown", Qt::WindowShortcut);
+	_previousImageReferenceAction = UIUtils::createAction(this, "Switch to &previous image", "", SLOT(previousImageReference()), "Activate the previous image", "Ctrl+PgUp", Qt::WindowShortcut);
+	_viewImageReferenceAction = UIUtils::createAction(this, "&View image", FILE_ICON_VIEWIMAGE, SLOT(viewImageReference()), "View the selected image");
+	_addImageReferencesAction = UIUtils::createAction(this, "Add &images", FILE_ICON_ADD, SLOT(addImageReferences()), "Add one or more images to the project", "Ctrl+I", Qt::WindowShortcut);
+	_removeImageReferenceAction = UIUtils::createAction(this, "&Remove image", FILE_ICON_DELETE, SLOT(removeImageReference()), "Remove the selected image", "Del");
+	_removeAllImageReferencesAction = UIUtils::createAction(this, "Remove all images", "", SLOT(removeAllImageReferences()), "Remove all images");
+
+	// Listen for item selections in order to update the actions
+	connect(this, &ImageReferencesListWidget::itemSelectionChanged, this, &ImageReferencesListWidget::updateActions);
+
+	// Listen for label switching in order to update the active state flags
+	connect(&grinder()->projectController(), &ProjectController::imageReferenceSwitched, this, &ImageReferencesListWidget::imageReferenceSwitched);
+
+	updateActions();
+}
+
+std::unique_ptr<QMenu> ImageReferencesListWidget::createContextMenu() const
+{
+	auto menu = widget_type::createContextMenu();
+	menu->setDefaultAction(_switchImageReferenceAction);
+	return menu;
+}
+
+std::vector<QAction*> ImageReferencesListWidget::getActions(AddActionsMode mode) const
+{
+	std::vector<QAction*> actions;
+
+	if (mode == AddActionsMode::ContextMenu || mode == AddActionsMode::Toolbar)
+	{
+		actions.push_back(_switchImageReferenceAction);
+
+		if (mode == AddActionsMode::ContextMenu)
+			actions.push_back(_viewImageReferenceAction);
+
+		actions.push_back(nullptr);
+	}
+
+	if (mode == AddActionsMode::MainMenu || mode == AddActionsMode::ContextMenu)
+	{
+		actions.push_back(_nextImageReferenceAction);
+		actions.push_back(_previousImageReferenceAction);
+		actions.push_back(nullptr);
+	}
+
+	actions.push_back(_addImageReferencesAction);
+	actions.push_back(_removeImageReferenceAction);
+
+	if (mode == AddActionsMode::ContextMenu)
+	{
+		actions.push_back(nullptr);
+		actions.push_back(_removeAllImageReferencesAction);
+	}
+
+	return actions;
+}
+
+void ImageReferencesListWidget::switchToObjectItem(ImageReferencesListItem* item, bool selectItem)
+{
+	if (item)
+	{
+		grinder()->projectController().switchImageReference(item->object());
+
+		if (selectItem)
+			setCurrentItem(item);
+	}
+}
+
+void ImageReferencesListWidget::dragEnterEvent(QDragEnterEvent* event)
+{
+	if (event->mimeData()->hasUrls())
+	{
+		event->setDropAction(Qt::CopyAction);
+		event->accept();
+	}
+	else
+		event->ignore();
+}
+
+void ImageReferencesListWidget::dragMoveEvent(QDragMoveEvent* event)
+{
+	if (event->mimeData()->hasUrls())
+	{
+		event->setDropAction(Qt::CopyAction);
+		event->accept();
+	}
+	else
+		event->ignore();
+}
+
+void ImageReferencesListWidget::dropEvent(QDropEvent* event)
+{
+	if (event->mimeData()->hasUrls())
+	{
+		auto urls = event->mimeData()->urls();
+		QStringList files;
+
+		for (auto url : urls)
+			files << url.toLocalFile();
+
+		addImageReferences(files);
+
+		event->setDropAction(Qt::CopyAction);
+		event->accept();
+	}
+	else
+		event->ignore();
+}
+
+void ImageReferencesListWidget::viewImageReference()
+{
+	if (auto item = currentObjectItem())
+		item->viewImage();
+}
+
+void ImageReferencesListWidget::addImageReferences()
+{
+	auto files = UIUtils::askFileNames("ImageReferences", this, "Add images", QString{"Images (%1);;All files (*.*)"}.arg(ImageUtils::getSupportedFormatFilters().join(" ")));
+	addImageReferences(files);
+}
+
+void ImageReferencesListWidget::addImageReferences(QStringList files)
+{
+	// Expand the file list, recursively finding all image files, and create image references for them	
+	files = FileUtils::expandFileList(files, ImageUtils::getSupportedFormatFilters());
+	auto imageRefs = grinder()->projectController().createImageReferences(files);
+
+	// If no active image exists, switch to the last added one
+	if (!grinder()->projectController().activeImageReference())
+	{
+		if (!imageRefs.empty())
+		{
+			auto item = findObjectItem(imageRefs[imageRefs.size() - 1].get());
+
+			if (item)
+				switchToObjectItem(item);
+		}
+	}
+}
+
+void ImageReferencesListWidget::removeImageReference()
+{
+	if (auto imageRef = currentObject())
+	{
+		grinder()->projectController().removeImageReference(imageRef);
+
+		// Switch to the image reference that is currently selected if no active one exists
+		if (!grinder()->projectController().activeImageReference())
+			switchToObjectItem(currentObjectItem());
+	}
+}
+
+void ImageReferencesListWidget::removeAllImageReferences()
+{
+	grinder()->projectController().removeAllImageReferences();
+	updateActions();
+}
+
+void ImageReferencesListWidget::imageReferenceSwitched(ImageReference* imageRef)
+{
+	objectSwitched(imageRef);
+	updateActions();
+}
+
+void ImageReferencesListWidget::updateActions()
+{
+	bool imageSelected = (currentObjectItem() != nullptr);
+
+	_switchImageReferenceAction->setEnabled(imageSelected && !currentObjectItem()->isActive());
+	_nextImageReferenceAction->setEnabled(count() > 1);
+	_previousImageReferenceAction->setEnabled(count() > 1);
+	_viewImageReferenceAction->setEnabled(imageSelected);
+	_removeImageReferenceAction->setEnabled(imageSelected);
+	_removeAllImageReferencesAction->setEnabled(count() > 0);
+}
+
+void ImageReferencesListWidget::advanceCurrentImageReference(bool goUp)
+{
+	int index = findObjectItemIndex(activeObjectItem());
+
+	if (index != -1)
+	{
+		if (goUp)
+		{
+			if (--index < 0)
+				index = count() - 1;
+		}
+		else
+		{
+			if (++index >= count())
+				index = 0;
+		}
+
+		switchToObjectItem(objectItem(index), false);
+	}
+}
diff --git a/Grinder/ui/mainwnd/ImageReferencesListWidget.h b/Grinder/ui/mainwnd/ImageReferencesListWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..00108585c447fc7784c95de3dbd33b1eeace77fb
--- /dev/null
+++ b/Grinder/ui/mainwnd/ImageReferencesListWidget.h
@@ -0,0 +1,70 @@
+/******************************************************************************
+ * File: ImageReferencesListWidget.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef IMAGEREFERENCESLISTWIDGET_H
+#define IMAGEREFERENCESLISTWIDGET_H
+
+#include "ui/widget/MetaWidget.h"
+#include "ui/widget/ObjectListWidget.h"
+#include "ImageReferencesListItem.h"
+
+namespace grndr
+{
+	class ControlBar;
+	class ImageReference;
+
+	using ImageReferenceObjectListWidget = ObjectListWidget<ImageReference, ImageReferencesListItem>;
+
+	class ImageReferencesListWidget : public MetaWidget<ImageReferenceObjectListWidget>
+	{
+		Q_OBJECT
+
+	public:
+		ImageReferencesListWidget(QWidget* parent = nullptr);
+
+	public slots:
+		void nextImageReference() { advanceCurrentImageReference(); }
+		void previousImageReference() { advanceCurrentImageReference(true); }
+
+	protected:
+		virtual std::unique_ptr<QMenu> createContextMenu() const override;
+
+		virtual std::vector<QAction*> getActions(AddActionsMode mode) const override;
+
+	protected:
+		virtual void switchToObjectItem(ImageReferencesListItem* item, bool selectItem = true) override;
+
+	protected:
+		virtual void dragEnterEvent(QDragEnterEvent* event) override;
+		virtual void dragMoveEvent(QDragMoveEvent* event) override;
+		virtual void dropEvent(QDropEvent* event) override;
+
+	private slots:
+		void switchImageReference() { switchToObjectItem(currentObjectItem()); }
+		void viewImageReference();
+		void addImageReferences();
+		void addImageReferences(QStringList files);
+		void removeImageReference();
+		void removeAllImageReferences();
+
+		void imageReferenceSwitched(ImageReference* imageRef);
+
+		void updateActions();
+
+	private:
+		void advanceCurrentImageReference(bool goUp = false);
+
+	private:
+		QAction* _switchImageReferenceAction{nullptr};
+		QAction* _nextImageReferenceAction{nullptr};
+		QAction* _previousImageReferenceAction{nullptr};
+		QAction* _viewImageReferenceAction{nullptr};
+		QAction* _addImageReferencesAction{nullptr};
+		QAction* _removeImageReferenceAction{nullptr};
+		QAction* _removeAllImageReferencesAction{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/mainwnd/LabelsListItem.cpp b/Grinder/ui/mainwnd/LabelsListItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..065694bdec8c5a22f254fa9c276dc8fb4b8f31a3
--- /dev/null
+++ b/Grinder/ui/mainwnd/LabelsListItem.cpp
@@ -0,0 +1,22 @@
+/******************************************************************************
+ * File: LabelsListItem.cpp
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LabelsListItem.h"
+#include "res/Resources.h"
+
+LabelsListItem::LabelsListItem(Label* label) : ObjectListItem(label)
+{
+	setFlags(Qt::ItemIsEnabled|Qt::ItemIsSelectable|Qt::ItemIsEditable);
+	setIcon(QIcon{FILE_ICON_LABEL});
+
+	updateItem();
+}
+
+void LabelsListItem::updateItem()
+{
+	setText(getName());
+	base_type::updateItem();
+}
diff --git a/Grinder/ui/mainwnd/LabelsListItem.h b/Grinder/ui/mainwnd/LabelsListItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..ac73efebf82ffb933d1912befb36654c7a780c33
--- /dev/null
+++ b/Grinder/ui/mainwnd/LabelsListItem.h
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: LabelsListItem.h
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#ifndef LABELSLISTITEM_H
+#define LABELSLISTITEM_H
+
+#include "ui/widget/ObjectListItem.h"
+#include "project/Label.h"
+
+namespace grndr
+{
+	class LabelsListItem : public ObjectListItem<Label>
+	{
+	public:
+		LabelsListItem(Label* label);
+
+	public:
+		virtual void updateItem() override;
+
+	public:
+		QString getName() const { return _object->getName(); }
+	};
+}
+
+#endif
diff --git a/Grinder/ui/mainwnd/LabelsListWidget.cpp b/Grinder/ui/mainwnd/LabelsListWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..90d020eda625a9111847e06fa721141ef304ff11
--- /dev/null
+++ b/Grinder/ui/mainwnd/LabelsListWidget.cpp
@@ -0,0 +1,146 @@
+/******************************************************************************
+ * File: LabelsListWidget.cpp
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "LabelsListWidget.h"
+#include "core/GrinderApplication.h"
+#include "project/Project.h"
+#include "util/StringUtils.h"
+#include "util/UIUtils.h"
+#include "res/Resources.h"
+
+LabelsListWidget::LabelsListWidget(QWidget* parent) : MetaWidget(parent)
+{
+	// Create labels actions
+	_switchLabelAction = UIUtils::createAction(this, "&Activate", FILE_ICON_ACTIVATE, SLOT(switchLabel()), "Activate the selected label", "Return");
+	_renameLabelAction = UIUtils::createAction(this, "Rename label", FILE_ICON_EDIT, SLOT(renameLabel()), "Rename the selected label", "F2");
+	_newLabelAction = UIUtils::createAction(this, "&New label", FILE_ICON_ADD, SLOT(newLabel()), "Add a new label", "Ctrl+L", Qt::WindowShortcut);
+	_removeLabelAction = UIUtils::createAction(this, "&Remove label", FILE_ICON_DELETE, SLOT(removeLabel()), "Remove the selected label", "Del");
+	_removeAllLabelsAction = UIUtils::createAction(this, "Remove all labels", "", SLOT(removeAllLabels()), "Remove all labels");
+
+	// Get notified when a label name has been edited
+	connect(itemDelegate(), &QAbstractItemDelegate::commitData, this, &LabelsListWidget::labelRenamed, Qt::QueuedConnection);	// Must be queued to prevent issues if renaming fails
+
+	// Listen for item selections in order to update the actions
+	connect(this, &LabelsListWidget::itemSelectionChanged, this, &LabelsListWidget::updateActions);
+
+	// Listen for label switching in order to update the active state flags
+	connect(&grinder()->projectController(), &ProjectController::labelSwitched, this, &LabelsListWidget::labelSwitched);
+
+	updateActions();
+}
+
+std::unique_ptr<QMenu> LabelsListWidget::createContextMenu() const
+{
+	auto menu = widget_type::createContextMenu();
+	menu->setDefaultAction(_switchLabelAction);
+	return menu;
+}
+
+std::vector<QAction*> LabelsListWidget::getActions(AddActionsMode mode) const
+{
+	std::vector<QAction*> actions;
+
+	if (mode == AddActionsMode::ContextMenu || mode == AddActionsMode::Toolbar)
+	{
+		actions.push_back(_switchLabelAction);
+
+		if (mode == AddActionsMode::ContextMenu)
+			actions.push_back(_renameLabelAction);
+
+		actions.push_back(nullptr);
+	}
+
+	actions.push_back(_newLabelAction);
+	actions.push_back(_removeLabelAction);
+
+	if (mode == AddActionsMode::ContextMenu)
+	{
+		actions.push_back(nullptr);
+		actions.push_back(_removeAllLabelsAction);
+	}
+
+	return actions;
+}
+
+void LabelsListWidget::switchToObjectItem(LabelsListItem* item, bool selectItem)
+{
+	if (item)
+	{
+		grinder()->projectController().switchLabel(item->object());
+
+		if (selectItem)
+			setCurrentItem(item);
+	}
+}
+
+void LabelsListWidget::newLabel()
+{
+	QString newLabelName = StringUtils::generateUniqueItemName(grinder()->project().labels(), "New label", &Label::getName);
+	bool ok = false;
+	auto name = QInputDialog::getText(this, "Label name", "Enter the name of the new label:", QLineEdit::Normal, newLabelName, &ok);
+
+	if (ok)
+	{
+		auto label = grinder()->projectController().createLabel(name.trimmed());
+
+		if (label)
+		{
+			// Find the just-created label item and switch to it
+			auto item = findObjectItem(label.get());
+
+			if (item)
+				switchToObjectItem(item);
+		}
+	}
+}
+
+void LabelsListWidget::removeLabel()
+{
+	if (auto label = currentObject())
+	{
+		grinder()->projectController().removeLabel(label);
+
+		// Switch to the label that is currently selected if no active one exists
+		if (!grinder()->projectController().activeLabel())
+			switchToObjectItem(currentObjectItem());
+	}
+}
+
+void LabelsListWidget::removeAllLabels()
+{
+	grinder()->projectController().removeAllLabels();
+	updateActions();
+}
+
+void LabelsListWidget::labelSwitched(Label* label)
+{
+	objectSwitched(label);
+	updateActions();
+}
+
+void LabelsListWidget::labelRenamed(QWidget* editor) const
+{
+	if (auto labelItem = currentObjectItem())
+	{
+		viewport()->setUpdatesEnabled(false);
+
+		auto newName = reinterpret_cast<QLineEdit*>(editor)->text();
+		grinder()->projectController().renameLabel(labelItem->object(), newName);
+
+		labelItem->updateItem();
+		viewport()->setUpdatesEnabled(true);
+	}
+}
+
+void LabelsListWidget::updateActions()
+{
+	bool labelSelected = (currentObjectItem() != nullptr);
+
+	_switchLabelAction->setEnabled(labelSelected && !currentObjectItem()->isActive());
+	_renameLabelAction->setEnabled(labelSelected);
+	_removeLabelAction->setEnabled(labelSelected);
+	_removeAllLabelsAction->setEnabled(count() > 0);
+}
diff --git a/Grinder/ui/mainwnd/LabelsListWidget.h b/Grinder/ui/mainwnd/LabelsListWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..abb61f15d113d36b793150e7d8eac3b71c89e050
--- /dev/null
+++ b/Grinder/ui/mainwnd/LabelsListWidget.h
@@ -0,0 +1,55 @@
+/******************************************************************************
+ * File: LabelsListWidget.h
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#ifndef LABELSLISTWIDGET_H
+#define LABELSLISTWIDGET_H
+
+#include "ui/widget/MetaWidget.h"
+#include "ui/widget/ObjectListWidget.h"
+#include "LabelsListItem.h"
+
+namespace grndr
+{
+	class Label;
+
+	using LabelObjectListWidget = ObjectListWidget<Label, LabelsListItem>;
+
+	class LabelsListWidget : public MetaWidget<LabelObjectListWidget>
+	{
+		Q_OBJECT
+
+	public:
+		LabelsListWidget(QWidget* parent = nullptr);
+
+	protected:
+		virtual std::unique_ptr<QMenu> createContextMenu() const override;
+
+		virtual std::vector<QAction*> getActions(AddActionsMode mode) const override;
+
+	protected:
+		virtual void switchToObjectItem(LabelsListItem* item, bool selectItem = true) override;
+
+	private slots:
+		void switchLabel() { switchToObjectItem(currentObjectItem()); }
+		void renameLabel() { editItem(currentItem()); }		
+		void newLabel();
+		void removeLabel();
+		void removeAllLabels();
+
+		void labelSwitched(Label* label);
+		void labelRenamed(QWidget* editor) const;
+
+		void updateActions();
+
+	private:
+		QAction* _switchLabelAction{nullptr};
+		QAction* _renameLabelAction{nullptr};
+		QAction* _newLabelAction{nullptr};
+		QAction* _removeLabelAction{nullptr};
+		QAction* _removeAllLabelsAction{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/mainwnd/RecentProjectsMenu.cpp b/Grinder/ui/mainwnd/RecentProjectsMenu.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..fa083db7f888585b584521f1c30fd7959c701564
--- /dev/null
+++ b/Grinder/ui/mainwnd/RecentProjectsMenu.cpp
@@ -0,0 +1,57 @@
+/******************************************************************************
+ * File: RecentProjectsMenu.cpp
+ * Date: 01.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "RecentProjectsMenu.h"
+
+void RecentProjectsMenu::setupUi(MRUStringList* recentProjects, std::function<void(QString)> loadProjectCallback)
+{
+	if (!recentProjects)
+		throw std::invalid_argument{_EXCPT("recentProjects may not be null")};
+
+	if (!loadProjectCallback)
+		throw std::invalid_argument{_EXCPT("loadProjectCallback may not be null")};
+
+	_recentProjects = recentProjects;
+	_loadProjectCallback = loadProjectCallback;
+
+	updateUi();
+}
+
+void RecentProjectsMenu::updateUi()
+{
+	clear();
+
+	if (!_recentProjects->empty())
+	{
+		for (int i = 0; i < _recentProjects->size(); ++i)
+		{
+			auto fileName = _recentProjects->at(i);
+			auto actionText = fileName;
+
+			if (i < 9)
+				actionText.insert(0, QString{"&%1 | "}.arg(i + 1));
+
+			QAction* action = new QAction{actionText};
+			action->setData(fileName);	// Save the file name to retrieve it when executing
+			addAction(action);
+
+			connect(action, &QAction::triggered, this, &RecentProjectsMenu::loadRecentProject);
+		}
+	}
+	else
+		addAction("No items to display")->setEnabled(false);
+}
+
+void RecentProjectsMenu::loadRecentProject()
+{
+	if (auto action = dynamic_cast<QAction*>(sender()))
+	{
+		QString fileName = action->data().toString();
+
+		if (!fileName.isEmpty())
+			_loadProjectCallback(fileName);
+	}
+}
diff --git a/Grinder/ui/mainwnd/RecentProjectsMenu.h b/Grinder/ui/mainwnd/RecentProjectsMenu.h
new file mode 100644
index 0000000000000000000000000000000000000000..b3a7dc25005916fd907b34c31181face1ec84fb8
--- /dev/null
+++ b/Grinder/ui/mainwnd/RecentProjectsMenu.h
@@ -0,0 +1,35 @@
+/******************************************************************************
+ * File: RecentProjectsMenu.h
+ * Date: 01.3.2018
+ *****************************************************************************/
+
+#ifndef RECENTPROJECTSMENU_H
+#define RECENTPROJECTSMENU_H
+
+#include <QMenu>
+
+#include "common/MRUStringList.h"
+
+namespace grndr
+{
+	class RecentProjectsMenu : public QMenu
+	{
+		Q_OBJECT
+
+	public:
+		using QMenu::QMenu;
+
+	public:
+		void setupUi(MRUStringList* recentProjects, std::function<void(QString)> loadProjectCallback);
+		void updateUi();
+
+	private slots:
+		void loadRecentProject();
+
+	private:
+		MRUStringList* _recentProjects{nullptr};
+		std::function<void(QString)> _loadProjectCallback{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/BlockPropertyTreeItem.cpp b/Grinder/ui/property/BlockPropertyTreeItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..2901f1848a1dd7ae4773ea6fb99177dc232f8726
--- /dev/null
+++ b/Grinder/ui/property/BlockPropertyTreeItem.cpp
@@ -0,0 +1,40 @@
+/******************************************************************************
+ * File: BlockPropertyTreeItem.cpp
+ * Date: 06.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BlockPropertyTreeItem.h"
+#include "pipeline/Block.h"
+#include "res/Resources.h"
+
+BlockPropertyTreeItem::BlockPropertyTreeItem(const std::shared_ptr<grndr::Block>& block) :
+	_block{block}
+{
+	if (!block)
+		throw std::invalid_argument{_EXCPT("block may not be null")};
+
+	setFlags(Qt::ItemIsEnabled|Qt::ItemIsSelectable);
+
+	QFont fontBold = font(0);
+	fontBold.setBold(true);
+	setFont(0, fontBold);
+
+	setBackgroundColor(0, QPalette().color(QPalette::Button));
+
+	updateItem();
+
+	// Update this item if the pipeline item has been renamed; use the internal helper since BlockPropertyTreeItem can't be a QObject
+	if (auto block = _block.lock())
+		block->connect(block.get(), &PipelineItem::itemRenamed, _helper.get(), &_PropertyTreeItemHelper::updateItem);
+}
+
+void BlockPropertyTreeItem::updateItem()
+{
+	if (auto block = _block.lock())	// Make sure that the underlying block still exists
+	{
+		setText(0, block->getName());
+		setToolTip(0, text(0));
+		setIcon(0, QIcon{FILE_ICON_BLOCK});
+	}
+}
diff --git a/Grinder/ui/property/BlockPropertyTreeItem.h b/Grinder/ui/property/BlockPropertyTreeItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..567bb6fe321e8f6d13e9fd810199e7c267740e5d
--- /dev/null
+++ b/Grinder/ui/property/BlockPropertyTreeItem.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: BlockPropertyTreeItem.h
+ * Date: 06.3.2018
+ *****************************************************************************/
+
+#ifndef BLOCKPROPERTYTREEITEM_H
+#define BLOCKPROPERTYTREEITEM_H
+
+#include "PropertyTreeItem.h"
+
+namespace grndr
+{
+	class Block;
+
+	class BlockPropertyTreeItem : public PropertyTreeItem
+	{
+	public:
+		BlockPropertyTreeItem(const std::shared_ptr<Block>& block);
+
+	public:
+		virtual void updateItem() override;
+
+	public:
+		const std::weak_ptr<Block>& pipelineItem() const { return _block; }
+
+	private:
+		std::weak_ptr<Block> _block;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/PropertyEditor.h b/Grinder/ui/property/PropertyEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..fca800260d4381abbdf0d3de9046f6151ded9f7c
--- /dev/null
+++ b/Grinder/ui/property/PropertyEditor.h
@@ -0,0 +1,44 @@
+/******************************************************************************
+ * File: PropertyEditor.h
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYEDITOR_H
+#define PROPERTYEDITOR_H
+
+#include <QWidget>
+
+namespace grndr
+{
+	class PropertyBase;
+
+	template<typename Base, typename PropertyType = PropertyBase>
+	class PropertyEditor : public Base
+	{
+		static_assert(std::is_base_of<QWidget, Base>::value, "Base must be derived from QWidget");
+
+	public:
+		using widget_type = PropertyEditor<Base, PropertyType>;
+		using base_type = Base;
+		using property_type = PropertyType;
+
+	public:
+		PropertyEditor(PropertyType* property, QWidget* parent = nullptr);
+
+	protected:
+		virtual void showEvent(QShowEvent* event) override;
+
+	protected:
+		QString getPropertyValue() const;
+		void setPropertyValue(const QString& value);
+
+		virtual void applyPropertyValue() = 0;
+
+	protected:
+		PropertyType* _property{nullptr};
+	};
+}
+
+#include "PropertyEditor.impl.h"
+
+#endif
diff --git a/Grinder/ui/property/PropertyEditor.impl.h b/Grinder/ui/property/PropertyEditor.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..0746a7fc48cf9fff1afcd672c3d7e1ae0a8eae94
--- /dev/null
+++ b/Grinder/ui/property/PropertyEditor.impl.h
@@ -0,0 +1,45 @@
+/******************************************************************************
+ * File: PropertyEditor.impl.h
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyEditor.h"
+#include "common/PropertyBase.h"
+
+template<typename Base, typename PropertyType>
+PropertyEditor<Base, PropertyType>::PropertyEditor(PropertyType* property, QWidget* parent) : Base(parent),
+	_property{property}
+{
+	if (!property)
+		throw std::invalid_argument{_EXCPT("property may not be null")};
+
+	this->setFocusPolicy(Qt::StrongFocus);
+
+	// If the property value changes, reflect the new value
+	_property->connect(_property, &PropertyBase::valueChanged, this, &widget_type::applyPropertyValue);
+}
+
+template<typename Base, typename PropertyType>
+void PropertyEditor<Base, PropertyType>::showEvent(QShowEvent* event)
+{
+	base_type::showEvent(event);
+	applyPropertyValue();
+}
+
+template<typename Base, typename PropertyType>
+QString PropertyEditor<Base, PropertyType>::getPropertyValue() const
+{
+	return _property->toString();
+}
+
+template<typename Base, typename PropertyType>
+void PropertyEditor<Base, PropertyType>::setPropertyValue(const QString& value)
+{
+	try {
+		_property->fromString(value);
+	} catch (...) {
+		// If the string-value is invalid, revert to the previous value in the editor by simulating a property value change
+		applyPropertyValue();
+	}
+}
diff --git a/Grinder/ui/property/PropertyTreeItem.cpp b/Grinder/ui/property/PropertyTreeItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e7bdf2421cb132593b96f609ba9f2b4a5140eede
--- /dev/null
+++ b/Grinder/ui/property/PropertyTreeItem.cpp
@@ -0,0 +1,18 @@
+/******************************************************************************
+ * File: PropertyTreeItem.cpp
+ * Date: 06.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyTreeItem.h"
+
+void _PropertyTreeItemHelper::updateItem()
+{
+	_treeItem->updateItem();
+}
+
+PropertyTreeItem::PropertyTreeItem() :
+	_helper{new _PropertyTreeItemHelper{this}}
+{
+
+}
diff --git a/Grinder/ui/property/PropertyTreeItem.h b/Grinder/ui/property/PropertyTreeItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..4b531b43fa19ec4aae88f3c62704be3abca30db7
--- /dev/null
+++ b/Grinder/ui/property/PropertyTreeItem.h
@@ -0,0 +1,45 @@
+/******************************************************************************
+ * File: PropertyTreeItem.h
+ * Date: 06.3.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYTREEITEM_H
+#define PROPERTYTREEITEM_H
+
+#include <QTreeWidgetItem>
+#include <memory>
+
+namespace grndr
+{
+	class PipelineItem;
+	class PropertyBase;
+	class PropertyTreeItem;
+
+	class _PropertyTreeItemHelper : public QObject
+	{
+		Q_OBJECT
+
+		friend class PropertyTreeItem;
+
+	public slots:
+		void updateItem();
+
+	private:
+		_PropertyTreeItemHelper(PropertyTreeItem* treeItem) : _treeItem{treeItem} { }
+		PropertyTreeItem* _treeItem{nullptr};
+	};
+
+	class PropertyTreeItem : public QTreeWidgetItem
+	{
+	public:
+		PropertyTreeItem();
+
+	public:
+		virtual void updateItem() = 0;
+
+	protected:
+		std::unique_ptr<_PropertyTreeItemHelper> _helper;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/PropertyTreeItemDelegate.cpp b/Grinder/ui/property/PropertyTreeItemDelegate.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..657e77173a6f8323632f206c66a51a84b2902161
--- /dev/null
+++ b/Grinder/ui/property/PropertyTreeItemDelegate.cpp
@@ -0,0 +1,48 @@
+/******************************************************************************
+ * File: PropertyTreeItemDelegate.cpp
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyTreeItemDelegate.h"
+#include "PropertyTreeWidget.h"
+#include "ValuePropertyTreeItem.h"
+
+#include "PropertyEditor.h"
+
+PropertyTreeItemDelegate::PropertyTreeItemDelegate(PropertyTreeWidget* widget, QObject* parent) : QStyledItemDelegate(parent),
+	_widget{widget}
+{
+	if (!widget)
+		throw std::invalid_argument{_EXCPT("widget may not be null")};
+}
+
+QWidget* PropertyTreeItemDelegate::createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const
+{
+	Q_UNUSED(parent);
+	Q_UNUSED(option);
+
+	if (auto item = _widget->valuePropertyItem(index))
+		return item->createEditor(parent);
+	else
+		return nullptr;
+}
+
+void PropertyTreeItemDelegate::setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& index) const
+{
+	Q_UNUSED(editor);
+	Q_UNUSED(model);
+
+	// The model data has been changed, so update the corresponding item
+	if (auto item = _widget->valuePropertyItem(index))
+		item->updateItem();
+}
+
+void PropertyTreeItemDelegate::initStyleOption(QStyleOptionViewItem* option, const QModelIndex& index) const
+{
+	QStyledItemDelegate::initStyleOption(option, index);
+
+	// If editing the given item, hide its text so that it doesn't appear below the editor
+	if (_widget->isEditing() && index.row() == _widget->currentIndex().row())
+		option->text = "";
+}
diff --git a/Grinder/ui/property/PropertyTreeItemDelegate.h b/Grinder/ui/property/PropertyTreeItemDelegate.h
new file mode 100644
index 0000000000000000000000000000000000000000..b6dfc52c3a532c685fb240a983468e9cf0e4c959
--- /dev/null
+++ b/Grinder/ui/property/PropertyTreeItemDelegate.h
@@ -0,0 +1,35 @@
+/******************************************************************************
+ * File: PropertyTreeItemDelegate.h
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYTREEITEMDELEGATE_H
+#define PROPERTYTREEITEMDELEGATE_H
+
+#include <QStyledItemDelegate>
+
+namespace grndr
+{
+	class PropertyTreeWidget;
+
+	class PropertyTreeItemDelegate : public QStyledItemDelegate
+	{
+		Q_OBJECT
+
+	public:
+		PropertyTreeItemDelegate(PropertyTreeWidget* widget, QObject* parent = nullptr);
+
+	public:
+		virtual QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const override;
+
+		virtual void setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& index) const override;
+
+	protected:
+		virtual void initStyleOption(QStyleOptionViewItem* option, const QModelIndex& index) const override;
+
+	private:
+		PropertyTreeWidget* _widget{nullptr};			
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/PropertyTreeWidget.cpp b/Grinder/ui/property/PropertyTreeWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e1e0ae4016d393d46930aab80fc4f9762e8b50b5
--- /dev/null
+++ b/Grinder/ui/property/PropertyTreeWidget.cpp
@@ -0,0 +1,247 @@
+/******************************************************************************
+ * File: PropertyTreeWidget.cpp
+ * Date: 06.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PropertyTreeWidget.h"
+#include "BlockPropertyTreeItem.h"
+#include "ValuePropertyTreeItem.h"
+#include "PropertyTreeItemDelegate.h"
+#include "core/GrinderApplication.h"
+#include "ui/graph/GraphBlockNode.h"
+#include "util/UIUtils.h"
+
+PropertyTreeWidget::PropertyTreeWidget(QWidget *parent) : QTreeWidget(parent)
+{
+	// Set the delegate for the second column which handles editing of the properties
+	setItemDelegateForColumn(1, new PropertyTreeItemDelegate{this});
+
+	// Listen for pipeline switches to clear all shown properties and to listen for selection changes
+	connect(&grinder()->pipelineController(), &PipelineController::pipelineSwitching, this, &PropertyTreeWidget::pipelineSwitching);
+	connect(&grinder()->pipelineController(), &PipelineController::pipelineSwitched, this, &PropertyTreeWidget::pipelineSwitched);
+
+	// Update the actions whenever the selection changes
+	connect(this, &QTreeWidget::itemSelectionChanged, this, &PropertyTreeWidget::itemSelectionChanged);
+
+	// Create actions
+	_editItem = UIUtils::createAction(this, "&Edit property", "", SLOT(editCurrentItem()), "Edit the selected property", "F2");
+	_expandAllAction = UIUtils::createAction(this, "E&xpand all", "", SLOT(expandAllItems()), "Expand all items");
+	_collapseAllAction = UIUtils::createAction(this, "&Collapse all", "", SLOT(collapseAllItems()), "Collapse all items");
+
+	updateActions();
+}
+
+void PropertyTreeWidget::setupUi(QLabel* propertyDescLabel)
+{
+	if (!propertyDescLabel)
+		throw std::invalid_argument{_EXCPT("propertyDescLabel may not be null")};
+
+	_propertyDescLabel = propertyDescLabel;
+	updatePropertyDescription();
+}
+
+void PropertyTreeWidget::showEvent(QShowEvent* event)
+{
+	QTreeWidget::showEvent(event);
+
+	header()->setDefaultAlignment(Qt::AlignCenter);
+	header()->setSortIndicator(0, Qt::AscendingOrder);
+}
+
+void PropertyTreeWidget::contextMenuEvent(QContextMenuEvent* event)
+{
+	QMenu menu;
+
+	menu.addAction(_editItem);
+	menu.addSeparator();
+	menu.addAction(_expandAllAction);
+	menu.addAction(_collapseAllAction);
+
+	menu.exec(event->globalPos());
+}
+
+void PropertyTreeWidget::mouseDoubleClickEvent(QMouseEvent* event)
+{
+	QTreeWidget::mouseDoubleClickEvent(event);
+
+	if (event->button() == Qt::LeftButton)
+		editCurrentItem();
+}
+
+void PropertyTreeWidget::keyPressEvent(QKeyEvent* event)
+{
+	if (event->modifiers() == Qt::NoModifier && (event->key() == Qt::Key_Return || event->key() == Qt::Key_Space))	// Also edit the property when pressing Return or Space
+		editCurrentItem();
+	else
+		QTreeWidget::keyPressEvent(event);
+}
+
+void PropertyTreeWidget::addBlocks(const std::vector<std::shared_ptr<grndr::Block> >& blocks)
+{
+	// Create a top-level item for every block
+	for (auto block : blocks)
+	{
+		BlockPropertyTreeItem* treeItem = new BlockPropertyTreeItem{block};
+		addTopLevelItem(treeItem);
+
+		addProperties(block.get(), treeItem);
+
+		treeItem->setExpanded(true);
+		treeItem->setFirstColumnSpanned(true);
+	}
+
+	updateColumnWidths();
+}
+
+void PropertyTreeWidget::addProperties(const PipelineItem* pipelineItem, QTreeWidgetItem* parentItem)
+{
+	std::vector<std::shared_ptr<PropertyBase>> properties;
+
+	// Ignore read-only properties
+	for (auto& property : pipelineItem->properties())
+	{
+		if (!property->hasFlag(PropertyBase::Flag::ReadOnly) && !property->hasFlag(PropertyBase::Flag::Hidden))
+			properties.push_back(property);
+	}
+
+	if (!properties.empty())
+	{
+		for (auto& property : properties)
+		{
+			ValuePropertyTreeItem* treeItem = new ValuePropertyTreeItem{property};
+			parentItem->addChild(treeItem);
+		}
+	}
+	else
+	{
+		// Add an item showing that the pipeline item has no properties
+		QTreeWidgetItem* emptyItem = new QTreeWidgetItem({"No properties to display"});
+		parentItem->addChild(emptyItem);
+
+		emptyItem->setFlags(Qt::NoItemFlags);
+		emptyItem->setFirstColumnSpanned(true);
+	}
+}
+
+void PropertyTreeWidget::itemSelectionChanged()
+{
+	updateActions();
+	updatePropertyDescription();
+}
+
+void PropertyTreeWidget::editCurrentItem()
+{
+	if (auto item = currentValuePropertyItem())
+		editItem(item, 1);
+}
+
+void PropertyTreeWidget::pipelineSwitching(Pipeline* pipeline)
+{
+	Q_UNUSED(pipeline);
+
+	clear();
+
+	if (auto scene = grinder()->pipelineController().activeScene())
+		disconnect(scene, &GraphScene::selectionChanged, this, &PropertyTreeWidget::sceneSelectionChanged);
+}
+
+void PropertyTreeWidget::pipelineSwitched(Pipeline* pipeline)
+{
+	Q_UNUSED(pipeline);
+
+	// Listen for selection changes in the current graph scene
+	if (auto scene = grinder()->pipelineController().activeScene())
+		connect(scene, &GraphScene::selectionChanged, this, &PropertyTreeWidget::sceneSelectionChanged);
+}
+
+void PropertyTreeWidget::sceneSelectionChanged()
+{
+	clear();
+
+	if (auto scene = grinder()->pipelineController().activeScene())
+	{
+		// Add all selected blocks
+		std::vector<std::shared_ptr<Block>> blocks;
+
+		for (auto& blockNode : scene->getNodes<GraphBlockNode>(true))
+		{
+			if (auto block = blockNode->block().lock())
+				blocks.push_back(block);
+		}
+
+		addBlocks(blocks);
+	}
+
+	updateActions();
+}
+
+void PropertyTreeWidget::updateActions()
+{
+	auto item = currentValuePropertyItem();
+
+	_editItem->setEnabled(item != nullptr);
+	_expandAllAction->setEnabled(topLevelItemCount() > 0);
+	_collapseAllAction->setEnabled(topLevelItemCount() > 0);
+}
+
+void PropertyTreeWidget::updatePropertyDescription()
+{
+	QString desc;
+
+	if (auto item = currentValuePropertyItem())
+	{
+		if (auto property = item->property().lock())
+		{
+			QString propertyName = QString{"<b>%1</b>"}.arg(property->getName());
+			QString propertyDesc = property->getDescription();
+
+			if (propertyDesc.isEmpty())
+				propertyDesc = "No description available";
+
+			desc = QString{"<div style='margin-bottom: 6px'>%1</div><div>%2</div>"}.arg(propertyName).arg(propertyDesc);
+		}
+	}
+
+	_propertyDescLabel->setText(desc);
+}
+
+ValuePropertyTreeItem* PropertyTreeWidget::valuePropertyItem(const QModelIndex& index) const
+{
+	return dynamic_cast<ValuePropertyTreeItem*>(itemFromIndex(index));
+}
+
+ValuePropertyTreeItem* PropertyTreeWidget::currentValuePropertyItem() const
+{
+	return valuePropertyItem(currentIndex());
+}
+
+void PropertyTreeWidget::updateColumnWidths()
+{
+	// Measure all level-1 items to get the minimum width of the first column
+	int minWidth = header()->defaultSectionSize();
+
+	for (int i = 0; i < topLevelItemCount(); ++i)
+	{
+		auto rootItem = topLevelItem(i);
+
+		for (int j = 0; j < rootItem->childCount(); ++j)
+		{
+			auto childItem = rootItem->child(j);
+
+			if (!childItem->isFirstColumnSpanned())	// Spanned items mean that the parent has no properties, so skip these
+			{
+				QFontMetrics fontMetrics{childItem->font(0)};
+				minWidth = std::max(fontMetrics.width(childItem->text(0)) + 3 * indentation(), minWidth);
+			}
+		}
+	}
+
+	setColumnWidth(0, minWidth);
+}
+
+void PropertyTreeWidget::expandAllItems(bool expand)
+{
+	for (int i = 0; i < topLevelItemCount(); ++i)
+		topLevelItem(i)->setExpanded(expand);
+}
diff --git a/Grinder/ui/property/PropertyTreeWidget.h b/Grinder/ui/property/PropertyTreeWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..1d44a59a1ce4dfc4f3f4e420cb2567a218648dfe
--- /dev/null
+++ b/Grinder/ui/property/PropertyTreeWidget.h
@@ -0,0 +1,82 @@
+/******************************************************************************
+ * File: PropertyTreeWidget.h
+ * Date: 06.3.2018
+ *****************************************************************************/
+
+#ifndef PROPERTYTREEWIDGET_H
+#define PROPERTYTREEWIDGET_H
+
+#include <QTreeWidget>
+#include <QLabel>
+#include <memory>
+
+namespace grndr
+{
+	class Pipeline;
+	class PipelineItem;
+	class Block;
+	class GraphBlockNode;
+	class GraphConnectionNode;
+	class ValuePropertyTreeItem;
+
+	class PropertyTreeWidget : public QTreeWidget
+	{
+		Q_OBJECT
+
+		friend class PropertyTreeItemDelegate;
+
+	public:
+		PropertyTreeWidget(QWidget *parent = nullptr);
+
+	public:
+		void setupUi(QLabel* propertyDescLabel);
+
+		void clear() { QTreeWidget::clear(); updateActions(); updatePropertyDescription(); }
+
+	protected:
+		virtual void showEvent(QShowEvent* event) override;
+
+		virtual void contextMenuEvent(QContextMenuEvent* event) override;
+		virtual void mouseDoubleClickEvent(QMouseEvent* event) override;
+		virtual void keyPressEvent(QKeyEvent* event) override;
+
+	private:
+		void addBlocks(const std::vector<std::shared_ptr<Block>>& blocks);
+		void addProperties(const PipelineItem* pipelineItem, QTreeWidgetItem* parentItem);
+
+	private slots:
+		void itemSelectionChanged();
+		void editCurrentItem();
+
+		void pipelineSwitching(Pipeline* pipeline);
+		void pipelineSwitched(Pipeline* pipeline);
+
+		void sceneSelectionChanged();
+
+		void expandAllItems() { expandAllItems(true); }
+		void collapseAllItems() { expandAllItems(false); }
+
+		void updateActions();
+		void updatePropertyDescription();
+
+	private:
+		ValuePropertyTreeItem* valuePropertyItem(const QModelIndex& index) const;
+		ValuePropertyTreeItem* currentValuePropertyItem() const;
+
+	private:
+		void updateColumnWidths();
+
+		void expandAllItems(bool expand);
+
+		bool isEditing() { return state() == EditingState; }
+
+	private:
+		QAction* _editItem{nullptr};
+		QAction* _expandAllAction{nullptr};
+		QAction* _collapseAllAction{nullptr};				
+
+		QLabel* _propertyDescLabel;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/ValuePropertyTreeItem.cpp b/Grinder/ui/property/ValuePropertyTreeItem.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a602ea8d160232bb0bad4e2dce0ede7c232c147c
--- /dev/null
+++ b/Grinder/ui/property/ValuePropertyTreeItem.cpp
@@ -0,0 +1,45 @@
+/******************************************************************************
+ * File: ValuePropertyTreeItem.cpp
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ValuePropertyTreeItem.h"
+#include "common/PropertyBase.h"
+
+ValuePropertyTreeItem::ValuePropertyTreeItem(const std::shared_ptr<PropertyBase>& property) :
+	_property{property}
+{
+	if (!property)
+		throw std::invalid_argument{_EXCPT("property may not be null")};
+
+	setFlags(Qt::ItemIsEnabled|Qt::ItemIsSelectable|Qt::ItemIsEditable);
+
+	setTextColor(1, QColor{20, 105, 140});
+
+	updateItem();
+
+	// Update this item if the property value has changed; use the internal helper since PropertyTreeItem can't be a QObject
+	if (auto property = _property.lock())
+		property->connect(property.get(), &PropertyBase::valueChanged, _helper.get(), &_PropertyTreeItemHelper::updateItem);
+}
+
+QWidget* ValuePropertyTreeItem::createEditor(QWidget* parent) const
+{
+	if (auto property = _property.lock())	// Make sure that the underlying property still exists
+		return property->createEditor(parent);
+	else
+		return nullptr;
+}
+
+void ValuePropertyTreeItem::updateItem()
+{
+	if (auto property = _property.lock())	// Make sure that the underlying property still exists
+	{
+		setText(0, property->getName());
+		setToolTip(0, text(0));
+
+		setText(1, property->toString());
+		setToolTip(1, text(1));
+	}
+}
diff --git a/Grinder/ui/property/ValuePropertyTreeItem.h b/Grinder/ui/property/ValuePropertyTreeItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..fd879fdf4a1e27a0b8061f67ec3e98f0dbca57c7
--- /dev/null
+++ b/Grinder/ui/property/ValuePropertyTreeItem.h
@@ -0,0 +1,36 @@
+/******************************************************************************
+ * File: ValuePropertyTreeItem.h
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#ifndef VALUEPROPERTYTREEITEM_H
+#define VALUEPROPERTYTREEITEM_H
+
+#include "PropertyTreeItem.h"
+
+namespace grndr
+{
+	class PipelineItem;
+	class PropertyBase;
+
+	class ValuePropertyTreeItem : public PropertyTreeItem
+	{
+	public:
+		ValuePropertyTreeItem(const std::shared_ptr<PropertyBase>& property);
+
+	public:
+		QWidget* createEditor(QWidget* parent) const;
+
+	public:
+		virtual void updateItem() override;
+
+	public:
+		std::weak_ptr<PropertyBase>& property() { return _property; }
+		const std::weak_ptr<PropertyBase>& property() const { return _property; }
+
+	private:
+		std::weak_ptr<PropertyBase> _property;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/editors/AnglePropertyEditor.cpp b/Grinder/ui/property/editors/AnglePropertyEditor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ac882f9acefa234c3beb9faaea3ecb74e0002196
--- /dev/null
+++ b/Grinder/ui/property/editors/AnglePropertyEditor.cpp
@@ -0,0 +1,63 @@
+/******************************************************************************
+ * File: AnglePropertyEditor.cpp
+ * Date: 26.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "AnglePropertyEditor.h"
+
+AnglePropertyEditor::AnglePropertyEditor(AngleProperty* property, QWidget* parent) : PropertyEditor(property, parent),
+	_angleDial{new QDial{}}, _angleEdit{new AutoFocusLineEdit{}}
+{
+	setFocusPolicy(Qt::NoFocus);
+
+	_angleDial->setFixedWidth(50);
+	_angleDial->setFixedHeight(50);
+	_angleDial->setRange(0, 360);
+	_angleDial->setWrapping(true);
+	_angleDial->setNotchesVisible(true);
+	_angleDial->setNotchTarget(45);
+	_angleDial->setInvertedAppearance(true);
+	_angleDial->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+
+	_angleEdit->setValidator(new QRegExpValidator{QRegExp{"\\d+(.?\\d+)?"}});
+	_angleEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+
+	// Create a horizontal layout and add all controls to it
+	auto layout = new QHBoxLayout{};
+	layout->setContentsMargins(0, 0, 0, 0);
+	layout->addWidget(_angleDial);
+	layout->addWidget(_angleEdit);
+	layout->addWidget(new QLabel{"deg"});
+	setLayout(layout);
+
+	// Keep the dial and the text edit in sync; this also updates the property
+	connect(_angleDial, &QDial::valueChanged, this, &AnglePropertyEditor::angleDialChanged);
+	connect(_angleEdit, &QLineEdit::textChanged, this, &AnglePropertyEditor::angleValueChanged);
+
+	applyPropertyValue();
+}
+
+void AnglePropertyEditor::applyPropertyValue()
+{
+	_angleEdit->setText(getPropertyValue());
+}
+
+void AnglePropertyEditor::angleDialChanged(int value)
+{
+	auto angle = (value + 270) % 360;
+	QString text = QString{"%1"}.arg(angle);
+
+	if (text != _angleEdit->text())
+		_angleEdit->setText(text);
+}
+
+void AnglePropertyEditor::angleValueChanged(const QString& value)
+{
+	auto angle = (std::lround(value.toDouble()) + 90) % 360;
+
+	if (angle != _angleDial->value())
+		_angleDial->setValue(angle);
+
+	setPropertyValue(_angleEdit->text());
+}
diff --git a/Grinder/ui/property/editors/AnglePropertyEditor.h b/Grinder/ui/property/editors/AnglePropertyEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..56b4b1b80ac88a4ab1e51caab46bb6f9f26194d1
--- /dev/null
+++ b/Grinder/ui/property/editors/AnglePropertyEditor.h
@@ -0,0 +1,35 @@
+/******************************************************************************
+ * File: AnglePropertyEditor.h
+ * Date: 26.3.2018
+ *****************************************************************************/
+
+#ifndef ANGLEPROPERTYEDITOR_H
+#define ANGLEPROPERTYEDITOR_H
+
+#include "common/properties/AngleProperty.h"
+#include "ui/widget/AutoFocusLineEdit.h"
+#include "ui/property/PropertyEditor.h"
+
+namespace grndr
+{
+	class AnglePropertyEditor : public PropertyEditor<QWidget, AngleProperty>
+	{
+		Q_OBJECT
+
+	public:
+		AnglePropertyEditor(AngleProperty* property, QWidget *parent = nullptr);
+
+	protected:
+		virtual void applyPropertyValue() override;
+
+	private slots:
+		void angleDialChanged(int value);
+		void angleValueChanged(const QString& value);
+
+	private:
+		QDial* _angleDial{nullptr};
+		AutoFocusLineEdit* _angleEdit{nullptr};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/editors/BoolPropertyEditor.cpp b/Grinder/ui/property/editors/BoolPropertyEditor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..3ffbdd41809acdd303bb4689e503e8075ae4cdc8
--- /dev/null
+++ b/Grinder/ui/property/editors/BoolPropertyEditor.cpp
@@ -0,0 +1,27 @@
+/******************************************************************************
+ * File: BoolPropertyEditor.cpp
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "BoolPropertyEditor.h"
+
+BoolPropertyEditor::BoolPropertyEditor(BoolProperty* property, QWidget *parent) : PropertyEditor(property, parent)
+{
+	setText("Enable");
+
+	applyPropertyValue();
+
+	// Update the property whenever the checkbox has been toggled
+	connect(this, &QCheckBox::toggled, this, &BoolPropertyEditor::itemChecked);
+}
+
+void BoolPropertyEditor::applyPropertyValue()
+{
+	setChecked(_property->getValue());
+}
+
+void BoolPropertyEditor::itemChecked(bool checked)
+{
+	_property->setValue(checked);
+}
diff --git a/Grinder/ui/property/editors/BoolPropertyEditor.h b/Grinder/ui/property/editors/BoolPropertyEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..71264345e17f1e9bcb8586d7e0be9013c551eb22
--- /dev/null
+++ b/Grinder/ui/property/editors/BoolPropertyEditor.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: BoolPropertyEditor.h
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#ifndef BOOLPROPERTYEDITOR_H
+#define BOOLPROPERTYEDITOR_H
+
+#include <QCheckBox>
+
+#include "common/properties/BoolProperty.h"
+#include "ui/property/PropertyEditor.h"
+
+namespace grndr
+{
+	class BoolPropertyEditor : public PropertyEditor<QCheckBox, BoolProperty>
+	{
+		Q_OBJECT
+
+	public:
+		BoolPropertyEditor(BoolProperty* property, QWidget *parent = nullptr);
+
+	protected:
+		virtual void applyPropertyValue() override;
+
+	private slots:
+		void itemChecked(bool checked);
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/editors/DualTextPropertyEditor.h b/Grinder/ui/property/editors/DualTextPropertyEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..9707afd3596aa6943aa21d46993f7ffd3f955d4a
--- /dev/null
+++ b/Grinder/ui/property/editors/DualTextPropertyEditor.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: DualTextPropertyEditor.h
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#ifndef DUALTEXTPROPERTYEDITOR_H
+#define DUALTEXTPROPERTYEDITOR_H
+
+#include "ui/widget/AutoFocusLineEdit.h"
+#include "ui/property/PropertyEditor.h"
+
+namespace grndr
+{
+	template<class PropertyType>
+	class DualTextPropertyEditor : public PropertyEditor<QWidget, PropertyType>
+	{
+	public:
+		DualTextPropertyEditor(PropertyType* property, QString name1, QString name2, QString inputMask1, QString inputMask2, QWidget *parent = nullptr);
+
+	protected:
+		virtual void editingFinished() = 0;
+
+	protected:
+		AutoFocusLineEdit* _lineEdit1{nullptr};
+		AutoFocusLineEdit* _lineEdit2{nullptr};
+	};
+}
+
+#include "DualTextPropertyEditor.impl.h"
+
+#endif
diff --git a/Grinder/ui/property/editors/DualTextPropertyEditor.impl.h b/Grinder/ui/property/editors/DualTextPropertyEditor.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..0bb8e25c795f39b28cd9d5887be73bb4cff300e9
--- /dev/null
+++ b/Grinder/ui/property/editors/DualTextPropertyEditor.impl.h
@@ -0,0 +1,36 @@
+/******************************************************************************
+ * File: DualTextPropertyEditor.impl.h
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DualTextPropertyEditor.h"
+
+template<class PropertyType>
+DualTextPropertyEditor<PropertyType>::DualTextPropertyEditor(PropertyType* property, QString name1, QString name2, QString inputMask1, QString inputMask2, QWidget* parent) : PropertyEditor<QWidget, PropertyType>(property, parent),
+	_lineEdit1{new AutoFocusLineEdit{}}, _lineEdit2{new AutoFocusLineEdit{}}
+{
+	this->setFocusPolicy(Qt::NoFocus);
+
+	if (!inputMask1.isEmpty())
+		_lineEdit1->setValidator(new QRegExpValidator{QRegExp{inputMask1}});
+
+	if (!inputMask2.isEmpty())
+		_lineEdit2->setValidator(new QRegExpValidator{QRegExp{inputMask2}});
+
+	// Create a horizontal layout and add all controls to it
+	auto layout = new QHBoxLayout{};
+	layout->setContentsMargins(0, 0, 0, 0);
+	layout->addWidget(new QLabel{name1});
+	layout->addWidget(_lineEdit1);
+	layout->addWidget(new QLabel{name2});
+	layout->addWidget(_lineEdit2);
+	this->setLayout(layout);
+
+	_lineEdit1->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+	_lineEdit2->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+
+	// Update the property whenever editing has been finished
+	this->connect(_lineEdit1, &QLineEdit::editingFinished, this, &DualTextPropertyEditor<PropertyType>::editingFinished);
+	this->connect(_lineEdit2, &QLineEdit::editingFinished, this, &DualTextPropertyEditor<PropertyType>::editingFinished);
+}
diff --git a/Grinder/ui/property/editors/PointPropertyEditor.cpp b/Grinder/ui/property/editors/PointPropertyEditor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..77e39b9265e049089ac901512a774a368898ffdd
--- /dev/null
+++ b/Grinder/ui/property/editors/PointPropertyEditor.cpp
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: PointPropertyEditor.cpp
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "PointPropertyEditor.h"
+#include "util/StringConv.h"
+
+PointPropertyEditor::PointPropertyEditor(PointProperty* property, QWidget* parent) : DualTextPropertyEditor(property, "x:", "y:", "-?\\d+", "-?\\d+", parent)
+{
+	applyPropertyValue();	
+}
+
+void PointPropertyEditor::applyPropertyValue()
+{
+	auto point = _property->getValue();
+
+	_lineEdit1->setText(QString{"%1"}.arg(point.x()));
+	_lineEdit2->setText(QString{"%1"}.arg(point.y()));
+}
+
+void PointPropertyEditor::editingFinished()
+{
+	QPoint point;
+
+	point.setX(_lineEdit1->text().toInt());
+	point.setY(_lineEdit2->text().toInt());
+
+	setPropertyValue(StringConv::convertValue(point));
+}
diff --git a/Grinder/ui/property/editors/PointPropertyEditor.h b/Grinder/ui/property/editors/PointPropertyEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..21e2b041d84f9aba6f26c0ea7a3ca9903cf9a17b
--- /dev/null
+++ b/Grinder/ui/property/editors/PointPropertyEditor.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: PointPropertyEditor.h
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#ifndef POINTPROPERTYEDITOR_H
+#define POINTPROPERTYEDITOR_H
+
+#include <QLineEdit>
+
+#include "DualTextPropertyEditor.h"
+#include "common/properties/PointProperty.h"
+
+namespace grndr
+{
+	class PointPropertyEditor : public DualTextPropertyEditor<PointProperty>
+	{
+		Q_OBJECT
+
+	public:
+		PointPropertyEditor(PointProperty* property, QWidget *parent = nullptr);
+
+	protected:
+		virtual void applyPropertyValue() override;
+
+	protected:
+		virtual void editingFinished() override;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/editors/SizePropertyEditor.cpp b/Grinder/ui/property/editors/SizePropertyEditor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0a22a61a916f19129dea177987907ea6827ed327
--- /dev/null
+++ b/Grinder/ui/property/editors/SizePropertyEditor.cpp
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: SizePropertyEditor.cpp
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SizePropertyEditor.h"
+#include "util/StringConv.h"
+
+SizePropertyEditor::SizePropertyEditor(SizeProperty* property, QWidget* parent) : DualTextPropertyEditor(property, "w:", "h:", "\\d+", "\\d+", parent)
+{
+	applyPropertyValue();	
+}
+
+void SizePropertyEditor::applyPropertyValue()
+{
+	auto size = _property->getValue();
+
+	_lineEdit1->setText(QString{"%1"}.arg(size.width()));
+	_lineEdit2->setText(QString{"%1"}.arg(size.height()));
+}
+
+void SizePropertyEditor::editingFinished()
+{
+	QSize size;
+
+	size.setWidth(_lineEdit1->text().toInt());
+	size.setHeight(_lineEdit2->text().toInt());
+
+	setPropertyValue(StringConv::convertValue(size));
+}
diff --git a/Grinder/ui/property/editors/SizePropertyEditor.h b/Grinder/ui/property/editors/SizePropertyEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..1d326df973aa89c608ee207806922e7ff51568f9
--- /dev/null
+++ b/Grinder/ui/property/editors/SizePropertyEditor.h
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: SizePropertyEditor.h
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#ifndef SIZEPROPERTYEDITOR_H
+#define SIZEPROPERTYEDITOR_H
+
+#include <QLineEdit>
+
+#include "DualTextPropertyEditor.h"
+#include "common/properties/SizeProperty.h"
+
+namespace grndr
+{
+	class SizePropertyEditor : public DualTextPropertyEditor<SizeProperty>
+	{
+		Q_OBJECT
+
+	public:
+		SizePropertyEditor(SizeProperty* property, QWidget *parent = nullptr);
+
+	protected:
+		virtual void applyPropertyValue() override;
+
+	protected:
+		virtual void editingFinished() override;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/property/editors/TextPropertyEditor.cpp b/Grinder/ui/property/editors/TextPropertyEditor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..4e5532ea311836f9bba52d0aef9302a594a37c3e
--- /dev/null
+++ b/Grinder/ui/property/editors/TextPropertyEditor.cpp
@@ -0,0 +1,28 @@
+/******************************************************************************
+ * File: TextPropertyEditor.cpp
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "TextPropertyEditor.h"
+
+TextPropertyEditor::TextPropertyEditor(PropertyBase* property, QString inputMask, QWidget* parent) : PropertyEditor(property, parent)
+{
+	if (!inputMask.isEmpty())
+		setValidator(new QRegExpValidator{QRegExp{inputMask}});
+
+	applyPropertyValue();
+
+	// Update the property whenever editing has been finished
+	connect(this, &QLineEdit::editingFinished, this, &TextPropertyEditor::editingFinished);	
+}
+
+void TextPropertyEditor::applyPropertyValue()
+{
+	setText(getPropertyValue());
+}
+
+void TextPropertyEditor::editingFinished()
+{
+	setPropertyValue(text());
+}
diff --git a/Grinder/ui/property/editors/TextPropertyEditor.h b/Grinder/ui/property/editors/TextPropertyEditor.h
new file mode 100644
index 0000000000000000000000000000000000000000..35179e6ff97a772f513edd13e635d7d57d47d404
--- /dev/null
+++ b/Grinder/ui/property/editors/TextPropertyEditor.h
@@ -0,0 +1,29 @@
+/******************************************************************************
+ * File: TextPropertyEditor.h
+ * Date: 07.3.2018
+ *****************************************************************************/
+
+#ifndef TEXTPROPERTYEDITOR_H
+#define TEXTPROPERTYEDITOR_H
+
+#include "ui/widget/AutoFocusLineEdit.h"
+#include "ui/property/PropertyEditor.h"
+
+namespace grndr
+{
+	class TextPropertyEditor : public PropertyEditor<AutoFocusLineEdit>
+	{
+		Q_OBJECT
+
+	public:
+		TextPropertyEditor(PropertyBase* property, QString inputMask, QWidget *parent = nullptr);
+
+	protected:
+		virtual void applyPropertyValue() override;
+
+	private slots:
+		void editingFinished();
+	};
+}
+
+#endif
diff --git a/Grinder/ui/visscene/VisualNode.cpp b/Grinder/ui/visscene/VisualNode.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..dfcf513a91b60c2185e517dcfdd034493d9f8d9b
--- /dev/null
+++ b/Grinder/ui/visscene/VisualNode.cpp
@@ -0,0 +1,88 @@
+/******************************************************************************
+ * File: VisualNode.cpp
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "VisualNode.h"
+#include "util/UIUtils.h"
+
+#include <QMenu>
+
+VisualNode::VisualNode(QGraphicsScene* scene, QGraphicsItem* parent) : QGraphicsObject(parent),
+	_graphicsScene{scene}, _nodeRect{0, 0, 1, 1}, _nodeRectSelected{0, 0, 1, 1}
+{
+	if (!scene)
+		throw std::invalid_argument{_EXCPT("scene may not be null")};
+
+	setFlags(ItemIsFocusable|ItemIsSelectable|ItemSendsGeometryChanges);
+}
+
+QRectF VisualNode::boundingRect(bool selectedRect) const
+{
+	if (selectedRect)
+		return _nodeRectSelected;
+	else
+		return _nodeRect;
+}
+
+QVariant VisualNode::itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant& value)
+{
+	if (change == QGraphicsItem::ItemSelectedChange)
+	{
+		prepareGeometryChange();
+
+		if (value == true)
+		{
+			setFocus();
+			emit nodeSelected(this);
+		}
+		else
+			emit nodeDeselected(this);
+	}
+	else if (change == QGraphicsItem::ItemPositionHasChanged)
+	{
+		emit nodeMoved(this);
+	}
+
+	return QGraphicsItem::itemChange(change, value);
+}
+
+void VisualNode::contextMenuEvent(QGraphicsSceneContextMenuEvent* event)
+{
+	// Select the node exclusively if it hasn't been selected
+	if (!isSelected())
+	{
+		_graphicsScene->clearSelection();
+		setSelected(true);
+	}
+
+	showContextMenu(event->screenPos());
+
+	event->accept();
+}
+
+QAction* VisualNode::createNodeAction(QString name, QString icon, const char* callback, QString help, QString shortcut)
+{
+	return UIUtils::createAction(this, name, icon, callback, help, shortcut);
+}
+
+void VisualNode::showContextMenu(QPoint pos) const
+{
+	QMenu menu;
+	auto actions = (_graphicsScene->selectedItems().size() > 1 ? getNodesActions(menu) : getNodeActions(menu));
+
+	// If at least one action was provided, create and show a popup menu
+	if (!actions.empty())
+	{
+		for (auto action : actions)
+		{
+			if (action)
+				menu.addAction(action);
+			else	// A nullptr indicates a separator
+				menu.addSeparator();
+		}
+	}
+
+	menu.exec(pos);
+}
diff --git a/Grinder/ui/visscene/VisualNode.h b/Grinder/ui/visscene/VisualNode.h
new file mode 100644
index 0000000000000000000000000000000000000000..f88e1ee74d2a698b3ae5c734d2c5a10650930427
--- /dev/null
+++ b/Grinder/ui/visscene/VisualNode.h
@@ -0,0 +1,52 @@
+/******************************************************************************
+ * File: VisualNode.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef VISUALNODE_H
+#define VISUALNODE_H
+
+#include <QGraphicsObject>
+#include <QAction>
+
+namespace grndr
+{
+	class VisualNode : public QGraphicsObject
+	{
+		Q_OBJECT
+
+	public:
+		VisualNode(QGraphicsScene* scene, QGraphicsItem* parent = nullptr);
+
+	public:
+		virtual QRectF boundingRect() const override { return boundingRect(isSelected()); }
+		QRectF boundingRect(bool selectedRect) const;
+
+	signals:
+		void nodeSelected(VisualNode*);
+		void nodeDeselected(VisualNode*);
+		void nodeMoved(VisualNode*);
+		void nodeGeometryUpdated(VisualNode*);
+
+	protected:
+		virtual QVariant itemChange(GraphicsItemChange change, const QVariant& value) override;
+		virtual void contextMenuEvent(QGraphicsSceneContextMenuEvent* event) override;
+
+	protected:
+		virtual void updateGeometry() { emit nodeGeometryUpdated(this); }
+
+		virtual std::vector<QAction*> getNodeActions(QMenu& menu) const { Q_UNUSED(menu); return {}; }
+		virtual std::vector<QAction*> getNodesActions(QMenu& menu) const { Q_UNUSED(menu); return {}; }
+		QAction* createNodeAction(QString name, QString icon = "", const char* callback = nullptr, QString help = "", QString shortcut = "");
+
+		void showContextMenu(QPoint pos) const;
+
+	protected:
+		QGraphicsScene* _graphicsScene{nullptr};
+
+		QRectF _nodeRect;
+		QRectF _nodeRectSelected;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/visscene/VisualNodeFactory.h b/Grinder/ui/visscene/VisualNodeFactory.h
new file mode 100644
index 0000000000000000000000000000000000000000..d3634860154eb2a9172aafc222484c98368e59eb
--- /dev/null
+++ b/Grinder/ui/visscene/VisualNodeFactory.h
@@ -0,0 +1,51 @@
+/******************************************************************************
+ * File: VisualNodeFactory.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#ifndef VISUALNODEFACTORY_H
+#define VISUALNODEFACTORY_H
+
+#include <memory>
+#include <functional>
+#include <map>
+
+#define REGISTER_NODEFACTORY_TYPE(cls)	registerType(cls::type_value, [scene = _scene](const std::shared_ptr<object_type>& object) { return new cls(scene, object); })
+
+namespace grndr
+{
+	template<typename NodeType, typename ObjType, typename KeyType, typename SceneType>
+	class VisualNodeFactory
+	{
+	public:
+		using node_type = NodeType;
+		using object_type = ObjType;
+		using key_type = KeyType;
+		using scene_type = SceneType;
+
+	private:
+		using node_creator_type = std::function<node_type*(const std::shared_ptr<object_type>&)>;
+
+	public:
+		VisualNodeFactory(scene_type* scene);
+
+	public:
+		void registerType(key_type type, node_creator_type creator);
+		void unregisterType(key_type type);
+
+		node_type* createNode(const std::shared_ptr<object_type>& object) const;
+
+	protected:
+		virtual node_type* createDefaultNode(const std::shared_ptr<object_type>& object) const = 0;
+
+	protected:
+		scene_type* _scene{nullptr};
+
+	private:
+		std::map<key_type, node_creator_type> _nodeCreators;
+	};
+}
+
+#include "VisualNodeFactory.impl.h"
+
+#endif
diff --git a/Grinder/ui/visscene/VisualNodeFactory.impl.h b/Grinder/ui/visscene/VisualNodeFactory.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..c4391dd2671107fbbe05fd03919a520551aa68eb
--- /dev/null
+++ b/Grinder/ui/visscene/VisualNodeFactory.impl.h
@@ -0,0 +1,59 @@
+/******************************************************************************
+ * File: VisualNodeFactory.impl.h
+ * Date: 21.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "VisualNodeFactory.h"
+
+template<typename NodeType, typename ObjType, typename KeyType, typename SceneType>
+VisualNodeFactory<NodeType, ObjType, KeyType, SceneType>::VisualNodeFactory(scene_type* scene) :
+	_scene{scene}
+{
+	if (!scene)
+		throw std::invalid_argument{_EXCPT("scene may not be null")};
+}
+
+template<typename NodeType, typename ObjType, typename KeyType, typename SceneType>
+void VisualNodeFactory<NodeType, ObjType, KeyType, SceneType>::registerType(key_type type, node_creator_type creator)
+{
+	if (type == key_type::Undefined)
+		throw std::invalid_argument{_EXCPT("type may not be Undefined")};
+
+	if (!creator)
+		throw std::invalid_argument{_EXCPT("creator may not be null")};
+
+	_nodeCreators[type] = creator;
+}
+
+template<typename NodeType, typename ObjType, typename KeyType, typename SceneType>
+void VisualNodeFactory<NodeType, ObjType, KeyType, SceneType>::unregisterType(key_type type)
+{
+	_nodeCreators.erase(type);
+}
+
+template<typename NodeType, typename ObjType, typename KeyType, typename SceneType>
+NodeType* VisualNodeFactory<NodeType, ObjType, KeyType, SceneType>::createNode(const std::shared_ptr<object_type>& object) const
+{
+	if (!object)
+		throw std::invalid_argument{_EXCPT("object may not be null")};
+
+	key_type type = object->getType();
+
+	if (type == key_type::Undefined)
+		throw std::invalid_argument{_EXCPT("type may not be Undefined")};
+
+	// Create a node for the given object (type); if no creator exists, create a basic GraphBlockNode
+	if (_nodeCreators.find(type) != _nodeCreators.end())
+	{
+		node_creator_type creator = _nodeCreators.at(type);
+		node_type* node = creator(object);
+
+		if (!node)
+			throw std::runtime_error{_EXCPT(QString{"Failed to create an object node of type %1"}.arg(type))};
+
+		return node;
+	}
+	else	// Use the default block node type
+		return createDefaultNode(object);
+}
diff --git a/Grinder/ui/visscene/VisualScene.h b/Grinder/ui/visscene/VisualScene.h
new file mode 100644
index 0000000000000000000000000000000000000000..dbcf84b3707fa6e5d8b70a8fba1c64ac73b3544f
--- /dev/null
+++ b/Grinder/ui/visscene/VisualScene.h
@@ -0,0 +1,56 @@
+/******************************************************************************
+ * File: VisualScene.h
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#ifndef VISUALSCENE_H
+#define VISUALSCENE_H
+
+#include <QGraphicsScene>
+
+namespace grndr
+{
+	class VisualSceneView;
+
+	template<typename ViewType>
+	class VisualScene : public QGraphicsScene
+	{
+		static_assert(std::is_base_of<VisualSceneView, ViewType>::value, "ViewType must be derived from VisualSceneView");
+
+	public:
+		using view_type = ViewType;	
+
+	public:
+		VisualScene(view_type* view, QRectF sceneRect = QRectF{});
+		virtual ~VisualScene();
+
+	public:
+		template<typename NodeType>
+		std::vector<NodeType*> getNodes(bool selectedOnly = false) { return _getNodes<NodeType>(selectedOnly); }
+		template<typename NodeType>
+		std::vector<const NodeType*> getNodes(bool selectedOnly = false) const;
+
+	public:
+		view_type* view() { return _view; }
+		const view_type* view() const { return _view; }
+
+	protected:
+		virtual void drawBackground(QPainter* painter, const QRectF& rect) override;
+
+	private:
+		void drawGrid(QPainter* painter, const QRectF& rect, int interval, QColor color);
+
+	private:
+		template<typename NodeType>
+		std::vector<NodeType*> _getNodes(bool selectedOnly = false) const;
+
+	protected:
+		view_type* _view{nullptr};
+
+		bool _drawGrid{true};
+	};
+}
+
+#include "VisualScene.impl.h"
+
+#endif
diff --git a/Grinder/ui/visscene/VisualScene.impl.h b/Grinder/ui/visscene/VisualScene.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..0585986242b526e601b406846427521ac62aa2fb
--- /dev/null
+++ b/Grinder/ui/visscene/VisualScene.impl.h
@@ -0,0 +1,96 @@
+/******************************************************************************
+ * File: VisualScene.impl.h
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "VisualScene.h"
+
+template<typename ViewType>
+VisualScene<ViewType>::VisualScene(view_type* view, QRectF sceneRect) : QGraphicsScene (sceneRect),
+	_view{view}
+{
+	if (!view)
+		throw std::invalid_argument{_EXCPT("view may not be null")};
+
+	// Force redraw if the selection has changed
+	connect(this, SIGNAL(selectionChanged()), this, SLOT(update()));
+}
+
+template<typename ViewType>
+VisualScene<ViewType>::~VisualScene()
+{
+	// Prevent selection change events from firing when a scene is destroyed
+	disconnect(this, &VisualScene::selectionChanged, nullptr, nullptr);
+}
+
+template<typename ViewType>
+template<typename NodeType>
+std::vector<const NodeType*> VisualScene<ViewType>::getNodes(bool selectedOnly) const
+{
+	auto selNodes = _getNodes<NodeType>(selectedOnly);
+	std::vector<const NodeType*> nodes;
+
+	for (auto node : selNodes)
+		nodes.push_back(node);
+
+	return nodes;
+}
+
+template<typename ViewType>
+template<typename NodeType>
+std::vector<NodeType*> VisualScene<ViewType>::_getNodes(bool selectedOnly) const
+{
+	std::vector<NodeType*> nodes;
+
+	for (auto item : selectedOnly ? selectedItems() : items())
+	{
+		if (auto node = dynamic_cast<NodeType*>(item))
+			nodes.push_back(node);
+	}
+
+	return nodes;
+}
+
+template<typename ViewType>
+void VisualScene<ViewType>::drawBackground(QPainter* painter, const QRectF& rect)
+{
+	QGraphicsScene::drawBackground(painter, rect);
+
+	if (_drawGrid)
+	{
+		auto gridStyle = _view->sceneStyle().getGridStyle();
+
+		// Draw small grid (only if not zoomed in too much)
+		if (_view->getZoom() < gridStyle.size * gridStyle.maxZoomFactor)
+			drawGrid(painter, rect, gridStyle.size, gridStyle.colorLight);
+
+		// Draw large grid
+		drawGrid(painter, rect, gridStyle.size * gridStyle.darkGridInterval, gridStyle.colorDark);
+	}
+}
+
+template<typename ViewType>
+void VisualScene<ViewType>::drawGrid(QPainter* painter, const QRectF& rect, int interval, QColor color)
+{
+	QVector<QLineF> lines;
+
+	qreal x = static_cast<int>(rect.left()) - (static_cast<int>(rect.left()) % interval);;
+
+	while (x < rect.right())
+	{
+		lines << QLineF{x, rect.top(), x, rect.bottom()};
+		x += interval;
+	}
+
+	qreal y = static_cast<int>(rect.top()) - (static_cast<int>(rect.top()) % interval);;
+
+	while (y < rect.bottom())
+	{
+		lines << QLineF{rect.left(), y, rect.right(), y};
+		y += interval;
+	}
+
+	painter->setPen(QPen{color});
+	painter->drawLines(lines);
+}
diff --git a/Grinder/ui/visscene/VisualSceneInputHandler.cpp b/Grinder/ui/visscene/VisualSceneInputHandler.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f167c754ea05945d707e9a8f5248508690483cae
--- /dev/null
+++ b/Grinder/ui/visscene/VisualSceneInputHandler.cpp
@@ -0,0 +1,47 @@
+/******************************************************************************
+ * File: VisualSceneInputHandler.cpp
+ * Date: 28.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "VisualSceneInputHandler.h"
+
+VisualSceneInputHandler::InputEventResult VisualSceneInputHandler::handleMousePressEvent(QGraphicsSceneMouseEvent* event)
+{
+	if (event->button() == Qt::LeftButton && !event->modifiers().testFlag(Qt::ShiftModifier))	// Shift-clicks should always be passed to the parent (to support shift-dragging of the scene)
+	{
+		_mouseButtonPressed = true;
+		return handleInputEvent<QGraphicsSceneMouseEvent>(event, &VisualSceneInputHandler::mousePressed);
+	}
+	else
+		return InputEventResult::Ignore;
+}
+
+VisualSceneInputHandler::InputEventResult VisualSceneInputHandler::handleMouseMoveEvent(QGraphicsSceneMouseEvent* event)
+{
+	if (_mouseButtonPressed)
+		return handleInputEvent<QGraphicsSceneMouseEvent>(event, &VisualSceneInputHandler::mouseMoved);
+	else
+		return InputEventResult::Ignore;
+}
+
+VisualSceneInputHandler::InputEventResult VisualSceneInputHandler::handleMouseReleaseEvent(QGraphicsSceneMouseEvent* event)
+{
+	if (event->button() == Qt::LeftButton)
+	{
+		_mouseButtonPressed = false;
+		return handleInputEvent<QGraphicsSceneMouseEvent>(event, &VisualSceneInputHandler::mouseReleased);
+	}
+	else
+		return InputEventResult::Ignore;
+}
+
+VisualSceneInputHandler::InputEventResult VisualSceneInputHandler::handleKeyPressEvent(QKeyEvent* event)
+{
+	return handleInputEvent<QKeyEvent>(event, &VisualSceneInputHandler::keyPressed);
+}
+
+VisualSceneInputHandler::InputEventResult VisualSceneInputHandler::handleKeyReleaseEvent(QKeyEvent* event)
+{
+	return handleInputEvent<QKeyEvent>(event, &VisualSceneInputHandler::keyReleased);
+}
diff --git a/Grinder/ui/visscene/VisualSceneInputHandler.h b/Grinder/ui/visscene/VisualSceneInputHandler.h
new file mode 100644
index 0000000000000000000000000000000000000000..8300c8eb542ab393cab231293c4648f050241584
--- /dev/null
+++ b/Grinder/ui/visscene/VisualSceneInputHandler.h
@@ -0,0 +1,51 @@
+/******************************************************************************
+ * File: VisualSceneInputHandler.h
+ * Date: 28.3.2018
+ *****************************************************************************/
+
+#ifndef VISUALSCENEINPUTHANDLER_H
+#define VISUALSCENEINPUTHANDLER_H
+
+#include <QGraphicsSceneMouseEvent>
+#include <QKeyEvent>
+#include <functional>
+
+namespace grndr
+{
+	class VisualSceneInputHandler
+	{
+	public:
+		enum class InputEventResult
+		{
+			Ignore,
+			Process,
+		};
+
+	public:
+		InputEventResult handleMousePressEvent(QGraphicsSceneMouseEvent* event);
+		InputEventResult handleMouseMoveEvent(QGraphicsSceneMouseEvent* event);
+		InputEventResult handleMouseReleaseEvent(QGraphicsSceneMouseEvent* event);
+
+		InputEventResult handleKeyPressEvent(QKeyEvent* event);
+		InputEventResult handleKeyReleaseEvent(QKeyEvent* event);
+
+	protected:
+		virtual InputEventResult mousePressed(const QGraphicsSceneMouseEvent* event) { Q_UNUSED(event); return InputEventResult::Ignore; }
+		virtual InputEventResult mouseMoved(const QGraphicsSceneMouseEvent* event) { Q_UNUSED(event); return InputEventResult::Ignore; }
+		virtual InputEventResult mouseReleased(const QGraphicsSceneMouseEvent* event) { Q_UNUSED(event); return InputEventResult::Ignore; }
+
+		virtual InputEventResult keyPressed(const QKeyEvent* event) { Q_UNUSED(event); return InputEventResult::Ignore; }
+		virtual InputEventResult keyReleased(const QKeyEvent* event) { Q_UNUSED(event); return InputEventResult::Ignore; }
+
+	private:
+		template<typename EventType>
+		InputEventResult handleInputEvent(EventType* event, std::function<InputEventResult(VisualSceneInputHandler*, const EventType*)> handler);
+
+	private:
+		bool _mouseButtonPressed{false};
+	};
+}
+
+#include "VisualSceneInputHandler.impl.h"
+
+#endif
diff --git a/Grinder/ui/visscene/VisualSceneInputHandler.impl.h b/Grinder/ui/visscene/VisualSceneInputHandler.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..04febc1d6a1dbc07b4142dada1a241d3644be368
--- /dev/null
+++ b/Grinder/ui/visscene/VisualSceneInputHandler.impl.h
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: VisualSceneInputHandler.impl.h
+ * Date: 30.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "VisualSceneInputHandler.h"
+
+template<typename EventType>
+VisualSceneInputHandler::InputEventResult VisualSceneInputHandler::handleInputEvent(EventType* event, std::function<InputEventResult(VisualSceneInputHandler*, const EventType*)> handler)
+{
+	InputEventResult result = handler(this, event);
+	event->setAccepted(result == InputEventResult::Process);
+	return result;
+}
diff --git a/Grinder/ui/visscene/VisualSceneStyle.cpp b/Grinder/ui/visscene/VisualSceneStyle.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..39c6bc421cd9278657539a70d72ae75591523418
--- /dev/null
+++ b/Grinder/ui/visscene/VisualSceneStyle.cpp
@@ -0,0 +1,7 @@
+/******************************************************************************
+ * File: VisualSceneStyle.cpp
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "VisualSceneStyle.h"
diff --git a/Grinder/ui/visscene/VisualSceneStyle.h b/Grinder/ui/visscene/VisualSceneStyle.h
new file mode 100644
index 0000000000000000000000000000000000000000..8750adb3de16adc56f3aaeb2b0d216b255c41f9b
--- /dev/null
+++ b/Grinder/ui/visscene/VisualSceneStyle.h
@@ -0,0 +1,47 @@
+/******************************************************************************
+ * File: VisualSceneStyle.h
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#ifndef VISUALSCENESTYLE_H
+#define VISUALSCENESTYLE_H
+
+#include <QSizeF>
+#include <QColor>
+
+namespace grndr
+{
+	class VisualSceneStyle
+	{
+	public:
+		struct ViewStyle
+		{
+			QSizeF defaultSceneSize{0.0f, 0.0f};
+
+			float zoomFactor{1.1};
+			float minZoomLevel{0.2};
+			float maxZoomLevel{5.0};
+		};
+
+		struct GridStyle
+		{
+			int size{20};
+
+			QColor colorDark{200, 200, 200};
+			QColor colorLight{230, 230, 230};
+			int darkGridInterval{5};
+
+			float maxZoomFactor{0.1f};
+		};
+
+	public:
+		const ViewStyle& getViewStyle() const { return _viewStyle; }
+		const GridStyle& getGridStyle() const { return _gridStyle; }
+
+	protected:
+		ViewStyle _viewStyle;
+		GridStyle _gridStyle;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/visscene/VisualSceneView.cpp b/Grinder/ui/visscene/VisualSceneView.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..cb036364bbe7ce9b21d811b5447f8be8bcb34f79
--- /dev/null
+++ b/Grinder/ui/visscene/VisualSceneView.cpp
@@ -0,0 +1,229 @@
+/******************************************************************************
+ * File: VisualSceneView.cpp
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "VisualSceneView.h"
+#include "VisualScene.h"
+#include "util/UIUtils.h"
+#include "res/Resources.h"
+
+VisualSceneView::VisualSceneView(QWidget* parent) : MetaWidget(parent)
+{
+	setInteractive(true);
+
+	setDragMode(QGraphicsView::RubberBandDrag);
+	setTransformationAnchor(QGraphicsView::AnchorUnderMouse);
+	setViewportUpdateMode(QGraphicsView::FullViewportUpdate);	
+
+	// Create view actions
+	_selectAllAction = UIUtils::createAction(this, "Select &all", FILE_ICON_SELECTALL, SLOT(selectAllItems()), "Select all items", "Ctrl+A");
+	_deleteSelectedAction = UIUtils::createAction(this, "&Delete selected items", FILE_ICON_DELETE_SELECTED, SLOT(removeSelectedItems()), "Delete the selected items", "Del");
+
+	_zoomInAction = UIUtils::createAction(this, "Zoom &in", FILE_ICON_ZOOMIN, SLOT(zoomIn()), "Zoom the view in", "Ctrl++");
+	_zoomOutAction = UIUtils::createAction(this, "Zoom &out", FILE_ICON_ZOOMOUT, SLOT(zoomOut()), "Zoom the view out", "Ctrl+-");
+	_zoomFullAction = UIUtils::createAction(this, "Zoom to &100%", FILE_ICON_ZOOMFULL, SLOT(zoomFull()), "Zoom the view to 100%", "Ctrl+Shift+A");
+}
+
+void VisualSceneView::setScene(QGraphicsScene* scene)
+{
+	if (_scene)
+		disconnect(_scene, &QGraphicsScene::selectionChanged, this, &VisualSceneView::updateActions);
+
+	_scene = scene;
+	QGraphicsView::setScene(scene);
+
+	// Listen for selection changes in order to update the view actions
+	if (_scene)
+		connect(_scene, &QGraphicsScene::selectionChanged, this, &VisualSceneView::updateActions);
+
+	updateActions();
+	centerOn(0, 0);
+}
+
+qreal VisualSceneView::getZoom() const
+{
+	return transform().m11();
+}
+
+void VisualSceneView::setZoom(qreal zoomVal)
+{
+	zoom(zoomVal / getZoom());
+}
+
+void VisualSceneView::zoom(qreal factor)
+{
+	auto zoom = getZoom() * factor;
+
+	// Keep zoom in range
+	if (zoom > sceneStyle().getViewStyle().maxZoomLevel)
+		factor = (1.0 / getZoom()) * sceneStyle().getViewStyle().maxZoomLevel;
+	else if (zoom < sceneStyle().getViewStyle().minZoomLevel)
+		factor = (1.0 / getZoom()) * sceneStyle().getViewStyle().minZoomLevel;
+
+	scale(factor, factor);
+	updateActions();
+
+	emit zoomChanged(getZoom());
+}
+
+void VisualSceneView::updateActions()
+{
+	_selectAllAction->setEnabled(_scene && !scene()->items().isEmpty());
+	_deleteSelectedAction->setEnabled(_scene && !scene()->selectedItems().isEmpty());
+
+	auto zoom = getZoom();
+
+	_zoomInAction->setEnabled(_scene && (zoom < sceneStyle().getViewStyle().maxZoomLevel));
+	_zoomOutAction->setEnabled(_scene && (zoom > sceneStyle().getViewStyle().minZoomLevel));
+	_zoomFullAction->setEnabled(_scene && (zoom != 1.0));
+}
+
+void VisualSceneView::fitToWindow(QGraphicsItem* item)
+{
+	if (item)
+	{
+		fitInView(item, Qt::KeepAspectRatio);
+	}
+	else
+	{
+		if (_scene)
+			fitInView(_scene->sceneRect(), Qt::KeepAspectRatio);
+	}
+
+	emit zoomChanged(getZoom());
+	updateActions();
+}
+
+void VisualSceneView::selectAllItems() const
+{
+	if (_scene)
+	{
+		for (auto item : _scene->items())
+			item->setSelected(true);
+	}
+}
+
+void VisualSceneView::moveSelectedItems(QPoint delta)
+{
+	if (_scene)
+	{
+		for (auto item : _scene->selectedItems())
+			item->setPos(item->pos() + delta);
+	}
+}
+
+void VisualSceneView::wheelEvent(QWheelEvent* event)
+{
+	// Only zoom if CTRL is pressed
+	if (!(event->modifiers() & Qt::ControlModifier))
+	{
+		QGraphicsView::wheelEvent(event);
+		return;
+	}
+
+	auto oldZoomPos = mapToScene(event->pos());
+
+	if (event->angleDelta().y() > 0)
+		zoom(sceneStyle().getViewStyle().zoomFactor);
+	else
+		zoom(1.0f / sceneStyle().getViewStyle().zoomFactor);
+
+	auto trans = mapToScene(event->pos()) - oldZoomPos;
+	translate(trans.x(), trans.y());
+
+	event->accept();
+}
+
+void VisualSceneView::keyPressEvent(QKeyEvent* event)
+{
+	if (event->key() == Qt::Key_Shift)	// When holding shift, the view can be dragged around
+	{
+		setDragMode(QGraphicsView::ScrollHandDrag);
+		setInteractive(false);
+		return;
+	}
+	else if (event->key() == Qt::Key_Escape)	// Deselect all items when pressing Esc (but do not suppress event propagation)
+	{
+		if (_scene)
+			_scene->clearSelection();
+	}
+	else if (event->key() == Qt::Key_Up || event->key() == Qt::Key_Down || event->key() == Qt::Key_Left || event->key() == Qt::Key_Right)	// Enable item movement via cursor keys
+	{
+		QPoint delta;
+		int offset = 1;
+
+		if (event->modifiers().testFlag(Qt::ControlModifier))
+		{
+			if (event->modifiers().testFlag(Qt::AltModifier))
+				offset = 25;
+			else
+				offset = 10;
+		}
+
+		switch (event->key())
+		{
+		case Qt::Key_Left:
+			delta.setX(-offset);
+			break;
+
+		case Qt::Key_Right:
+			delta.setX(offset);
+			break;
+
+		case Qt::Key_Up:
+			delta.setY(-offset);
+			break;
+
+		case Qt::Key_Down:
+			delta.setY(offset);
+			break;
+
+		default:
+			break;
+		}
+
+		if (!delta.isNull())
+		{
+			moveSelectedItems(delta);
+			return;
+		}
+	}
+
+	QGraphicsView::keyPressEvent(event);
+}
+
+void VisualSceneView::keyReleaseEvent(QKeyEvent* event)
+{
+	if (event->key() == Qt::Key_Shift)	// When release shift, revert back to rubber band selection
+	{
+		setDragMode(QGraphicsView::RubberBandDrag);
+		setInteractive(true);
+		return;
+	}
+
+	QGraphicsView::keyReleaseEvent(event);
+}
+
+void VisualSceneView::mouseMoveEvent(QMouseEvent* event)
+{
+	// Just in case someone switched applications while holding shift
+	if (!QApplication::queryKeyboardModifiers().testFlag(Qt::ShiftModifier))
+	{
+		setDragMode(QGraphicsView::RubberBandDrag);
+		setInteractive(true);
+	}
+
+	QGraphicsView::mouseMoveEvent(event);
+}
+
+void VisualSceneView::contextMenuEvent(QContextMenuEvent* event)
+{
+	event->setAccepted(false);
+	QGraphicsView::contextMenuEvent(event);
+
+	// Only show the global popup if we didn't right-click a node
+	if (_scene && !event->isAccepted())
+		widget_type::contextMenuEvent(event);
+}
diff --git a/Grinder/ui/visscene/VisualSceneView.h b/Grinder/ui/visscene/VisualSceneView.h
new file mode 100644
index 0000000000000000000000000000000000000000..e540c18265d844d65c624681662ec1024f26dee7
--- /dev/null
+++ b/Grinder/ui/visscene/VisualSceneView.h
@@ -0,0 +1,69 @@
+/******************************************************************************
+ * File: VisualSceneView.h
+ * Date: 15.3.2018
+ *****************************************************************************/
+
+#ifndef VISUALSCENEVIEW_H
+#define VISUALSCENEVIEW_H
+
+#include <QGraphicsView>
+
+#include "ui/widget/MetaWidget.h"
+#include "VisualSceneStyle.h"
+
+namespace grndr
+{
+	class VisualSceneView : public MetaWidget<QGraphicsView>
+	{
+		Q_OBJECT
+
+	public:
+		VisualSceneView(QWidget* parent = nullptr);
+
+	public:
+		void setScene(QGraphicsScene* scene);
+
+		qreal getZoom() const;
+		void setZoom(qreal zoomVal);
+		void zoom(qreal factor);		
+
+	public slots:
+		virtual void selectAllItems() const;
+		virtual void moveSelectedItems(QPoint delta);
+		virtual void removeSelectedItems() const { }
+
+		void zoomIn() { zoom(sceneStyle().getViewStyle().zoomFactor); }
+		void zoomOut() { zoom(1.0 / sceneStyle().getViewStyle().zoomFactor); }
+		void zoomFull() { setZoom(1.0); }
+		void fitToWindow(QGraphicsItem* item = nullptr);		
+
+	public:
+		virtual const VisualSceneStyle& sceneStyle() const = 0;
+
+	signals:
+		void zoomChanged(qreal);
+
+	protected:
+		virtual void contextMenuEvent(QContextMenuEvent* event) override;
+		virtual void wheelEvent(QWheelEvent* event) override;
+		virtual void keyPressEvent(QKeyEvent* event) override;
+		virtual void keyReleaseEvent(QKeyEvent* event) override;
+		virtual void mouseMoveEvent(QMouseEvent* event) override;
+
+	protected slots:
+		virtual void updateActions();
+
+	protected:
+		QGraphicsScene* _scene{nullptr};
+
+	protected:
+		QAction* _selectAllAction{nullptr};
+		QAction* _deleteSelectedAction{nullptr};
+
+		QAction* _zoomInAction{nullptr};
+		QAction* _zoomOutAction{nullptr};
+		QAction* _zoomFullAction{nullptr};		
+	};
+}
+
+#endif
diff --git a/Grinder/ui/widget/AutoFocusLineEdit.cpp b/Grinder/ui/widget/AutoFocusLineEdit.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..024471be7565bdf1ea37824614fbb9e63ead7ba5
--- /dev/null
+++ b/Grinder/ui/widget/AutoFocusLineEdit.cpp
@@ -0,0 +1,13 @@
+/******************************************************************************
+ * File: AutoFocusLineEdit.cpp
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "AutoFocusLineEdit.h"
+
+void AutoFocusLineEdit::focusInEvent(QFocusEvent* event)
+{
+	QLineEdit::focusInEvent(event);
+	QTimer::singleShot(0, [this]() { selectAll(); });
+}
diff --git a/Grinder/ui/widget/AutoFocusLineEdit.h b/Grinder/ui/widget/AutoFocusLineEdit.h
new file mode 100644
index 0000000000000000000000000000000000000000..934d4ca74def3710c288aa4107d816e1c8dddfc3
--- /dev/null
+++ b/Grinder/ui/widget/AutoFocusLineEdit.h
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: AutoFocusLineEdit.h
+ * Date: 23.3.2018
+ *****************************************************************************/
+
+#ifndef AUTOFOCUSLINEEDIT_H
+#define AUTOFOCUSLINEEDIT_H
+
+#include <QLineEdit>
+
+namespace grndr
+{
+	class AutoFocusLineEdit : public QLineEdit
+	{
+		Q_OBJECT
+
+	public:
+		using QLineEdit::QLineEdit;
+
+	protected:
+		virtual void focusInEvent(QFocusEvent* event) override;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/widget/ColorWidget.cpp b/Grinder/ui/widget/ColorWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..9ae76c4aa3c30493231db0e22d5cf497bd490879
--- /dev/null
+++ b/Grinder/ui/widget/ColorWidget.cpp
@@ -0,0 +1,120 @@
+/******************************************************************************
+ * File: ColorWidget.cpp
+ * Date: 24.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ColorWidget.h"
+
+ColorWidget::ColorWidget(QWidget* parent) : QWidget(parent),
+	_color{255, 255, 255}
+{
+	setFocusPolicy(Qt::NoFocus);
+	updateWidget();
+}
+
+ColorWidget::ColorWidget(Flags flags, QWidget* parent) : ColorWidget(parent)
+{
+	_flags = flags;
+}
+
+QSize ColorWidget::sizeHint() const
+{
+	// Hardcoded size hints
+	if (_flags.testFlag(Flag::SmallBox))
+		return QSize{22, 15};
+	else
+		return QSize{50, 35};
+}
+
+void ColorWidget::paintEvent(QPaintEvent* event)
+{
+	Q_UNUSED(event);
+
+	QRect rc = rect() + QMargins{0, 0, -1, -1};
+	QPainter painter{this};
+	painter.setRenderHints(QPainter::Antialiasing, false);
+
+	// Draw the background
+	QPen pen{Qt::black, 1};
+	painter.setPen(pen);
+
+	painter.setBrush(Qt::white);
+	painter.drawRect(rc);
+
+	// Draw the current color(s)
+	rc -= QMargins{2, 2, 1, 1};
+	painter.fillRect(rc, _color);
+
+	if (_showColorComp)
+	{
+		rc.setLeft(rc.left() + std::ceil(rc.width() / 2.0));
+		painter.fillRect(rc, _colorComp);
+	}
+}
+
+void ColorWidget::mouseReleaseEvent(QMouseEvent* event)
+{
+	if (event->button() == Qt::LeftButton && event->modifiers() == 0)
+	{
+		emit colorClicked(_color);
+
+		if (!_flags.testFlag(Flag::SelectColorOnDoubleClick))
+		{
+			selectColor();
+			return;
+		}
+	}
+
+	QWidget::mouseReleaseEvent(event);
+}
+
+void ColorWidget::mouseDoubleClickEvent(QMouseEvent* event)
+{
+	if (event->button() == Qt::LeftButton && event->modifiers() == 0)
+	{
+		if (_flags.testFlag(Flag::SelectColorOnDoubleClick))
+		{
+			selectColor();
+			return;
+		}
+	}
+
+	QWidget::mouseDoubleClickEvent(event);
+}
+
+void ColorWidget::selectColor()
+{
+	bool prevShowColorComp = _showColorComp;
+	QColor prevColorComp = _colorComp;
+
+	setColorComp(true, _color);
+
+	QColorDialog colorDlg{_color};
+	colorDlg.setOptions(QColorDialog::DontUseNativeDialog);
+	connect(&colorDlg, SIGNAL(currentColorChanged(const QColor&)), this, SLOT(colorDialogColorChanged(const QColor&)));
+
+	if (colorDlg.exec() == QDialog::Accepted)
+	{
+		setColor(colorDlg.currentColor());
+		emit colorChanged(_color, true);
+	}
+
+	setColorComp(prevShowColorComp, prevColorComp);
+}
+
+void ColorWidget::updateWidget()
+{
+	QString toolTip = QString{"R: %1; G: %2; B: %3<br>%4"}.arg(_color.red()).arg(_color.green()).arg(_color.blue()).arg(_color.name());
+
+	if (!_extraToolTip.isEmpty())
+		toolTip.insert(0, QString{"%1<br>"}.arg(_extraToolTip));
+
+	setToolTip(toolTip);
+	update();
+}
+
+void ColorWidget::colorDialogColorChanged(const QColor& color)
+{
+	setColorComp(true, color);
+}
diff --git a/Grinder/ui/widget/ColorWidget.h b/Grinder/ui/widget/ColorWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..b2eed7f8bda73ee4a282640e20408dc13a8f284f
--- /dev/null
+++ b/Grinder/ui/widget/ColorWidget.h
@@ -0,0 +1,74 @@
+/******************************************************************************
+ * File: ColorWidget.h
+ * Date: 24.3.2018
+ *****************************************************************************/
+
+#ifndef COLORWIDGET_H
+#define COLORWIDGET_H
+
+#include <QWidget>
+
+namespace grndr
+{
+	class ColorWidget : public QWidget
+	{
+		Q_OBJECT
+
+	public:
+		enum class Flag
+		{
+			NoFlag = 0x0000,
+			SmallBox = 0x0001,
+			SelectColorOnDoubleClick = 0x0002,
+		};
+
+		Q_DECLARE_FLAGS(Flags, Flag)
+
+	public:
+		ColorWidget(QWidget* parent = nullptr);
+		ColorWidget(Flags flags, QWidget* parent = nullptr);
+
+	public:
+		QColor getColor() const { return _color; }
+		void setColor(QColor color) { _color = color; updateWidget(); emit colorChanged(color, false); }
+		std::pair<bool, QColor> getColorComp() const { return std::make_pair(_showColorComp, _colorComp); }
+		void setColorComp(bool show, QColor color = QColor{}) { _showColorComp = show; _colorComp = color; updateWidget(); }
+
+		QString getExtraToolTip() const { return _extraToolTip; }
+		void setExtraToolTip(QString toolTip) { _extraToolTip = toolTip; updateWidget(); }
+
+	public:
+		virtual QSize sizeHint() const override;
+
+	signals:
+		void colorChanged(QColor, bool);
+		void colorClicked(QColor);
+
+	protected:
+		virtual void paintEvent(QPaintEvent* event) override;
+		virtual void mouseReleaseEvent(QMouseEvent* event) override;
+		virtual void mouseDoubleClickEvent(QMouseEvent* event) override;
+
+	private:
+		void selectColor();
+
+		void updateWidget();
+
+	private slots:
+		void colorDialogColorChanged(const QColor& color);
+
+	private:
+		Flags _flags{Flag::NoFlag};
+
+		QColor _color;
+
+		bool _showColorComp{false};
+		QColor _colorComp;
+
+		QString _extraToolTip{""};
+	};
+}
+
+Q_DECLARE_OPERATORS_FOR_FLAGS(grndr::ColorWidget::Flags)
+
+#endif
diff --git a/Grinder/ui/widget/ControlBar.cpp b/Grinder/ui/widget/ControlBar.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..16ce3dfa9a5178261996300a2ca4063c18951b57
--- /dev/null
+++ b/Grinder/ui/widget/ControlBar.cpp
@@ -0,0 +1,58 @@
+/******************************************************************************
+ * File: ControlBar.cpp
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ControlBar.h"
+#include "ui/StyleSheet.h"
+#include "res/Resources.h"
+
+ControlBar::ControlBar(QWidget *parent) : QFrame(parent),
+	_layout{new QHBoxLayout{this}}
+{
+	_layout->setContentsMargins(2, 2, 2, 2);
+	_layout->addStretch();
+
+	setStyleSheet(StyleSheet::loadStyleSheet(FILE_STYLESHEET_CONTROLBAR));
+}
+
+void ControlBar::addWidget(QWidget* widget, Qt::Alignment alignment)
+{
+	if (alignment == 0)
+		alignment = _defaultAlignment;
+
+	if (alignment == Qt::AlignRight)
+		_layout->addWidget(widget);
+	else if (alignment == Qt::AlignLeft)
+		_layout->insertWidget(_layout->count() - 1, widget);
+
+	setMinimumSize(0, sizeHint().height());
+}
+
+void ControlBar::addAction(QAction* action, Qt::ToolButtonStyle buttonStyle, Qt::Alignment alignment)
+{
+	if (buttonStyle == Qt::ToolButtonFollowStyle)
+		buttonStyle = _defaultToolButtonStyle;
+
+	if (action->data().isValid())	// Actions can specify a tool button style in their data field
+		buttonStyle = static_cast<Qt::ToolButtonStyle>(action->data().toInt());
+
+	auto toolButton = new QToolButton{};
+
+	toolButton->setDefaultAction(action);
+	toolButton->setToolButtonStyle(buttonStyle);
+	toolButton->setAutoRaise(true);
+
+	addWidget(toolButton, alignment);
+}
+
+void ControlBar::addSeparator(Qt::Alignment alignment)
+{
+	auto line = new QFrame{};
+
+	line->setFrameShape(QFrame::VLine);
+	line->setFrameShadow(QFrame::Plain);
+
+	addWidget(line, alignment);
+}
diff --git a/Grinder/ui/widget/ControlBar.h b/Grinder/ui/widget/ControlBar.h
new file mode 100644
index 0000000000000000000000000000000000000000..ea161a4d1cc9a0045d594025b1a0f47cf37eef5b
--- /dev/null
+++ b/Grinder/ui/widget/ControlBar.h
@@ -0,0 +1,38 @@
+/******************************************************************************
+ * File: ControlBar.h
+ * Date: 07.2.2018
+ *****************************************************************************/
+
+#ifndef CONTROLBAR_H
+#define CONTROLBAR_H
+
+#include <QFrame>
+#include <QHBoxLayout>
+
+namespace grndr
+{
+	class ControlBar : public QFrame
+	{
+		Q_OBJECT
+
+	public:
+		ControlBar(QWidget *parent = nullptr);
+
+	public:
+		void addWidget(QWidget* widget, Qt::Alignment alignment = 0);
+		void addAction(QAction* action, Qt::ToolButtonStyle buttonStyle = Qt::ToolButtonFollowStyle, Qt::Alignment alignment = 0);
+		void addSeparator(Qt::Alignment alignment = 0);
+
+	public:
+		void setDefaultAlignment(Qt::Alignment alignment) { _defaultAlignment = alignment; }
+		void setDefaultToolButtonStyle(Qt::ToolButtonStyle style) { _defaultToolButtonStyle = style; }
+
+	private:
+		QHBoxLayout* _layout{nullptr};
+
+		Qt::Alignment _defaultAlignment{Qt::AlignLeft};
+		Qt::ToolButtonStyle _defaultToolButtonStyle{Qt::ToolButtonTextBesideIcon};
+	};
+}
+
+#endif
diff --git a/Grinder/ui/widget/GrinderDockWidget.cpp b/Grinder/ui/widget/GrinderDockWidget.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e0832512e8e9c4eb6fb2a8917d1e19c96b781d7a
--- /dev/null
+++ b/Grinder/ui/widget/GrinderDockWidget.cpp
@@ -0,0 +1,15 @@
+/******************************************************************************
+ * File: GrinderDockWidget.cpp
+ * Date: 24.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "GrinderDockWidget.h"
+
+void GrinderDockWidget::closeEvent(QCloseEvent* event)
+{
+	if (!features().testFlag(QDockWidget::DockWidgetClosable))
+		event->setAccepted(!event->spontaneous());
+	else
+		QDockWidget::closeEvent(event);
+}
diff --git a/Grinder/ui/widget/GrinderDockWidget.h b/Grinder/ui/widget/GrinderDockWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..1a1653ee4e17638f88ab32e01271853e0aba202b
--- /dev/null
+++ b/Grinder/ui/widget/GrinderDockWidget.h
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: GrinderDockWidget.h
+ * Date: 24.3.2018
+ *****************************************************************************/
+
+#ifndef GRINDERDOCKWIDGET_H
+#define GRINDERDOCKWIDGET_H
+
+#include <QDockWidget>
+
+namespace grndr
+{
+	class GrinderDockWidget : public QDockWidget
+	{
+		Q_OBJECT
+
+	public:
+		using QDockWidget::QDockWidget;
+
+	protected:
+		virtual void closeEvent(QCloseEvent* event) override;
+	};
+}
+
+#endif
diff --git a/Grinder/ui/widget/MetaWidget.h b/Grinder/ui/widget/MetaWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..305618b0f47f5faa129e1976092e6883fd317ef0
--- /dev/null
+++ b/Grinder/ui/widget/MetaWidget.h
@@ -0,0 +1,50 @@
+/******************************************************************************
+ * File: MetaWidget.h
+ * Date: 08.2.2018
+ *****************************************************************************/
+
+#ifndef METAWIDGET_H
+#define METAWIDGET_H
+
+#include <QMenu>
+#include <memory>
+
+namespace grndr
+{
+	template<typename Base>
+	class MetaWidget : public Base
+	{
+	public:
+		using widget_type = MetaWidget<Base>;
+		using base_type = Base;
+
+		using Base::Base;
+
+	public:
+		enum class AddActionsMode
+		{
+			MainMenu,
+			ContextMenu,
+			Toolbar,
+		};
+
+	public:
+		template<typename T>
+		int setupActions(T* widget, AddActionsMode mode) const;
+
+		template<typename MenuType, typename BarType>
+		void setupUi(MenuType* menu, BarType* toolbar) const;
+
+	protected:
+		virtual void contextMenuEvent(QContextMenuEvent*event) override;
+
+	protected:
+		virtual std::unique_ptr<QMenu> createContextMenu() const;
+
+		virtual std::vector<QAction*> getActions(AddActionsMode mode) const { Q_UNUSED(mode); return {}; }
+	};
+}
+
+#include "MetaWidget.impl.h"
+
+#endif
diff --git a/Grinder/ui/widget/MetaWidget.impl.h b/Grinder/ui/widget/MetaWidget.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..d4d462a7437eaf430572f5e5f394db4d8dd2dfcf
--- /dev/null
+++ b/Grinder/ui/widget/MetaWidget.impl.h
@@ -0,0 +1,56 @@
+/******************************************************************************
+ * File: MetaWidget.impl.h
+ * Date: 08.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "MetaWidget.h"
+
+template<typename Base>
+template<typename T>
+int MetaWidget<Base>::setupActions(T* widget, AddActionsMode mode) const
+{
+	auto actions = getActions(mode);
+
+	for (auto action : actions)
+	{
+		if (action)
+			widget->addAction(action);
+		else
+			widget->addSeparator();
+	}
+
+	return actions.size();
+}
+
+template<typename Base>
+template<typename MenuType, typename BarType>
+void MetaWidget<Base>::setupUi(MenuType* menu, BarType* toolbar) const
+{
+	// Add actions to the provided widgets
+	if (menu)
+		setupActions(menu, AddActionsMode::MainMenu);
+
+	if (toolbar)
+		setupActions(toolbar, AddActionsMode::Toolbar);
+}
+
+template<typename Base>
+std::unique_ptr<QMenu> MetaWidget<Base>::createContextMenu() const
+{
+	auto menu = std::make_unique<QMenu>();
+	setupActions(menu.get(), AddActionsMode::ContextMenu);
+	return menu;
+}
+
+template<typename Base>
+void MetaWidget<Base>::contextMenuEvent(QContextMenuEvent* event)
+{
+	auto menu = createContextMenu();
+
+	if (menu)
+	{
+		menu->exec(event->globalPos());
+		event->accept();
+	}
+}
diff --git a/Grinder/ui/widget/ObjectListItem.h b/Grinder/ui/widget/ObjectListItem.h
new file mode 100644
index 0000000000000000000000000000000000000000..234e13a75e39841f3a06a01415d0724ca78ac985
--- /dev/null
+++ b/Grinder/ui/widget/ObjectListItem.h
@@ -0,0 +1,45 @@
+/******************************************************************************
+ * File: ObjectListItem.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef OBJECTLISTITEM_H
+#define OBJECTLISTITEM_H
+
+#include <QListWidgetItem>
+
+namespace grndr
+{
+	template<typename ObjectType>
+	class ObjectListItem : public QListWidgetItem
+	{
+	public:
+		using base_type = ObjectListItem<ObjectType>;
+
+	public:
+		ObjectListItem(ObjectType* object);
+
+	public:
+		virtual void updateItem();
+
+	public:
+		ObjectType* object() { return _object; }
+		const ObjectType* object() const { return _object; }
+
+		bool isActive() const { return _isActive; }
+		void setActive(bool active) { _isActive = active; updateItem(); }
+
+	protected:
+		ObjectType* _object{nullptr};
+
+		bool _isActive{false};
+
+	private:
+		QFont _regularFont;
+		QFont _activeFont;
+	};
+}
+
+#include "ObjectListItem.impl.h"
+
+#endif
diff --git a/Grinder/ui/widget/ObjectListItem.impl.h b/Grinder/ui/widget/ObjectListItem.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..d8c3931b153a2247608ba9c22ec2d1af42e27d53
--- /dev/null
+++ b/Grinder/ui/widget/ObjectListItem.impl.h
@@ -0,0 +1,26 @@
+/******************************************************************************
+ * File: ObjectListItem.impl.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ObjectListItem.h"
+
+template<typename ObjectType>
+ObjectListItem<ObjectType>::ObjectListItem(ObjectType* object) :
+	_object{object}
+{
+	if (!object)
+		throw std::invalid_argument{_EXCPT("object may not be null")};
+
+	_activeFont.setBold(true);
+}
+
+template<typename ObjectType>
+void ObjectListItem<ObjectType>::updateItem()
+{
+	if (_isActive)
+		setFont(_activeFont);
+	else
+		setFont(_regularFont);
+}
diff --git a/Grinder/ui/widget/ObjectListWidget.h b/Grinder/ui/widget/ObjectListWidget.h
new file mode 100644
index 0000000000000000000000000000000000000000..df8fc755f8fd637ff5e94324618b32cceed37638
--- /dev/null
+++ b/Grinder/ui/widget/ObjectListWidget.h
@@ -0,0 +1,56 @@
+/******************************************************************************
+ * File: ObjectListWidget.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef OBJECTLISTWIDGET_H
+#define OBJECTLISTWIDGET_H
+
+#include <QListWidget>
+
+#include "ObjectListItem.h"
+
+namespace grndr
+{
+	class ControlBar;
+	class Label;
+
+	template<typename ObjectType, typename ItemType>
+	class ObjectListWidget : public QListWidget
+	{
+		static_assert(std::is_base_of<ObjectListItem<ObjectType>, ItemType>::value, "ItemType must be derived from ObjectListItem<ObjectType>");
+
+	public:
+		using object_type = ObjectType;
+		using item_type = ItemType;
+
+	public:
+		using QListWidget::QListWidget;
+
+	public:		
+		item_type* addObject(object_type* obj, bool autoSelect = true, bool insertAtFront = false);
+		void removeObject(const object_type* obj, bool autoSelect = true);
+		object_type* currentObject() const;
+
+		template<typename ContainerType>
+		void populateList(const ContainerType& container, bool selectFirst = true, bool reverseOrder = false);
+
+	protected:
+		virtual void mouseDoubleClickEvent(QMouseEvent* event) override;
+
+	protected:
+		virtual void switchToObjectItem(item_type* item, bool selectItem = true) { Q_UNUSED(item); Q_UNUSED(selectItem); }
+		virtual void objectSwitched(object_type* obj);
+
+		item_type* objectItem(int index) const;
+		item_type* currentObjectItem() const { return objectItem(currentRow()); }
+		item_type* activeObjectItem() const;
+
+		item_type* findObjectItem(const object_type* obj) const;
+		int findObjectItemIndex(const item_type* item);
+	};
+}
+
+#include "ObjectListWidget.impl.h"
+
+#endif
diff --git a/Grinder/ui/widget/ObjectListWidget.impl.h b/Grinder/ui/widget/ObjectListWidget.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..efdafd38ce4d283a34f2a9ba1c540cb066870d01
--- /dev/null
+++ b/Grinder/ui/widget/ObjectListWidget.impl.h
@@ -0,0 +1,141 @@
+/******************************************************************************
+ * File: ObjectListWidget.impl.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ObjectListWidget.h"
+
+template<typename ObjectType, typename ItemType>
+ItemType* ObjectListWidget<ObjectType, ItemType>::addObject(object_type* obj, bool autoSelect, bool insertAtFront)
+{
+	auto item = new ItemType{obj};
+
+	if (insertAtFront)
+		insertItem(0, item);
+	else
+		addItem(item);
+
+	if (autoSelect)
+		setCurrentItem(item);
+
+	return item;
+}
+
+template<typename ObjectType, typename ItemType>
+void ObjectListWidget<ObjectType, ItemType>::removeObject(const object_type* obj, bool autoSelect)
+{
+	int curRow = currentRow();
+
+	if (auto item = findObjectItem(obj))
+	{
+		auto taken = takeItem(indexFromItem(item).row());
+		delete taken;
+
+		if (autoSelect)
+			setCurrentRow(std::min(curRow, count() - 1));
+	}
+}
+
+template<typename ObjectType, typename ItemType>
+ObjectType* ObjectListWidget<ObjectType, ItemType>::currentObject() const
+{
+	auto item = currentObjectItem();
+
+	if (item)
+		return item->object();
+	else
+		return nullptr;
+}
+
+template<typename ObjectType, typename ItemType>
+template<typename ContainerType>
+void ObjectListWidget<ObjectType, ItemType>::populateList(const ContainerType& container, bool selectFirst, bool reverseOrder)
+{
+	clear();
+
+	bool firstItem = true;
+
+	for (const auto& item : container)
+	{
+		addObject(item.get(), firstItem, reverseOrder);
+		firstItem = false;
+	}
+
+	if (!firstItem && selectFirst)	// There's at least one item, so select the first one
+		switchToObjectItem(objectItem(0));
+}
+
+template<typename ObjectType, typename ItemType>
+void ObjectListWidget<ObjectType, ItemType>::mouseDoubleClickEvent(QMouseEvent* event)
+{
+	auto item = dynamic_cast<item_type*>(itemAt(event->pos()));
+
+	if (item)
+		switchToObjectItem(item);
+	else
+		QListWidget::mouseDoubleClickEvent(event);
+}
+
+template<typename ObjectType, typename ItemType>
+void ObjectListWidget<ObjectType, ItemType>::objectSwitched(object_type* obj)
+{
+	for (int i = 0; i < count(); ++i)
+	{
+		if (auto item = objectItem(i))
+			item->setActive(item->object() == obj);
+	}
+
+	update();
+}
+
+template<typename ObjectType, typename ItemType>
+ItemType* ObjectListWidget<ObjectType, ItemType>::objectItem(int index) const
+{
+	if (index < 0 || index >= count())
+		return nullptr;
+
+	return dynamic_cast<item_type*>(item(index));
+}
+
+template<typename ObjectType, typename ItemType>
+ItemType* ObjectListWidget<ObjectType, ItemType>::activeObjectItem() const
+{
+	for (int i = 0; i < count(); ++i)
+	{
+		if (auto item = objectItem(i))
+		{
+			if (item->isActive())
+				return item;
+		}
+	}
+
+	return nullptr;
+}
+
+template<typename ObjectType, typename ItemType>
+ItemType* ObjectListWidget<ObjectType, ItemType>::findObjectItem(const object_type* obj) const
+{
+	for (int i = 0; i < count(); ++i)
+	{
+		if (auto item = objectItem(i))
+		{
+			if (item->object() == obj)
+				return item;
+		}
+	}
+
+	return nullptr;
+}
+
+template<typename ObjectType, typename ItemType>
+int ObjectListWidget<ObjectType, ItemType>::findObjectItemIndex(const item_type* item)
+{
+	for (int i = 0; i < count(); ++i)
+	{
+		if (objectItem(i) == item)
+			return i;
+	}
+
+	return -1;
+}
diff --git a/Grinder/util/CVUtils.cpp b/Grinder/util/CVUtils.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f38bb6738f115149cc4da45b21aa54790ffdfc57
--- /dev/null
+++ b/Grinder/util/CVUtils.cpp
@@ -0,0 +1,40 @@
+/******************************************************************************
+ * File: CVUtils.cpp
+ * Date: 19.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "CVUtils.h"
+#include "image/ImageExceptions.h"
+
+QImage CVUtils::matrixToImage(const cv::Mat& matrix)
+{
+	cv::Mat imageMatrix = matrix;
+
+	// Check if the matrix has an unsupported type; if so, convert it first
+	if (matrix.type() != CV_8UC4 && matrix.type() != CV_8UC3 && matrix.type() != CV_8UC1)
+	{
+		matrix.convertTo(imageMatrix, CV_8U);
+		cv::normalize(imageMatrix, imageMatrix, std::numeric_limits<unsigned char>::max(), std::numeric_limits<unsigned char>::min(), cv::NORM_MINMAX);
+	}
+
+	switch (imageMatrix.type())
+	{
+	case CV_8UC4:
+		return QImage(imageMatrix.data, imageMatrix.cols, imageMatrix.rows, static_cast<int>(imageMatrix.step), QImage::Format_ARGB32);
+
+	case CV_8UC3:
+		return QImage(imageMatrix.data, imageMatrix.cols, imageMatrix.rows, static_cast<int>(imageMatrix.step), QImage::Format_RGB888).rgbSwapped();
+
+	case CV_8UC1:
+		return QImage(imageMatrix.data, imageMatrix.cols, imageMatrix.rows, static_cast<int>(imageMatrix.step), QImage::Format_Grayscale8);
+
+	default:
+		throw ImageException{_EXCPT("Unsupported matrix format for conversion to an image")};
+	}
+}
+
+QPixmap CVUtils::matrixToPixmap(const cv::Mat& matrix)
+{
+	return QPixmap::fromImage(matrixToImage(matrix));
+}
diff --git a/Grinder/util/CVUtils.h b/Grinder/util/CVUtils.h
new file mode 100644
index 0000000000000000000000000000000000000000..575fc9b850858c83e4b3ce30e56be7cdb4491802
--- /dev/null
+++ b/Grinder/util/CVUtils.h
@@ -0,0 +1,34 @@
+/******************************************************************************
+ * File: CVUtils.h
+ * Date: 19.2.2018
+ *****************************************************************************/
+
+#ifndef CVUTILS_H
+#define CVUTILS_H
+
+#include <QString>
+#include <QImage>
+#include <functional>
+#include <opencv2/core.hpp>
+
+namespace grndr
+{
+	class CVUtils final
+	{
+	public:
+		static QImage matrixToImage(const cv::Mat& matrix);
+		static QPixmap matrixToPixmap(const cv::Mat& matrix);
+
+		template<typename DataType>
+		static QString matrixToString(const cv::Mat& matrix);
+		template<typename DataType>
+		static QString matrixToString(const cv::Mat& matrix, std::function<QString(const DataType&)> formatter);
+
+	private:
+		CVUtils() { }
+	};
+}
+
+#include "CVUtils.impl.h"
+
+#endif
diff --git a/Grinder/util/CVUtils.impl.h b/Grinder/util/CVUtils.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..5b409e341fdc4135865998a754c3749f8e7b2f07
--- /dev/null
+++ b/Grinder/util/CVUtils.impl.h
@@ -0,0 +1,36 @@
+/******************************************************************************
+ * File: CVUtils.impl.h
+ * Date: 19.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "CVUtils.h"
+
+template<typename DataType>
+QString CVUtils::matrixToString(const cv::Mat& matrix)
+{
+	// Default formatter: Use QString's arg function
+	return matrixToString(matrix, [](const DataType& value) { return QString{"%1"}.arg(value); });
+}
+
+template<typename DataType>
+QString CVUtils::matrixToString(const cv::Mat& matrix, std::function<QString(const DataType&)> formatter)
+{
+	QStringList entries;
+	QString stringRep;
+
+	for (int r = 0; r < matrix.rows; ++r)
+	{
+		entries.clear();
+
+		for (int c = 0; c < matrix.cols; ++c)
+			entries << formatter(matrix.at<DataType>(r, c));
+
+		if (r != 0)
+			stringRep += "\n";
+
+		stringRep += entries.join(", ");
+	}
+
+	return stringRep;
+}
diff --git a/Grinder/util/DataUtils.cpp b/Grinder/util/DataUtils.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f2d53d3a8cc5473afa14186dc44f3632b0e106c0
--- /dev/null
+++ b/Grinder/util/DataUtils.cpp
@@ -0,0 +1,97 @@
+/******************************************************************************
+ * File: DataUtils.cpp
+ * Date: 16.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "DataUtils.h"
+
+QString DataUtils::getDataDescriptorTypeName(DataDescriptor::StructureType type)
+{
+	switch (type)
+	{
+	case DataDescriptor::StructureType::Any:
+		return "Arbitrary";
+
+	case DataDescriptor::StructureType::Adaptive:
+		return "Adaptive";
+
+	case DataDescriptor::StructureType::Scalar:
+		return "Scalar";
+
+	case DataDescriptor::StructureType::Vector:
+		return "Vector";
+
+	case DataDescriptor::StructureType::Matrix:
+		return "Matrix";
+
+	default:
+		return "Unknown";
+	}
+}
+
+QString DataUtils::getDataDescriptorTypeName(DataDescriptor::FieldType type)
+{
+	switch (type)
+	{
+	case DataDescriptor::FieldType::Any:
+		return "Arbitrary";
+
+	case DataDescriptor::FieldType::Adaptive:
+		return "Adaptive";
+
+	case DataDescriptor::FieldType::Basic:
+		return "Basic";
+
+	case DataDescriptor::FieldType::Color:
+		return "Colors";
+
+	case DataDescriptor::FieldType::Point2D:
+		return "2D points";
+
+	case DataDescriptor::FieldType::Point3D:
+		return "3D points";
+
+	default:
+		return "Unknown";
+	}
+}
+
+QString DataUtils::getDataDescriptorTypeName(DataDescriptor::ValueType type)
+{
+	switch (type)
+	{
+	case DataDescriptor::ValueType::Any:
+		return "Arbitrary";
+
+	case DataDescriptor::ValueType::Adaptive:
+		return "Adaptive";
+
+	case DataDescriptor::ValueType::Int8:
+		return "Integer (signed, 8 bit)";
+
+	case DataDescriptor::ValueType::UInt8:
+		return "Integer (unsigned, 8 bit)";
+
+	case DataDescriptor::ValueType::Int16:
+		return "Integer (signed, 16 bit)";
+
+	case DataDescriptor::ValueType::UInt16:
+		return "Integer (unsigned, 16 bit)";
+
+	case DataDescriptor::ValueType::Int32:
+		return "Integer (signed, 32 bit)";
+
+	case DataDescriptor::ValueType::UInt32:
+		return "Integer (unsigned, 32 bit)";
+
+	case DataDescriptor::ValueType::Float:
+		return "Real (single precision)";
+
+	case DataDescriptor::ValueType::Double:
+		return "Real (double precision)";
+
+	default:
+		return "Unknown";
+	}
+}
diff --git a/Grinder/util/DataUtils.h b/Grinder/util/DataUtils.h
new file mode 100644
index 0000000000000000000000000000000000000000..0f78a81ea5b7a867019ac841f0b049417f9ff2db
--- /dev/null
+++ b/Grinder/util/DataUtils.h
@@ -0,0 +1,25 @@
+/******************************************************************************
+ * File: DataUtils.h
+ * Date: 16.2.2018
+ *****************************************************************************/
+
+#ifndef DATAUTILS_H
+#define DATAUTILS_H
+
+#include "engine/data/DataDescriptor.h"
+
+namespace grndr
+{
+	class DataUtils final
+	{
+	public:
+		static QString getDataDescriptorTypeName(DataDescriptor::StructureType type);
+		static QString getDataDescriptorTypeName(DataDescriptor::FieldType type);
+		static QString getDataDescriptorTypeName(DataDescriptor::ValueType type);
+
+	private:
+		DataUtils() { }
+	};
+}
+
+#endif
diff --git a/Grinder/util/FileUtils.cpp b/Grinder/util/FileUtils.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..765cbc05dfb86c92b22a9f09f37a523ca143c8c7
--- /dev/null
+++ b/Grinder/util/FileUtils.cpp
@@ -0,0 +1,34 @@
+/******************************************************************************
+ * File: FileUtils.cpp
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "FileUtils.h"
+
+QStringList FileUtils::expandFileList(QStringList paths, QStringList filters)
+{
+	QStringList files;
+
+	for (auto path : paths)
+	{
+		QFileInfo fileInfo{path};
+
+		if (fileInfo.isDir())
+		{
+			QDirIterator dirIt{fileInfo.filePath(), filters, QDir::NoFilter, QDirIterator::Subdirectories};
+
+			while (dirIt.hasNext())
+			{
+				auto file = dirIt.next();
+
+				if (dirIt.fileInfo().isFile())
+					files << file;
+			}
+		}
+		else
+			files << path;
+	}
+
+	return files;
+}
diff --git a/Grinder/util/FileUtils.h b/Grinder/util/FileUtils.h
new file mode 100644
index 0000000000000000000000000000000000000000..e9de78229e2a52e8763e90cc279b2618e396d5ec
--- /dev/null
+++ b/Grinder/util/FileUtils.h
@@ -0,0 +1,23 @@
+/******************************************************************************
+ * File: FileUtils.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef FILEUTILS_H
+#define FILEUTILS_H
+
+#include <QStringList>
+
+namespace grndr
+{
+	class FileUtils final
+	{
+	public:
+		static QStringList expandFileList(QStringList paths, QStringList filters = QStringList{});
+
+	private:
+		FileUtils();
+	};
+}
+
+#endif
diff --git a/Grinder/util/ImageUtils.cpp b/Grinder/util/ImageUtils.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..3366c39109b55abb4ad470ee9927c3b2cb7885f8
--- /dev/null
+++ b/Grinder/util/ImageUtils.cpp
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: ImageUtils.cpp
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "ImageUtils.h"
+
+QStringList ImageUtils::getSupportedFormats()
+{
+	// Return a list of formats supported by OpenCV
+	return {"bmp", "dib", "jpeg", "jpg", "jpe", "jp2", "png", "webp", "pbm", "pgm", "ppm", "pxm", "pnm", "sr", "ras", "tiff", "tif", "exr", "hdr", "pic"};
+}
+
+QStringList ImageUtils::getSupportedFormatFilters()
+{
+	auto formats = getSupportedFormats();
+	QStringList filters;
+
+	for (auto format : formats)
+		filters << "*." + format;
+
+	return filters;
+}
diff --git a/Grinder/util/ImageUtils.h b/Grinder/util/ImageUtils.h
new file mode 100644
index 0000000000000000000000000000000000000000..3268df5771d608a70412c0c981a15f2db48dbf0b
--- /dev/null
+++ b/Grinder/util/ImageUtils.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: ImageUtils.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef IMAGEUTILS_H
+#define IMAGEUTILS_H
+
+#include <QStringList>
+
+namespace grndr
+{
+	class ImageUtils final
+	{
+	public:
+		static QStringList getSupportedFormats();
+		static QStringList getSupportedFormatFilters();
+
+	private:
+		ImageUtils();
+	};
+}
+
+#endif
diff --git a/Grinder/util/MathUtils.cpp b/Grinder/util/MathUtils.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..652c90f69d169710294ec48de985670e569d871e
--- /dev/null
+++ b/Grinder/util/MathUtils.cpp
@@ -0,0 +1,18 @@
+/******************************************************************************
+ * File: MathUtils.cpp
+ * Date: 27.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "MathUtils.h"
+
+double MathUtils::vectorLength(double x, double y)
+{
+	// Simple L2 norm
+	return std::sqrt(x * x + y * y);
+}
+
+QPoint MathUtils::round(const QPointF& point)
+{
+	return QPoint{std::lround(point.x()), std::lround(point.y())};
+}
diff --git a/Grinder/util/MathUtils.h b/Grinder/util/MathUtils.h
new file mode 100644
index 0000000000000000000000000000000000000000..049fa58b1143584d49898d582dd4101e8adb2caf
--- /dev/null
+++ b/Grinder/util/MathUtils.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: MathUtils.h
+ * Date: 27.3.2018
+ *****************************************************************************/
+
+#ifndef MATHUTILS_H
+#define MATHUTILS_H
+
+#include <QPoint>
+
+namespace grndr
+{
+	class MathUtils final
+	{
+	public:
+		static double vectorLength(double x, double y);
+		static QPoint round(const QPointF& point);
+
+	private:
+		MathUtils() { }
+	};
+}
+
+#endif
diff --git a/Grinder/util/SerializationUtils.cpp b/Grinder/util/SerializationUtils.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..864050eb7225513387f5653543b4209a62b94adc
--- /dev/null
+++ b/Grinder/util/SerializationUtils.cpp
@@ -0,0 +1,7 @@
+/******************************************************************************
+ * File: SerializationUtils.cpp
+ * Date: 01.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SerializationUtils.h"
diff --git a/Grinder/util/SerializationUtils.h b/Grinder/util/SerializationUtils.h
new file mode 100644
index 0000000000000000000000000000000000000000..8190a56f897c44848178e151d96928bbf6db1aee
--- /dev/null
+++ b/Grinder/util/SerializationUtils.h
@@ -0,0 +1,29 @@
+/******************************************************************************
+ * File: SerializationUtils.h
+ * Date: 01.3.2018
+ *****************************************************************************/
+
+#ifndef SERIALIZATIONUTILS_H
+#define SERIALIZATIONUTILS_H
+
+#include "project/serialization/SerializationContext.h"
+#include "project/serialization/DeserializationContext.h"
+
+namespace grndr
+{
+	class SerializationUtils final
+	{
+	public:
+		template<typename ContainerType>
+		static void serializeContainer(const ContainerType& container, QString elemName, SerializationContext& ctx, std::function<bool(const typename ContainerType::value_type&)> predicate = nullptr);
+		template<typename ContainerType>
+		static void deserializeContainer(QString elemName, DeserializationContext& ctx, std::function<typename ContainerType::value_type(const SettingsContainer&)> objCreator);
+
+	private:
+		SerializationUtils() { }
+	};
+}
+
+#include "SerializationUtils.impl.h"
+
+#endif
diff --git a/Grinder/util/SerializationUtils.impl.h b/Grinder/util/SerializationUtils.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..83e9bd488e147441c99e11eaeac8be70924390f4
--- /dev/null
+++ b/Grinder/util/SerializationUtils.impl.h
@@ -0,0 +1,35 @@
+/******************************************************************************
+ * File: SerializationUtils.impl.h
+ * Date: 01.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "SerializationUtils.h"
+
+template<typename ContainerType>
+void SerializationUtils::serializeContainer(const ContainerType& container, QString elemName, SerializationContext& ctx, std::function<bool(const typename ContainerType::value_type&)> predicate)
+{
+	for (const auto& object : container)
+	{
+		if (!predicate || predicate(object))
+		{
+			ctx.beginGroup(elemName);
+			object->serialize(ctx);
+			ctx.endGroup();
+		}
+	}
+}
+
+template<typename ContainerType>
+void SerializationUtils::deserializeContainer(QString elemName, DeserializationContext& ctx, std::function<typename ContainerType::value_type(const SettingsContainer&)> objCreator)
+{
+	for (const auto elemSettings : ctx.settings().children(elemName))
+	{
+		ctx.beginGroup(elemSettings);
+
+		if (auto object = objCreator(*elemSettings))
+			object->deserialize(ctx);
+
+		ctx.endGroup();
+	}
+}
diff --git a/Grinder/util/StringConv.cpp b/Grinder/util/StringConv.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..57e298dadce2d12632acc421b4cf08ef5ace279a
--- /dev/null
+++ b/Grinder/util/StringConv.cpp
@@ -0,0 +1,172 @@
+/******************************************************************************
+ * File: StringConv.cpp
+ * Date: 12.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "StringConv.h"
+
+template<>
+QString StringConv::convertValue<bool>(const bool& val)
+{
+	return val ? "true" : "false";
+}
+
+template<>
+QString StringConv::convertValue<QPoint>(const QPoint& val)
+{
+	return QString{"%1;%2"}.arg(val.x()).arg(val.y());
+}
+
+template<>
+QString StringConv::convertValue<QPointF>(const QPointF& val)
+{
+	return QString{"%1;%2"}.arg(val.x()).arg(val.y());
+}
+
+template<>
+QString StringConv::convertValue<QSize>(const QSize& val)
+{
+	QPoint pt{val.width(), val.height()};
+	return convertValue(pt);
+}
+
+template<>
+QString StringConv::convertValue<QColor>(const QColor& val)
+{
+	return val.name();
+}
+
+template<>
+QString StringConv::convertString<QString>(const QString& str, bool* ok)
+{
+	if (ok)
+		*ok = true;
+
+	return str;
+}
+
+template<>
+short StringConv::convertString<short>(const QString& str, bool* ok)
+{
+	return str.toShort(ok);
+}
+
+template<>
+ushort StringConv::convertString<ushort>(const QString& str, bool* ok)
+{
+	return str.toUShort(ok);
+}
+
+template<>
+int StringConv::convertString<int>(const QString& str, bool* ok)
+{
+	return str.toInt(ok);
+}
+
+template<>
+uint StringConv::convertString<uint>(const QString& str, bool* ok)
+{
+	return str.toUInt(ok);
+}
+
+template<>
+long StringConv::convertString<long>(const QString& str, bool* ok)
+{
+	return str.toLong(ok);
+}
+
+template<>
+ulong StringConv::convertString<ulong>(const QString& str, bool* ok)
+{
+	return str.toULong(ok);
+}
+
+template<>
+qlonglong StringConv::convertString<qlonglong>(const QString& str, bool* ok)
+{
+	return str.toLongLong(ok);
+}
+
+template<>
+qulonglong StringConv::convertString<qulonglong>(const QString& str, bool* ok)
+{
+	return str.toULongLong(ok);
+}
+
+template<>
+float StringConv::convertString<float>(const QString& str, bool* ok)
+{
+	return str.toFloat(ok);
+}
+
+template<>
+double StringConv::convertString<double>(const QString& str, bool* ok)
+{
+	return str.toDouble(ok);
+}
+
+template<>
+bool StringConv::convertString<bool>(const QString& str, bool* ok)
+{
+	if (ok)
+		*ok = true;
+
+	return (str.compare("true", Qt::CaseInsensitive) == 0 || str.compare("yes", Qt::CaseInsensitive) == 0 || str == "1") ? true : false;
+}
+
+template<>
+QPoint StringConv::convertString<QPoint>(const QString& str, bool* ok)
+{
+	auto tokens = str.split(";");
+
+	if (tokens.size() == 2)
+	{
+		bool ok1 = true, ok2 = true;
+		QPoint point{tokens[0].toInt(&ok1), tokens[1].toInt(&ok2)};
+
+		if (ok)
+			*ok = ok1 && ok2;
+
+		return point;
+	}
+	else
+		return QPoint{};
+}
+
+template<>
+QPointF StringConv::convertString<QPointF>(const QString& str, bool* ok)
+{
+	auto tokens = str.split(";");
+
+	if (tokens.size() == 2)
+	{
+		bool ok1 = true, ok2 = true;
+		QPointF point{tokens[0].toDouble(&ok1), tokens[1].toDouble(&ok2)};
+
+		if (ok)
+			*ok = ok1 && ok2;
+
+		return point;
+	}
+	else
+		return QPointF{};
+}
+
+template<>
+QSize StringConv::convertString<QSize>(const QString& str, bool* ok)
+{
+	QPoint pt = convertString<QPoint>(str, ok);
+	return QSize{pt.x(), pt.y()};
+}
+
+template<>
+QColor StringConv::convertString<QColor>(const QString& str, bool* ok)
+{
+	QColor color{str};
+
+	if (ok)
+		*ok = color.isValid();
+
+	return color;
+}
diff --git a/Grinder/util/StringConv.h b/Grinder/util/StringConv.h
new file mode 100644
index 0000000000000000000000000000000000000000..05ca47113d4bd4a21386b4d9b6beba57298b18af
--- /dev/null
+++ b/Grinder/util/StringConv.h
@@ -0,0 +1,56 @@
+/******************************************************************************
+ * File: StringConv.h
+ * Date: 12.1.2018
+ *****************************************************************************/
+
+#ifndef STRINGCONV_H
+#define STRINGCONV_H
+
+#include <QString>
+#include <QPoint>
+#include <QSize>
+#include <QColor>
+
+namespace grndr
+{
+	class StringConv final
+	{
+	public:
+		template<typename ValueType>
+		static QString convertValue(const ValueType& val)
+		{
+			return QString("%1").arg(val);
+		}
+
+		template<typename ValueType>
+		static ValueType convertString(const QString&, bool* ok = nullptr);
+
+	private:
+		StringConv() { }
+	};
+
+	template<> QString StringConv::convertValue<bool>(const bool& val);
+	template<> QString StringConv::convertValue<QPoint>(const QPoint& val);
+	template<> QString StringConv::convertValue<QPointF>(const QPointF& val);
+	template<> QString StringConv::convertValue<QSize>(const QSize& val);
+	template<> QString StringConv::convertValue<QColor>(const QColor& val);
+
+	template<> QString StringConv::convertString<QString>(const QString& str, bool* ok);
+	template<> short StringConv::convertString<short>(const QString& str, bool* ok);
+	template<> ushort StringConv::convertString<ushort>(const QString& str, bool* ok);
+	template<> int StringConv::convertString<int>(const QString& str, bool* ok);
+	template<> uint StringConv::convertString<uint>(const QString& str, bool* ok);
+	template<> long StringConv::convertString<long>(const QString& str, bool* ok);
+	template<> ulong StringConv::convertString<ulong>(const QString& str, bool* ok);
+	template<> qlonglong StringConv::convertString<qlonglong>(const QString& str, bool* ok);
+	template<> qulonglong StringConv::convertString<qulonglong>(const QString& str, bool* ok);
+	template<> float StringConv::convertString<float>(const QString& str, bool* ok);
+	template<> double StringConv::convertString<double>(const QString& str, bool* ok);
+	template<> bool StringConv::convertString<bool>(const QString& str, bool* ok);
+	template<> QPoint StringConv::convertString<QPoint>(const QString& str, bool* ok);
+	template<> QPointF StringConv::convertString<QPointF>(const QString& str, bool* ok);
+	template<> QSize StringConv::convertString<QSize>(const QString& str, bool* ok);
+	template<> QColor StringConv::convertString<QColor>(const QString& str, bool* ok);
+}
+
+#endif
diff --git a/Grinder/util/StringUtils.cpp b/Grinder/util/StringUtils.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f0bb5cae1a07ddf95ad5d5dfe80a6ac89750edea
--- /dev/null
+++ b/Grinder/util/StringUtils.cpp
@@ -0,0 +1,70 @@
+/******************************************************************************
+ * File: StringUtils.cpp
+ * Date: 12.1.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "StringUtils.h"
+
+QString StringUtils::encodeEscapeCharacters(QString str, bool includeQuotes)
+{
+	str.replace("\\", "\\\\");
+	str.replace("\n", "\\n");
+	str.replace("\r", "\\r");
+	str.replace("\t", "\\t");
+
+	if (includeQuotes)
+		str.replace("\"", "\\\"");
+
+	return str;
+}
+
+QString StringUtils::decodeEscapeCharacters(const QString& str, bool includeQuotes)
+{
+	QString	strOut;
+	bool isEscape = false;
+
+	for (QChar c : str)
+	{
+		if (isEscape)
+		{
+			if (c == 'n')
+				c = '\n';
+			else if (c == 'r')
+				c = '\r';
+			else if (c == 't')
+				c = '\t';
+			else if (c == '\\')
+				c = '\\';
+			else if (c == '\"' && includeQuotes)
+				c = '\"';
+
+			strOut += c;
+			isEscape = false;
+		}
+		else
+		{
+			if (c == '\\')
+				isEscape = true;
+			else
+				strOut += c;
+		}
+	}
+
+	return strOut;
+}
+
+QString StringUtils::fileSizeToString(qint64 fileSize)
+{
+	static const QString units[] = {"B", "KB", "MB", "GB"};
+	double size = fileSize;
+	int unitIndex = 0;
+
+	while (size > 1024.0)
+	{
+		size /= 1024.0;
+		unitIndex++;
+	}
+
+	return QString{"%1 %2"}.arg(size, 0, 'f', 2).arg(units[unitIndex]);
+}
diff --git a/Grinder/util/StringUtils.h b/Grinder/util/StringUtils.h
new file mode 100644
index 0000000000000000000000000000000000000000..8e28ce4a48018bfc7ec16b1f7fc1bbb946b7e440
--- /dev/null
+++ b/Grinder/util/StringUtils.h
@@ -0,0 +1,32 @@
+/******************************************************************************
+ * File: StringUtils.h
+ * Date: 12.1.2018
+ *****************************************************************************/
+
+#ifndef STRINGUTILS_H
+#define STRINGUTILS_H
+
+#include <QString>
+#include <functional>
+
+namespace grndr
+{
+	class StringUtils final
+	{
+	public:
+		static QString encodeEscapeCharacters(QString str, bool includeQuotes = true);
+		static QString decodeEscapeCharacters(const QString& str, bool includeQuotes = true);
+
+		static QString fileSizeToString(qint64 fileSize);
+
+		template<typename ContainerType>
+		static QString generateUniqueItemName(const ContainerType& container, QString name, std::function<QString(const typename ContainerType::value_type&)> nameGetter, bool caseSensitive = false, bool addSpaces = true);
+
+	private:
+		StringUtils() { }
+	};	
+}
+
+#include "StringUtils.impl.h"
+
+#endif
diff --git a/Grinder/util/StringUtils.impl.h b/Grinder/util/StringUtils.impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..19c50973a54af398b1af8d5c21eef31fe8c2c2ff
--- /dev/null
+++ b/Grinder/util/StringUtils.impl.h
@@ -0,0 +1,54 @@
+/******************************************************************************
+ * File: StringUtils.impl.h
+ * Date: 02.3.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "StringUtils.h"
+
+template<typename ContainerType>
+QString StringUtils::generateUniqueItemName(const ContainerType& container, QString name, std::function<QString(const typename ContainerType::value_type&)> nameGetter, bool caseSensitive, bool addSpaces)
+{
+	QString newName;
+
+	// Convert names like ThisIsMyName to This Is My Name
+	if (addSpaces)
+	{
+		bool prevUpper = true;
+
+		for (QChar c : name)
+		{
+			if (c.isUpper())
+			{
+				if (!prevUpper)
+					newName += " ";
+
+				prevUpper = true;
+			}
+
+			newName += c;
+			prevUpper = c.isUpper();
+		}
+	}
+	else
+		newName = name;
+
+	unsigned int index = 1;
+
+	// Add increasing numbers to the name until we've found a unique one
+	while (true)
+	{
+		QString indexedName = QString{"%1 #%2"}.arg(newName).arg(index++);
+
+		if (std::none_of(std::cbegin(container), std::cend(container), [indexedName, caseSensitive, nameGetter](const typename ContainerType::value_type& value) {
+			QString existingName = nameGetter(value);
+			return existingName.compare(indexedName, caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive) == 0;
+		}))
+		{
+			newName = indexedName;
+			break;
+		}
+	}
+
+	return newName;
+}
diff --git a/Grinder/util/TemporaryStatusMessage.cpp b/Grinder/util/TemporaryStatusMessage.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..4c8af3429f6b0760cc5c286ea79ef24f4608ebf9
--- /dev/null
+++ b/Grinder/util/TemporaryStatusMessage.cpp
@@ -0,0 +1,31 @@
+/******************************************************************************
+ * File: TemporaryStatusMessage.cpp
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "TemporaryStatusMessage.h"
+#include "core/GrinderApplication.h"
+
+TemporaryStatusMessage::TemporaryStatusMessage(QString message)
+{
+	setMessage(message);
+}
+
+TemporaryStatusMessage::~TemporaryStatusMessage()
+{
+	if (grinder()->mainWindow())
+	{
+		grinder()->mainWindow()->statusBar()->clearMessage();
+		QApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
+	}
+}
+
+void TemporaryStatusMessage::setMessage(QString message) const
+{
+	if (!message.isEmpty() && grinder()->mainWindow())
+	{
+		grinder()->mainWindow()->statusBar()->showMessage(message);
+		QApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
+	}
+}
diff --git a/Grinder/util/TemporaryStatusMessage.h b/Grinder/util/TemporaryStatusMessage.h
new file mode 100644
index 0000000000000000000000000000000000000000..c17bf07f5de7438fc4f43c523dd54f850944ec51
--- /dev/null
+++ b/Grinder/util/TemporaryStatusMessage.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * File: TemporaryStatusMessage.h
+ * Date: 10.2.2018
+ *****************************************************************************/
+
+#ifndef TEMPORARYSTATUSMESSAGE_H
+#define TEMPORARYSTATUSMESSAGE_H
+
+#include <QString>
+
+namespace grndr
+{
+	class TemporaryStatusMessage
+	{
+	public:
+		TemporaryStatusMessage(QString message = "");
+		~TemporaryStatusMessage();
+
+	public:
+		void setMessage(QString message) const;
+	};
+}
+
+#endif
diff --git a/Grinder/util/UIUtils.cpp b/Grinder/util/UIUtils.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..d6cc7433ab9678b6ff4e70d82dfaf3a003bceb77
--- /dev/null
+++ b/Grinder/util/UIUtils.cpp
@@ -0,0 +1,87 @@
+/******************************************************************************
+ * File: UIUtils.cpp
+ * Date: 02.2.2018
+ *****************************************************************************/
+
+#include "Grinder.h"
+#include "UIUtils.h"
+#include "core/GrinderApplication.h"
+
+QAction* UIUtils::createAction(QObject* owner, QString name, QString icon, const char* callback, QString help, QString shortcut, Qt::ShortcutContext context, QObject* receiver)
+{
+	auto action = new QAction{name, owner};
+
+	if (auto widget = dynamic_cast<QWidget*>(owner))
+		widget->addAction(action);
+
+	if (!icon.isEmpty())
+		action->setIcon(QIcon{icon});
+
+	if (!help.isEmpty())
+	{
+		action->setStatusTip(help);
+		action->setToolTip(help);
+	}
+
+	if (!shortcut.isEmpty())
+	{
+		action->setShortcut(QKeySequence{shortcut});
+		action->setShortcutContext(context);
+		action->setShortcutVisibleInContextMenu(true);
+	}
+
+	if (callback)
+		owner->connect(action, SIGNAL(triggered(bool)), receiver ? receiver : owner, callback);
+
+	return action;
+}
+
+QString UIUtils::askFileName(bool saveFileName, QString dlgName, QWidget* parent, QString caption, QString filter, QString* selectedFilter, QFileDialog::Options options)
+{
+	QString dir = grinder()->settings().getFileDialogDir(dlgName);
+	QString fileName = saveFileName ? QFileDialog::getSaveFileName(parent, caption, dir, filter, selectedFilter, options) : QFileDialog::getOpenFileName(parent, caption, dir, filter, selectedFilter, options);
+
+	if (!fileName.isEmpty())
+	{
+		QFileInfo fileInfo{fileName};
+		grinder()->settings().setFileDialogDir(dlgName, fileInfo.path());
+	}
+
+	return fileName;
+}
+
+QStringList UIUtils::askFileNames(QString dlgName, QWidget* parent, QString caption, QString filter, QString* selectedFilter, QFileDialog::Options options)
+{
+	QString dir = grinder()->settings().getFileDialogDir(dlgName);
+	auto fileNames = QFileDialog::getOpenFileNames(parent, caption, dir, filter, selectedFilter, options);
+
+	if (!fileNames.isEmpty())
+	{
+		QFileInfo fileInfo{fileNames.front()};
+		grinder()->settings().setFileDialogDir(dlgName, fileInfo.path());
+	}
+
+	return fileNames;
+}
+
+void UIUtils::removeChildrenFromLayout(QLayout* layout)
+{
+	QLayoutItem* item;
+	QLayout* sublayout;
+	QWidget* widget;
+
+	while ((item = layout->takeAt(0)))
+	{
+		if ((sublayout = item->layout()))
+		{
+			removeChildrenFromLayout(sublayout);
+		}
+		else if ((widget = item->widget()))
+		{
+			widget->hide();
+			delete widget;
+		}
+		else
+			delete item;
+	}
+}
diff --git a/Grinder/util/UIUtils.h b/Grinder/util/UIUtils.h
new file mode 100644
index 0000000000000000000000000000000000000000..2e6597b9bfbe6b3f7d5b23bea73b84aceb2f0e4d
--- /dev/null
+++ b/Grinder/util/UIUtils.h
@@ -0,0 +1,29 @@
+/******************************************************************************
+ * File: UIUtils.h
+ * Date: 02.2.2018
+ *****************************************************************************/
+
+#ifndef UIUTILS_H
+#define UIUTILS_H
+
+#include <QAction>
+#include <QFileDialog>
+
+namespace grndr
+{
+	class UIUtils final
+	{
+	public:
+		static QAction* createAction(QObject* owner, QString name, QString icon = "", const char* callback = nullptr, QString help = "", QString shortcut = "", Qt::ShortcutContext context = Qt::WidgetShortcut, QObject* receiver = nullptr);
+
+		static QString askFileName(bool saveFileName, QString dlgName, QWidget* parent = nullptr, QString caption = "", QString filter = "", QString* selectedFilter = nullptr, QFileDialog::Options options = QFileDialog::Options{});
+		static QStringList askFileNames(QString dlgName, QWidget* parent = nullptr, QString caption = "", QString filter = "", QString* selectedFilter = nullptr, QFileDialog::Options options = QFileDialog::Options{});
+
+		static void removeChildrenFromLayout(QLayout* layout);
+
+	private:
+		UIUtils() { }
+	};		
+}
+
+#endif