12.2a: folders duplicate with their contents, folder opacity, format version 8, View > Snap (Compositor 1.1.5–1.1.6)
This commit is contained in:
1 parent
ee34c338d4
commit
f1b2c65c1e
52 files changed
+909
-239
No files matched your search
@@ -168,6 +168,10 @@ SnapGuides EditorSession::transformSnapTargets(const QSet<QUuid> &moving) const
|
||||
// `draft` nudged onto a nearby edge or middle, within `tolerance`.
|
||||
LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance)
|
||||
{
|
||||
if (!m_snappingEnabled) {
|
||||
snapGuides = {};
|
||||
return draft;
|
||||
}
|
||||
const SnapGuides targets = transformSnapTargets(moving);
|
||||
const TransformSnap::Offset snap = TransformSnap::offset(boxAround(draft), targets.xs, targets.ys, tolerance);
|
||||
snapGuides = {snap.x ? std::vector<double>{*snap.x} : std::vector<double>{}, snap.y ? std::vector<double>{*snap.y} : std::vector<double>{}};
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
#include "Document/TypeTool.h"
|
||||
#include "IO/ImageImporter.h"
|
||||
#include <QString>
|
||||
#include <QHash>
|
||||
#include <QUuid>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
@@ -40,6 +42,8 @@ struct ImageLayer {
|
||||
QPointF origin() const { return transform.origin; }
|
||||
QSizeF size() const { return transform.size; }
|
||||
ProjectLayerRecord hierarchyRecord() const;
|
||||
// Drawn opacity, folders included (LayerOpacity).
|
||||
double effectiveOpacity(const std::map<QUuid, ImageLayer> &byID) const;
|
||||
LayerTransform maskTransform() const;
|
||||
// The shape this layer still is: none once repainted otherwise.
|
||||
std::optional<LayerShape> liveShape() const;
|
||||
@@ -70,6 +74,7 @@ struct CanvasDocument {
|
||||
static std::optional<int> validDimension(const QString &value);
|
||||
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
|
||||
QSet<QUuid> effectiveVisibleIDs() const;
|
||||
QHash<QUuid, double> effectiveOpacities() const;
|
||||
std::vector<ImageLayer> renderLayers() const;
|
||||
friend bool operator==(const CanvasDocument &lhs, const CanvasDocument &rhs) = default;
|
||||
};
|
||||
|
||||
@@ -100,7 +100,7 @@ void EditorSession::setLocksTransformRatio(bool locks)
|
||||
notify();
|
||||
}
|
||||
|
||||
// A folder has no pixels to copy: it just moves.
|
||||
// Alt-drag copies selected roots with their contents.
|
||||
void EditorSession::beginDuplicateTransform()
|
||||
{
|
||||
if (m_transformDuplicate || !m_activeLayerID)
|
||||
@@ -110,10 +110,14 @@ void EditorSession::beginDuplicateTransform()
|
||||
if (!canTransform())
|
||||
return;
|
||||
const QSet<QUuid> selection = m_selectedLayerIDs;
|
||||
// A selected folder carries its selected contents along.
|
||||
QSet<QUuid> carried;
|
||||
for (const QUuid &id : selection)
|
||||
carried.unite(descendantIDs(id));
|
||||
// Bottom to top: the copies keep their order.
|
||||
std::vector<QUuid> targets;
|
||||
for (const ImageLayer &layer : m_document->layers) {
|
||||
if (selection.contains(layer.id) && !layer.isGroup)
|
||||
if (selection.contains(layer.id) && !carried.contains(layer.id))
|
||||
targets.push_back(layer.id);
|
||||
}
|
||||
if (targets.empty())
|
||||
@@ -123,7 +127,14 @@ void EditorSession::beginDuplicateTransform()
|
||||
for (const QUuid &id : targets) {
|
||||
selectLayer(id);
|
||||
duplicateActiveLayer();
|
||||
copies.push_back(m_activeLayerID.value());
|
||||
if (m_activeLayerID != id)
|
||||
copies.push_back(m_activeLayerID.value());
|
||||
}
|
||||
// Past the layer limit nothing was copied.
|
||||
if (copies.empty()) {
|
||||
endEdit();
|
||||
selectLayers(selection, primary);
|
||||
return;
|
||||
}
|
||||
m_transformDuplicate = TransformDuplicate{copies, selection, primary};
|
||||
selectLayers(QSet<QUuid>(copies.begin(), copies.end()), copies.back());
|
||||
@@ -206,9 +217,11 @@ void EditorSession::cancelTransform()
|
||||
if (m_transformDuplicate) {
|
||||
// The copies go and the old selection returns.
|
||||
const TransformDuplicate duplicate = *m_transformDuplicate;
|
||||
std::erase_if(m_document->layers, [&](const ImageLayer &layer) {
|
||||
return std::find(duplicate.copies.begin(), duplicate.copies.end(), layer.id) != duplicate.copies.end();
|
||||
});
|
||||
QSet<QUuid> removed(duplicate.copies.begin(), duplicate.copies.end());
|
||||
for (const QUuid © : duplicate.copies)
|
||||
removed.unite(descendantIDs(copy));
|
||||
std::erase_if(m_document->layers, [&](const ImageLayer &layer) { return removed.contains(layer.id); });
|
||||
m_collapsedGroupIDs.subtract(removed);
|
||||
selectLayers(duplicate.source, duplicate.primary);
|
||||
endDuplicateTransform();
|
||||
}
|
||||
|
||||
@@ -318,6 +318,14 @@ void EditorSession::setShowsPixelGrid(bool shows)
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setSnappingEnabled(bool enabled)
|
||||
{
|
||||
m_snappingEnabled = enabled;
|
||||
if (!enabled)
|
||||
snapGuides = {};
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setCropRect(std::optional<QRectF> rect)
|
||||
{
|
||||
if (std::exchange(m_cropRect, rect) != rect)
|
||||
|
||||
@@ -238,6 +238,9 @@ public:
|
||||
// The View menu's grid, shown from 800%.
|
||||
bool showsPixelGrid() const { return m_showsPixelGrid; }
|
||||
void setShowsPixelGrid(bool shows);
|
||||
// View > Snap: moves and crops line up with edges.
|
||||
bool snappingEnabled() const { return m_snappingEnabled; }
|
||||
void setSnappingEnabled(bool enabled);
|
||||
// The Eyedropper's ring: the sampled colour over the original.
|
||||
bool showsSampleRing() const { return m_showsSampleRing; }
|
||||
void setShowsSampleRing(bool shows);
|
||||
@@ -336,6 +339,7 @@ private:
|
||||
std::optional<QUuid> m_renamingLayerID;
|
||||
std::optional<QString> m_projectPath;
|
||||
bool m_showsPixelGrid = true;
|
||||
bool m_snappingEnabled = true;
|
||||
bool m_showsSampleRing = true;
|
||||
std::optional<QRectF> m_cropRect;
|
||||
QString m_cropRatioChoice = QStringLiteral("Free");
|
||||
|
||||
@@ -3,6 +3,8 @@ public:
|
||||
LayerBlendMode displayedBlendMode(const ImageLayer &layer) const;
|
||||
void previewBlendMode(std::optional<LayerBlendMode> mode, std::optional<QUuid> id);
|
||||
bool canEditAppearance() const;
|
||||
// A folder dims what it holds; blending stays per layer.
|
||||
bool canEditOpacity() const;
|
||||
// A slider drag: many values, one undo step.
|
||||
void beginOpacityEdit();
|
||||
void finishOpacityEdit();
|
||||
|
||||
@@ -54,9 +54,14 @@ bool EditorSession::canEditAppearance() const
|
||||
return canEditLayers() && m_selectedLayerIDs.size() == 1 && active && !active->isGroup;
|
||||
}
|
||||
|
||||
bool EditorSession::canEditOpacity() const
|
||||
{
|
||||
return canEditLayers() && m_selectedLayerIDs.size() == 1 && activeLayer();
|
||||
}
|
||||
|
||||
void EditorSession::beginOpacityEdit()
|
||||
{
|
||||
if (!canEditAppearance() || m_opacityEditLayerID)
|
||||
if (!canEditOpacity() || m_opacityEditLayerID)
|
||||
return;
|
||||
beginEdit(QStringLiteral("Layer Opacity"));
|
||||
m_opacityEditLayerID = m_activeLayerID;
|
||||
@@ -72,7 +77,7 @@ void EditorSession::finishOpacityEdit()
|
||||
|
||||
void EditorSession::setLayerOpacity(double opacity)
|
||||
{
|
||||
if (!std::isfinite(opacity) || !canEditAppearance())
|
||||
if (!std::isfinite(opacity) || !canEditOpacity())
|
||||
return;
|
||||
const QUuid id = m_opacityEditLayerID.value_or(*m_activeLayerID);
|
||||
const auto layer = std::find_if(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &each) { return each.id == id; });
|
||||
@@ -88,9 +93,9 @@ void EditorSession::setSelectedLayersOpacity(double opacity)
|
||||
{
|
||||
if (!std::isfinite(opacity) || !canEditLayers())
|
||||
return;
|
||||
// Folders have no opacity of their own.
|
||||
// A selected folder takes it too, dimming what it holds.
|
||||
const double value = std::clamp(opacity, 0.0, 1.0);
|
||||
const auto changes = [&](const ImageLayer &layer) { return m_selectedLayerIDs.contains(layer.id) && !layer.isGroup && layer.opacity != value; };
|
||||
const auto changes = [&](const ImageLayer &layer) { return m_selectedLayerIDs.contains(layer.id) && layer.opacity != value; };
|
||||
if (std::none_of(m_document->layers.begin(), m_document->layers.end(), changes))
|
||||
return;
|
||||
finishOpacityEdit();
|
||||
|
||||
@@ -86,6 +86,54 @@ void LayerHierarchy::validate(const std::vector<ProjectLayerRecord> &layers)
|
||||
}
|
||||
}
|
||||
|
||||
double LayerOpacity::effective(double own, std::optional<QUuid> parent, const Node &folder)
|
||||
{
|
||||
double opacity = own;
|
||||
std::optional<QUuid> id = parent;
|
||||
// Past 64 folders the tree is wrong: Swift stops there.
|
||||
for (int depth = 0; id && depth < 64; ++depth) {
|
||||
const std::optional<std::pair<double, std::optional<QUuid>>> node = folder(*id);
|
||||
if (!node)
|
||||
break;
|
||||
opacity *= node->first;
|
||||
id = node->second;
|
||||
}
|
||||
return opacity;
|
||||
}
|
||||
|
||||
double ImageLayer::effectiveOpacity(const std::map<QUuid, ImageLayer> &byID) const
|
||||
{
|
||||
return LayerOpacity::effective(opacity, parentID, [&byID](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
|
||||
if (!byID.contains(id))
|
||||
return std::nullopt;
|
||||
const ImageLayer &node = byID.at(id);
|
||||
return std::pair(node.opacity, node.parentID);
|
||||
});
|
||||
}
|
||||
|
||||
double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRecord> &byID) const
|
||||
{
|
||||
return LayerOpacity::effective(opacity.value_or(1), parentID, [&byID](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
|
||||
if (!byID.contains(id))
|
||||
return std::nullopt;
|
||||
const ProjectLayerRecord &node = byID.at(id);
|
||||
return std::pair(node.opacity.value_or(1), node.parentID);
|
||||
});
|
||||
}
|
||||
|
||||
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
|
||||
{
|
||||
std::map<QUuid, ImageLayer> byID;
|
||||
for (const ImageLayer &layer : layers) {
|
||||
if (!byID.emplace(layer.id, layer).second)
|
||||
throw std::logic_error("two layers share an id");
|
||||
}
|
||||
QHash<QUuid, double> result;
|
||||
for (const ImageLayer &layer : layers)
|
||||
result.insert(layer.id, layer.effectiveOpacity(byID));
|
||||
return result;
|
||||
}
|
||||
|
||||
ProjectLayerRecord ImageLayer::hierarchyRecord() const
|
||||
{
|
||||
const QString file = id.toString(QUuid::WithoutBraces).toUpper();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "IO/ProjectStore.h"
|
||||
#include <QSet>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
namespace LayerHierarchy {
|
||||
@@ -15,3 +16,10 @@ std::vector<Entry> entries(const std::vector<ProjectLayerRecord> &layers, bool t
|
||||
std::vector<ProjectLayerRecord> visibleLayers(const std::vector<ProjectLayerRecord> &layers);
|
||||
void validate(const std::vector<ProjectLayerRecord> &layers);
|
||||
}
|
||||
|
||||
// A folder's opacity multiplies into each layer inside it.
|
||||
namespace LayerOpacity {
|
||||
// A layer's opacity and parent, or none when unknown.
|
||||
using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>;
|
||||
double effective(double own, std::optional<QUuid> parent, const Node &folder);
|
||||
}
|
||||
@@ -42,14 +42,14 @@ void EditorSession::drawLiveComposite(const CanvasDocument &document, QPainter &
|
||||
if (const auto effects = LayerEffectsRenderer::cached(image, mask, layer.effects)) {
|
||||
const LayerTransform grown = LayerEffectsRenderer::placed(transform, effects->image, effects->inset);
|
||||
LayerRenderer::draw(effects->image, grown, grown.center(), target,
|
||||
{.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .clip = clip});
|
||||
{.opacity = layer.effectiveOpacity(records), .blendMode = displayedBlendMode(layer), .clip = clip});
|
||||
return;
|
||||
}
|
||||
LayerRenderer::draw(image, transform, transform.center(), target,
|
||||
{.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .mask = mask.value_or(QImage()), .clip = clip});
|
||||
{.opacity = layer.effectiveOpacity(records), .blendMode = displayedBlendMode(layer), .mask = mask.value_or(QImage()), .clip = clip});
|
||||
});
|
||||
live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).effectiveOpacity(records); };
|
||||
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
|
||||
const ImageLayer &layer = records.at(id);
|
||||
if (const std::optional<QImage> image = layer.mask ? layer.mask->enabledImage() : std::nullopt)
|
||||
@@ -116,6 +116,14 @@ std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot
|
||||
throw std::logic_error("a snapshot to bake repeats a layer id");
|
||||
records.insert(layer.id, &layer);
|
||||
}
|
||||
// A source dims with its folders, as exported.
|
||||
const auto folded = [&records](const ProjectLayerRecord &layer) {
|
||||
return LayerOpacity::effective(layer.opacity.value_or(1), layer.parentID, [&records](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
|
||||
if (!records.contains(id))
|
||||
return std::nullopt;
|
||||
return std::pair(records.value(id)->opacity.value_or(1), records.value(id)->parentID);
|
||||
});
|
||||
};
|
||||
LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.value(id)->maskSourceID : std::nullopt; },
|
||||
[&](QUuid id, QPainter &context, const QImage &clip) {
|
||||
// `at` is checked: end() would read wild memory.
|
||||
@@ -135,7 +143,7 @@ std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot
|
||||
context.save();
|
||||
context.setTransform(inverse, true);
|
||||
LayerRenderer::draw(pixels, layer.transform, layer.transform.center(), context,
|
||||
{.opacity = layer.opacity.value_or(1), .mask = shown.value_or(QImage()), .clip = clip});
|
||||
{.opacity = folded(layer), .mask = shown.value_or(QImage()), .clip = clip});
|
||||
context.restore();
|
||||
});
|
||||
{
|
||||
|
||||
@@ -317,28 +317,54 @@ QString EditorSession::nextLayerName() const
|
||||
void EditorSession::duplicateActiveLayer()
|
||||
{
|
||||
const int index = canEditLayers() ? indexOf(m_document->layers, m_activeLayerID) : -1;
|
||||
if (index < 0 || m_document->layers[index].isGroup)
|
||||
if (index < 0)
|
||||
return;
|
||||
ImageLayer copy = m_document->layers[index];
|
||||
copy.id = QUuid::createUuid();
|
||||
copy.name = copy.name + QStringLiteral(" copy");
|
||||
// A folder carries its whole tree, links kept inside it.
|
||||
const QUuid id = *m_activeLayerID;
|
||||
QSet<QUuid> included = descendantIDs(id);
|
||||
included.insert(id);
|
||||
std::vector<ImageLayer> copies;
|
||||
for (const ImageLayer &layer : m_document->layers) {
|
||||
if (included.contains(layer.id))
|
||||
copies.push_back(layer);
|
||||
}
|
||||
if (m_document->layers.size() + copies.size() > 10'000)
|
||||
return;
|
||||
QHash<QUuid, QUuid> mapping;
|
||||
for (const ImageLayer © : copies)
|
||||
mapping.insert(copy.id, QUuid::createUuid());
|
||||
const auto renamed = [&mapping](std::optional<QUuid> &link) {
|
||||
if (link && mapping.contains(*link))
|
||||
link = mapping.value(*link);
|
||||
};
|
||||
for (ImageLayer © : copies) {
|
||||
if (copy.id == id)
|
||||
copy.name += QStringLiteral(" copy");
|
||||
copy.id = mapping.value(copy.id);
|
||||
renamed(copy.parentID);
|
||||
renamed(copy.maskSourceID);
|
||||
}
|
||||
beginEdit(QStringLiteral("Duplicate Layer"));
|
||||
m_document->layers.insert(m_document->layers.begin() + index + 1, copy);
|
||||
setActiveLayerID(copy.id);
|
||||
m_document->layers.insert(m_document->layers.begin() + index + 1, copies.begin(), copies.end());
|
||||
for (auto original = mapping.cbegin(); original != mapping.cend(); ++original) {
|
||||
if (m_collapsedGroupIDs.contains(original.key()))
|
||||
m_collapsedGroupIDs.insert(original.value());
|
||||
}
|
||||
setActiveLayerID(mapping.value(id));
|
||||
endEdit();
|
||||
}
|
||||
|
||||
// Alt-drag in the Layers panel: a copy placed where dropped.
|
||||
bool EditorSession::duplicateLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above, bool atBottom)
|
||||
{
|
||||
const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1;
|
||||
if (index < 0 || m_document->layers[index].isGroup || !canPlaceLayer(id, parent))
|
||||
// canPlaceLayer reads canEditLayers, Swift's first term.
|
||||
if (!canPlaceLayer(id, parent))
|
||||
return false;
|
||||
beginEdit(QStringLiteral("Duplicate Layer"));
|
||||
selectLayer(id);
|
||||
duplicateActiveLayer();
|
||||
// The copy is active: nothing above refuses it.
|
||||
const bool placed = placeLayer(*m_activeLayerID, parent, above, atBottom);
|
||||
// Past the layer limit no copy was made.
|
||||
const bool placed = m_activeLayerID != id && placeLayer(*m_activeLayerID, parent, above, atBottom);
|
||||
endEdit();
|
||||
return placed;
|
||||
}
|
||||
Reference in new issue
Block a user