OmaPhoto 1.0.0: the Linux port of Compositor
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s

A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

Packages for Ubuntu, Fedora and Arch come from the release workflow;
scripts/install/ holds one install script per distribution family,
and flake.nix serves NixOS.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit c864304695
564 files changed
+100804

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
#include "Document/MaskTracing.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
#include <functional>
#include <map>
#include <vector>
namespace {
// Outer loops run clockwise, holes counterclockwise, for winding fill.
std::optional<QPainterPath> trace(const QImage &image, bool alpha, const std::function<bool(uchar)> &test)
{
const int width = image.width(), height = image.height();
if (width <= 0 || height <= 0)
return std::nullopt;
const int channels = alpha ? 4 : 1;
// Drawn over a cleared bitmap, as the Swift tracer does.
QImage pixels(width, height, alpha ? QImage::Format_RGBA8888_Premultiplied : QImage::Format_Grayscale8);
if (pixels.isNull())
return std::nullopt;
pixels.fill(0);
QPainter(&pixels).drawImage(QRectF(0, 0, width, height), image);
const auto selected = [&](int x, int y) {
return x >= 0 && y >= 0 && x < width && y < height
&& test(pixels.constScanLine(y)[x * channels + channels - 1]);
};
// Directed unit edges, keyed by their start vertex.
const int stride = width + 1;
std::map<int, std::vector<int>> outgoing;
const auto edge = [&](int x0, int y0, int x1, int y1) { outgoing[y0 * stride + x0].push_back(y1 * stride + x1); };
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
if (!selected(x, y))
continue;
if (!selected(x, y - 1))
edge(x, y, x + 1, y);
if (!selected(x + 1, y))
edge(x + 1, y, x + 1, y + 1);
if (!selected(x, y + 1))
edge(x + 1, y + 1, x, y + 1);
if (!selected(x - 1, y))
edge(x, y + 1, x, y);
}
}
if (outgoing.empty())
return std::nullopt;
QPainterPath path;
path.setFillRule(Qt::WindingFill);
while (!outgoing.empty()) {
const int start = outgoing.begin()->first;
std::vector<int> loop;
int current = start;
do {
const auto found = outgoing.find(current);
if (found == outgoing.end())
break;
const int end = found->second.back();
found->second.pop_back();
if (found->second.empty())
outgoing.erase(found);
loop.push_back(current);
current = end;
} while (current != start);
// Keep corners; drop vertices that continue straight.
std::vector<QPointF> corners;
const int count = int(loop.size());
for (int index = 0; index < count; ++index) {
const int vertex = loop[index], previous = loop[(index + count - 1) % count], following = loop[(index + 1) % count];
const int inX = vertex % stride - previous % stride, inY = vertex / stride - previous / stride;
const int outX = following % stride - vertex % stride, outY = following / stride - vertex / stride;
if (inX != outX || inY != outY)
corners.push_back(QPointF(vertex % stride, vertex / stride));
}
if (corners.size() < 3)
continue;
path.moveTo(corners.front());
for (std::size_t index = 1; index < corners.size(); ++index)
path.lineTo(corners[index]);
path.closeSubpath();
}
return path.isEmpty() ? std::nullopt : std::optional(path);
}
}
std::optional<QPainterPath> MaskTracing::darkPixels(const QImage &image)
{
return trace(image, false, [](uchar value) { return value < 128; });
}
std::optional<QPainterPath> MaskTracing::opaquePixels(const QImage &image)
{
return trace(image, true, [](uchar value) { return value >= 128; });
}
void EditorSession::loadMaskSelection(QUuid layerID, SelectionMode mode)
{
if (!canEditSelection())
return;
const int index = indexOf(m_document->layers, layerID);
if (index < 0 || !m_document->layers[size_t(index)].mask)
return;
const ImageLayer &layer = m_document->layers[size_t(index)];
QImage mask;
// A painted mask flattens here; a failure loads nothing.
try {
mask = layer.mask->asset.image();
} catch (const ExportError &error) {
qCWarning(lcApp).noquote() << "cannot read the mask of" << layer.name << ":" << error.what();
return;
}
const std::optional<QPainterPath> traced = MaskTracing::darkPixels(mask);
// Swift beeps: nothing black to select.
if (!traced) {
qCWarning(lcApp).noquote() << "no black to select in the mask of" << layer.name;
return;
}
applySelection(BrushRaster::pixelToDocument(layer.maskTransform(), mask.width(), mask.height()).map(*traced), mode,
QStringLiteral("Load Mask Selection"));
}
void EditorSession::loadLayerSelection(QUuid layerID, SelectionMode mode)
{
const int index = canEditSelection() ? indexOf(m_document->layers, layerID) : -1;
// Swift beeps: no pixels, or none opaque.
if (index < 0 || m_document->layers[size_t(index)].isGroup || !m_document->layers[size_t(index)].asset) {
qCWarning(lcApp) << "no pixels to select for layer" << layerID.toString();
return;
}
const ImageLayer &layer = m_document->layers[size_t(index)];
QImage image;
try {
image = layer.asset->image();
} catch (const ExportError &error) {
qCWarning(lcApp).noquote() << "cannot read the pixels of" << layer.name << ":" << error.what();
return;
}
const std::optional<QPainterPath> traced = MaskTracing::opaquePixels(image);
if (!traced) {
qCWarning(lcApp).noquote() << "no opaque pixels to select in" << layer.name;
return;
}
applySelection(BrushRaster::pixelToDocument(layer.transform, image.width(), image.height()).map(*traced), mode,
QStringLiteral("Load Layer Selection"));
}