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:
雷电芽衣 committed 2026-09-26 18:17:14 -04:00
1 parent ee34c338d4
commit f1b2c65c1e
52 files changed
+909 -239

No files matched your search

+4
View File
@@ -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>{}};
+5
View File
@@ -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;
};
+19 -6
View File
@@ -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 &copy : 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();
}
+8
View File
@@ -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)
+4
View File
@@ -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");
+2
View File
@@ -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();
+9 -4
View File
@@ -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();
+48
View File
@@ -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();
+8
View File
@@ -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);
}
+12 -4
View File
@@ -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();
});
{
+36 -10
View File
@@ -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 &copy : 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 &copy : 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;
}