Sketch2D: tablet painting application #17

Merged
ilusha merged 13 commits from sketch2d into master 2026-11-01 14:26:57 +03:00
184 changed files with 38179 additions and 1 deletions

View file

@ -38,3 +38,4 @@ add_subdirectory(3DScene)
add_subdirectory(RayTracer)
add_subdirectory(Sketch3D)
add_subdirectory(3DEditor)
add_subdirectory(Sketch2D)

View file

@ -0,0 +1,25 @@
#include "AppStorage.hpp"
#include <cstdlib>
#include <filesystem>
#include <utility>
namespace tp {
AppStorage::AppStorage(std::string root) :
mRoot(std::move(root)) {}
std::string AppStorage::homeDirectory() {
const char* home = std::getenv("HOME");
if (home && *home) return home;
return std::filesystem::current_path().string();
}
AppStorage AppStorage::inHome(const std::string& applicationName) {
return AppStorage((std::filesystem::path(homeDirectory()) / applicationName).string());
}
std::string AppStorage::path(const std::string& relative) const {
return (std::filesystem::path(mRoot) / relative).string();
}
}

View file

@ -0,0 +1,23 @@
#pragma once
#include <string>
namespace tp {
// A folder for an application's persistent files. The refactor moved storage out of Widgets into the
// applications; this shared locator (the one place to resolve a home directory and build paths) keeps every
// application from reimplementing it.
class AppStorage {
public:
explicit AppStorage(std::string root);
[[nodiscard]] static AppStorage inHome(const std::string& applicationName);
[[nodiscard]] static std::string homeDirectory();
[[nodiscard]] const std::string& root() const { return mRoot; }
[[nodiscard]] std::string path(const std::string& relative) const;
private:
std::string mRoot;
};
}

58
Sketch2D/CMakeLists.txt Normal file
View file

@ -0,0 +1,58 @@
project(Sketch2D)
add_subdirectory(src/libs)
if (MODULES_BUILD_DESKTOP OR ANDROID)
if (ANDROID)
add_library(sketch2dAndroid SHARED ./src/apps/AndroidEntry.cpp ./src/apps/AndroidUpdater.cpp)
target_include_directories(sketch2dAndroid PRIVATE ${ANDROID_NDK}/sources/android/native_app_glue)
target_link_libraries(sketch2dAndroid PRIVATE ${PROJECT_NAME}Application)
target_link_options(sketch2dAndroid PRIVATE "-Wl,-u,ANativeActivity_onCreate")
else ()
add_executable(sketch2dApp ./src/apps/Entry.cpp)
target_link_libraries(sketch2dApp ${PROJECT_NAME}Application)
modules_add_desktop_entry(sketch2dApp
ID online.ilushabasic.sketch2d
NAME Sketch2D
COMMENT "Raster sketching and painting"
ICON ${CMAKE_SOURCE_DIR}/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
CATEGORIES Graphics 2DGraphics RasterGraphics)
endif ()
endif ()
if (MODULES_BUILD_DESKTOP)
### -------------------------- Benchmark -------------------------- ###
add_library(${PROJECT_NAME}BenchCore STATIC
./bench/BenchInput.cpp ./bench/BenchReport.cpp
./bench/BenchInput.hpp ./bench/BenchReport.hpp ./bench/BenchScenario.hpp
)
target_include_directories(${PROJECT_NAME}BenchCore PUBLIC ./bench/)
target_link_libraries(${PROJECT_NAME}BenchCore PUBLIC ${PROJECT_NAME}UI Graphics)
add_executable(sketch2dBench ./bench/BenchMain.cpp ./bench/BenchRunner.cpp ./bench/BenchScenario.cpp ./bench/BenchRunner.hpp)
target_link_libraries(sketch2dBench ${PROJECT_NAME}BenchCore ${PROJECT_NAME}Application)
if (MODULES_BUILD_BENCH_TESTS)
add_test(NAME sketch2dBenchSmoke COMMAND ${CMAKE_SOURCE_DIR}/Sketch2D/temp/run-headless-wayland.sh $<TARGET_FILE:sketch2dBench> --quick --repeats 2 --filter "size 20" --out ${CMAKE_BINARY_DIR}/Sketch2D/bench-smoke WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/${PROJECT_NAME})
endif ()
endif ()
### -------------------------- Headless UI Tests -------------------------- ###
if (MODULES_BUILD_TESTS AND MODULES_BUILD_DESKTOP)
add_executable(Test${PROJECT_NAME} ./tests/ui/Sketch2DUiTests.cpp ./tests/ui/TouchUiTests.cpp ./tests/ui/StudioUiTests.cpp ./tests/ui/ViewLockTests.cpp ./tests/engine/SketchLibraryTests.cpp ./tests/engine/UpdateTests.cpp ./tests/brush/BrushTests.cpp ./tests/brush/StabilizerTests.cpp ./tests/document/CommandTests.cpp ./tests/document/HistoryTests.cpp ./tests/document/LayerTests.cpp ./tests/color/CompositorTests.cpp ./tests/color/MipTests.cpp ./tests/color/CanvasTests.cpp ./tests/document/BenchTests.cpp ./tests/engine/SessionTests.cpp ./tests/brush/StrokePainterTests.cpp ./tests/brush/BrushCursorTests.cpp ./tests/brush/BrushToolsTests.cpp ./tests/color/ColorCommandTests.cpp ./tests/color/ColorPanelTests.cpp ./tests/color/ColorHistoryTests.cpp ./tests/tools/PickerToolTests.cpp ./tests/tools/ViewToolTests.cpp ./tests/tools/BucketToolTests.cpp ./tests/tools/GradientToolTests.cpp ./tests/tools/MoveToolTests.cpp ./tests/tools/TransformToolTests.cpp ./tests/tools/CropToolTests.cpp ./tests/tools/SelectionTests.cpp ./tests/tools/MagicWandTests.cpp ./tests/tools/SelectionEditTests.cpp ./tests/color/FilterTests.cpp ./tests/document/ClipboardTests.cpp ./tests/color/AdjustmentTests.cpp ./tests/tools/LineTests.cpp ./tests/engine/EngineScenarioTests.cpp ./tests/engine/EngineTests.cpp ./tests/brush/BrushWorkflowTests.cpp ./tests/brush/StrokePathTests.cpp ./tests/brush/DabMaskTests.cpp ./tests/brush/DabMaskBenchTests.cpp ./bench/BenchScenario.cpp ../Widgets/tests/Main.cpp)
target_include_directories(Test${PROJECT_NAME} PRIVATE ./tests/support)
target_link_libraries(Test${PROJECT_NAME} ${PROJECT_NAME}BenchCore WidgetsTesting UnitTest++)
add_test(NAME Test${PROJECT_NAME} COMMAND Test${PROJECT_NAME})
endif ()
### -------------------------- Application Integration Tests -------------------------- ###
if (MODULES_BUILD_TESTS AND MODULES_BUILD_DESKTOP)
add_executable(Test${PROJECT_NAME}App ./tests/integration/Sketch2DAppTests.cpp ../Widgets/tests/Main.cpp)
target_link_libraries(Test${PROJECT_NAME}App ${PROJECT_NAME}Application WidgetsTesting UnitTest++)
add_test(NAME Test${PROJECT_NAME}App COMMAND ${CMAKE_SOURCE_DIR}/Sketch2D/temp/run-headless-wayland.sh $<TARGET_FILE:Test${PROJECT_NAME}App> WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/${PROJECT_NAME})
endif ()
if (MODULES_BUILD_DESKTOP)
file(COPY "../Graphics/rsc" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/")
file(COPY "rsc/icons" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/rsc/")
endif ()

View file

@ -0,0 +1,132 @@
#include "BenchInput.hpp"
#include <algorithm>
#include <cmath>
namespace tp::sketch2d::bench {
namespace {
constexpr double kPi = 3.14159265358979;
class Random {
public:
explicit Random(uint32_t seed) :
mState(seed ? seed : 1) {}
double next() {
mState ^= mState << 13;
mState ^= mState >> 17;
mState ^= mState << 5;
return (double) mState / 4294967296.0;
}
double between(double low, double high) { return low + (high - low) * next(); }
private:
uint32_t mState;
};
struct Pen {
Vec2F position;
double heading = 0;
double turnRate = 0;
};
PointerSample sampleAt(bool pen, double strokeFraction, double timeMs, double heading) {
PointerSample sample;
if (!pen) return sample;
sample.tool = PointerTool::Pen;
const double rise = std::min(1.0, strokeFraction * 6.0);
const double fall = std::min(1.0, (1.0 - strokeFraction) * 5.0);
sample.pressure = (halnf) std::clamp(0.25 + 0.7 * rise * fall, 0.05, 1.0);
sample.tilt = { (halnf) (0.3 * std::cos(heading)), (halnf) (0.3 * std::sin(heading)) };
sample.timeMs = (uint32_t) timeMs;
return sample;
}
void keepInside(Pen& pen, const RectF& area) {
const Vec2F center = area.center();
const Vec2F half = area.size * 0.5f;
const Vec2F offset = pen.position - center;
if (std::fabs(offset.x) < half.x * 0.85f && std::fabs(offset.y) < half.y * 0.85f) return;
const double towardCenter = std::atan2(-offset.y, -offset.x);
double difference = towardCenter - pen.heading;
while (difference > kPi)
difference -= 2 * kPi;
while (difference < -kPi)
difference += 2 * kPi;
pen.heading += difference * 0.2;
pen.position.x = std::clamp(pen.position.x, area.pos.x, area.pos.x + area.size.x);
pen.position.y = std::clamp(pen.position.y, area.pos.y, area.pos.y + area.size.y);
}
void push(InputRecording& recording, double timeMs, RecordedInput::Kind kind) {
RecordedInput event;
event.timeMs = timeMs;
event.kind = kind;
recording.events.push_back(event);
}
}
InputRecording fastScribble(const ScribbleOptions& options) {
InputRecording recording;
Random random(options.seed);
Pen pen;
pen.position = options.area.center();
pen.heading = random.between(0, 2 * kPi);
double time = 0;
while (time < options.durationMs) {
const double strokeEnd = std::min(time + options.strokeMs, options.durationMs);
const double strokeLength = strokeEnd - time;
bool down = false;
for (double now = time; now <= strokeEnd; now += options.sampleIntervalMs) {
const double fraction = strokeLength > 0 ? (now - time) / strokeLength : 1.0;
if (random.next() < 0.04) pen.turnRate = random.between(-0.35, 0.35);
pen.heading += pen.turnRate + random.between(-0.05, 0.05);
const double step = options.speedPixelsPerSecond * options.sampleIntervalMs / 1000.0;
pen.position += Vec2F((halnf) (std::cos(pen.heading) * step), (halnf) (std::sin(pen.heading) * step));
keepInside(pen, options.area);
RecordedInput sample;
sample.timeMs = now;
sample.kind = RecordedInput::Kind::Sample;
sample.sample = sampleAt(options.pen, fraction, now, pen.heading);
recording.events.push_back(sample);
RecordedInput move;
move.timeMs = now;
move.kind = RecordedInput::Kind::Move;
move.vector = pen.position;
recording.events.push_back(move);
if (!down) {
RecordedInput press;
press.timeMs = now;
press.kind = RecordedInput::Kind::Button;
press.id = Key::MOUSE1;
press.flag = true;
recording.events.push_back(press);
down = true;
}
}
RecordedInput release;
release.timeMs = strokeEnd;
release.kind = RecordedInput::Kind::Button;
release.id = Key::MOUSE1;
release.flag = false;
recording.events.push_back(release);
if (options.pen) {
RecordedInput away;
away.timeMs = strokeEnd;
away.kind = RecordedInput::Kind::Sample;
recording.events.push_back(away);
}
time = strokeEnd + options.gapMs;
}
push(recording, time, RecordedInput::Kind::Leave);
return recording;
}
}

View file

@ -0,0 +1,22 @@
#pragma once
#include "InputRecording.hpp"
#include "Rect.hpp"
#include <cstdint>
namespace tp::sketch2d::bench {
struct ScribbleOptions {
RectF area = { 0, 0, 800, 600 };
double durationMs = 2500;
double sampleIntervalMs = 4;
double speedPixelsPerSecond = 2600;
double strokeMs = 700;
double gapMs = 60;
bool pen = true;
uint32_t seed = 1;
};
[[nodiscard]] InputRecording fastScribble(const ScribbleOptions& options);
}

View file

@ -0,0 +1,312 @@
#include "ThreadPlacement.hpp"
#include "BenchInput.hpp"
#include "BenchReport.hpp"
#include "BenchRunner.hpp"
#include "Sketch2DApplication.hpp"
#include "VulkanContext.hpp"
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <map>
#include <optional>
#include <sstream>
#include <string>
#include <vector>
using namespace tp;
using namespace tp::sketch2d;
using namespace tp::sketch2d::bench;
namespace {
struct Options {
std::string outputDirectory;
std::string baselinePath;
std::string comparePath;
std::vector<std::string> sessions;
std::string revision = "unknown";
std::string buildType = "unknown";
std::string filter;
std::vector<std::string> recordings;
bool synthetic = true;
bool quick = false;
bool list = false;
bool failOnRegression = false;
bool unpaced = false;
bool matrix = false;
int repeats = 5;
double minimumEffect = 0.05;
};
void printUsage() {
std::printf(
"Usage: sketch2dBench [options]\n"
"\n"
"Replays scripted drawing input through the real sketch2d application and writes a timing report.\n"
"\n"
" --out DIR directory for report.md and results.tsv (default bench-results)\n"
" --baseline FILE results.tsv of an earlier run to compare against\n"
" --session DIR replay a session recorded with Debug > Record session; repeatable.\n"
" Only sessions run unless --matrix is also given; output defaults into DIR\n"
" --matrix with --session, also run the brush and eraser matrix\n"
" --compare FILE compare a stored results.tsv with --baseline without running\n"
" --recording FILE add an input recording (input.txt of a session) as a scenario input; repeatable\n"
" --no-synthetic do not use the generated fast scribble input\n"
" --filter TEXT run only scenarios whose name contains TEXT\n"
" --repeats N replay each scenario N times; statistics need at least 3 (default 5)\n"
" --quick short synthetic input, for smoke runs\n"
" --min-effect FRACTION smallest median change reported as faster or slower (default 0.05)\n"
" --fail-on-regression exit with status 3 when a key metric is significantly slower\n"
" --unpaced render frames back to back instead of waiting for the compositor\n"
" --revision TEXT revision recorded in the report\n"
" --build TEXT build type recorded in the report\n"
" --list print the scenario names and exit\n"
);
}
std::optional<Options> parseOptions(int argc, char** argv) {
Options options;
for (int index = 1; index < argc; index++) {
const std::string argument = argv[index];
auto value = [&]() -> std::string { return index + 1 < argc ? argv[++index] : std::string(); };
if (argument == "--out") options.outputDirectory = value();
else if (argument == "--baseline") options.baselinePath = value();
else if (argument == "--recording") options.recordings.push_back(value());
else if (argument == "--compare") options.comparePath = value();
else if (argument == "--session") options.sessions.push_back(value());
else if (argument == "--no-synthetic") options.synthetic = false;
else if (argument == "--filter") options.filter = value();
else if (argument == "--repeats") options.repeats = std::atoi(value().c_str());
else if (argument == "--quick") options.quick = true;
else if (argument == "--min-effect") options.minimumEffect = std::atof(value().c_str());
else if (argument == "--fail-on-regression") options.failOnRegression = true;
else if (argument == "--unpaced") options.unpaced = true;
else if (argument == "--matrix") options.matrix = true;
else if (argument == "--revision") options.revision = value();
else if (argument == "--build") options.buildType = value();
else if (argument == "--list") options.list = true;
else {
printUsage();
return std::nullopt;
}
}
return options;
}
std::string readFile(const std::string& path) {
std::ifstream file(path);
std::stringstream text;
text << file.rdbuf();
return text.str();
}
bool writeFile(const std::filesystem::path& path, const std::string& text) {
std::ofstream file(path);
file << text;
return (bool) file;
}
std::string today() {
const std::time_t now = std::time(nullptr);
char text[32];
std::strftime(text, sizeof(text), "%Y-%m-%d %H:%M", std::localtime(&now));
return text;
}
std::string deviceName(Window& window) {
VkPhysicalDeviceProperties properties{};
vkGetPhysicalDeviceProperties(window.vulkan()->physicalDevice(), &properties);
return properties.deviceName;
}
std::vector<BenchInputs> loadInputs(const Options& options, RectF canvasArea) {
std::vector<BenchInputs> inputs;
if (options.synthetic) {
ScribbleOptions scribble;
scribble.area = canvasArea;
if (options.quick) scribble.durationMs = 400;
inputs.push_back({ "scribble", fastScribble(scribble) });
}
for (const std::string& path : options.recordings) {
std::optional<InputRecording> recording = InputRecording::load(path);
if (!recording) {
std::fprintf(stderr, "sketch2dBench: %s is not an input recording\n", path.c_str());
continue;
}
inputs.push_back({ std::filesystem::path(path).stem().string(), *recording });
}
return inputs;
}
const InputRecording* inputNamed(const std::vector<BenchInputs>& inputs, const std::string& name) {
for (const BenchInputs& input : inputs) {
if (input.name == name) return &input.recording;
}
return nullptr;
}
}
int compareStored(const Options& options) {
const std::optional<Baseline> before = Baseline::parse(readFile(options.baselinePath));
const std::optional<Baseline> after = Baseline::parse(readFile(options.comparePath));
if (!before || !after) {
std::fprintf(stderr, "sketch2dBench: --compare needs two results.tsv files (--baseline and --compare)\n");
return 2;
}
ComparisonPolicy policy;
policy.minimumEffect = options.minimumEffect;
RunInfo info;
info.revision = after->revision;
info.buildType = options.buildType;
info.pacing = "stored";
const std::vector<ScenarioResult> results = resultsFromBaseline(*after);
const std::filesystem::path directory(options.outputDirectory);
std::filesystem::create_directories(directory);
writeFile(directory / "comparison.md", markdownReport(info, results, before, policy));
const std::vector<Change> changes = compare(*before, results, policy);
std::fprintf(
stderr,
"sketch2dBench: %zu key metrics compared: %zu slower, %zu faster, %zu within noise; report in %s\n",
changes.size(),
countVerdicts(changes, Verdict::Slower),
countVerdicts(changes, Verdict::Faster),
countVerdicts(changes, Verdict::WithinNoise),
(directory / "comparison.md").c_str()
);
return options.failOnRegression && countVerdicts(changes, Verdict::Slower) > 0 ? 3 : 0;
}
namespace {
struct ScratchStorage {
std::filesystem::path root =
std::filesystem::temp_directory_path() / ("sketch2d-bench-" + std::to_string(::getpid()));
~ScratchStorage() {
std::error_code error;
std::filesystem::remove_all(root, error);
}
};
}
int main(int argc, char** argv) {
const std::optional<Options> parsed = parseOptions(argc, argv);
if (!parsed) return 2;
const Options& options = *parsed;
if (!options.comparePath.empty()) return compareStored(options);
ThreadPlacement::reserveCallingThreadForInteraction();
const ScratchStorage scratch;
Sketch2DApplication application(Sketch2DOptions::forTesting(scratch.root.string()));
BenchRunner runner(application, { options.repeats, !options.unpaced });
std::vector<BenchInputs> inputs = loadInputs(options, application.root().canvas.windowRect());
std::map<std::string, std::string> recordedFrames;
if (inputs.empty() && options.sessions.empty()) {
std::fprintf(stderr, "sketch2dBench: no inputs\n");
return 2;
}
std::vector<std::string> inputNames;
for (const BenchInputs& input : inputs)
inputNames.push_back(input.name);
std::vector<Scenario> scenarios;
std::vector<BenchInputs> sessionInputs;
for (const std::string& path : options.sessions) {
std::optional<RecordedSession> session = RecordedSession::load(path);
if (!session) {
std::fprintf(stderr, "sketch2dBench: %s is not a recorded session\n", path.c_str());
return 2;
}
Scenario scenario;
scenario.name = "session " + std::filesystem::path(path).filename().string();
scenario.group = "recorded sessions";
scenario.input = scenario.name;
scenario.session = std::make_shared<SessionSnapshot>(session->snapshot);
scenarios.push_back(scenario);
sessionInputs.push_back({ scenario.name, session->input });
recordedFrames[scenario.name] = session->recordedFrames;
}
if (options.sessions.empty() || options.matrix) {
for (const Scenario& scenario : brushAndEraserScenarios(inputNames))
scenarios.push_back(scenario);
}
for (const BenchInputs& input : sessionInputs)
inputs.push_back(input);
std::vector<ScenarioResult> results;
for (const Scenario& scenario : scenarios) {
if (!options.filter.empty() && scenario.name.find(options.filter) == std::string::npos) continue;
if (options.list) {
std::printf("%s\n", scenario.name.c_str());
continue;
}
std::fprintf(stderr, "sketch2dBench: %s\n", scenario.name.c_str());
results.push_back(runner.run(scenario, *inputNamed(inputs, scenario.input)));
}
if (options.list) return 0;
RunInfo info;
info.revision = options.revision;
info.buildType = options.buildType;
info.device = deviceName(application.window());
info.date = today();
info.repeats = options.repeats;
info.pacing = options.unpaced ? "unpaced (frames back to back)" : "compositor frame callbacks";
info.peakResidentMegabytes = Profiler::peakResidentMegabytes();
for (const std::string& name : inputNames)
info.inputs += (info.inputs.empty() ? "" : ", ") + name;
std::string outputDirectory = options.outputDirectory;
if (outputDirectory.empty())
outputDirectory = options.sessions.size() == 1 && !options.matrix
? (std::filesystem::path(options.sessions.front()) / "replay").string()
: std::string("bench-results");
for (ScenarioResult& result : results) {
const auto live = recordedFrames.find(result.name);
if (live == recordedFrames.end() || live->second.empty()) continue;
result.liveMetrics = metricsFromRecordedFrames(live->second);
}
std::optional<Baseline> baseline;
if (!options.baselinePath.empty()) {
baseline = Baseline::parse(readFile(options.baselinePath));
if (!baseline)
std::fprintf(stderr, "sketch2dBench: ignoring unreadable baseline %s\n", options.baselinePath.c_str());
}
ComparisonPolicy policy;
policy.minimumEffect = options.minimumEffect;
const std::filesystem::path directory(outputDirectory);
std::filesystem::create_directories(directory);
const bool written = writeFile(directory / "results.tsv", resultsTsv(info, results)) &&
writeFile(directory / "report.md", markdownReport(info, results, baseline, policy));
if (!written) {
std::fprintf(stderr, "sketch2dBench: could not write into %s\n", directory.c_str());
return 1;
}
const std::vector<Change> changes = baseline ? compare(*baseline, results, policy) : std::vector<Change>{};
const size_t regressions = countVerdicts(changes, Verdict::Slower);
std::fprintf(
stderr,
"sketch2dBench: %zu scenarios, %zu key metrics compared: %zu slower, %zu faster, %zu within noise, %zu unmeasured; "
"report in %s\n",
results.size(),
changes.size(),
regressions,
countVerdicts(changes, Verdict::Faster),
countVerdicts(changes, Verdict::WithinNoise),
countVerdicts(changes, Verdict::Unmeasured),
(directory / "report.md").c_str()
);
return options.failOnRegression && regressions > 0 ? 3 : 0;
}

View file

@ -0,0 +1,587 @@
#include "BenchReport.hpp"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <numeric>
#include <sstream>
namespace tp::sketch2d::bench {
namespace {
const char* const kStatistics[] = { "mean", "median", "p95", "p99", "max", "total" };
std::string format(const char* pattern, double value) {
char text[64];
std::snprintf(text, sizeof(text), pattern, value);
return text;
}
std::string ms(double value) { return format("%.3f", value); }
std::string percent(double fraction) { return format("%+.1f%%", fraction * 100.0); }
std::vector<std::string> split(const std::string& line, char separator) {
std::vector<std::string> fields;
std::string field;
std::istringstream input(line);
while (std::getline(input, field, separator))
fields.push_back(field);
return fields;
}
std::string joined(const std::vector<double>& values) {
std::string text;
for (double value : values)
text += (text.empty() ? "" : ",") + format("%.6f", value);
return text;
}
std::vector<double> numbers(const std::string& text) {
std::vector<double> values;
for (const std::string& field : split(text, ','))
if (!field.empty()) values.push_back(std::stod(field));
return values;
}
double normalTail(double z) { return 0.5 * std::erfc(z / std::sqrt(2.0)); }
constexpr size_t kExactSampleLimit = 20;
double exactMannWhitneyPValue(size_t first, size_t second, double u) {
const size_t maximum = first * second;
std::vector<std::vector<double>> previous(second + 1, std::vector<double>(maximum + 1, 0.0));
for (size_t m = 0; m <= second; m++)
previous[m][0] = 1;
for (size_t n = 1; n <= first; n++) {
std::vector<std::vector<double>> current(second + 1, std::vector<double>(maximum + 1, 0.0));
current[0][0] = 1;
for (size_t m = 1; m <= second; m++) {
for (size_t value = 0; value <= n * m; value++) {
current[m][value] = current[m - 1][value] + (value >= m ? previous[m][value - m] : 0.0);
}
}
previous = std::move(current);
}
const std::vector<double>& counts = previous[second];
double total = 0;
double lower = 0;
double upper = 0;
for (size_t value = 0; value <= maximum; value++) {
total += counts[value];
if ((double) value <= u + 1e-9) lower += counts[value];
if ((double) value >= u - 1e-9) upper += counts[value];
}
return std::min(1.0, 2.0 * std::min(lower, upper) / total);
}
std::string spread(const SampleSummary& summary) {
if (summary.count < 2) return ms(summary.median);
return ms(summary.median) + " ±" + format("%.0f", summary.variation * 100.0) + "%";
}
std::string cell(const ScenarioResult& result, const std::string& metric, const std::string& statistic) {
const Metric* found = result.metric(metric);
return found ? ms(statisticValue(found->statistics, statistic)) : std::string("-");
}
std::vector<std::string> groupsInOrder(const std::vector<ScenarioResult>& results) {
std::vector<std::string> groups;
for (const ScenarioResult& result : results) {
if (std::find(groups.begin(), groups.end(), result.group) == groups.end()) groups.push_back(result.group);
}
return groups;
}
void appendSummaryTable(std::string& text, const std::vector<ScenarioResult>& results, const std::string& group) {
text += "| scenario | frames | dabs | input lat p95 | frame wall p50 | frame wall p95 | frame wall max | "
"frame cpu p95 | process | draw | canvas gpu p95 | window gpu p95 |\n";
text += "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|\n";
for (const ScenarioResult& result : results) {
if (result.group != group) continue;
text += "| " + result.name + " | " + std::to_string(result.frames) + " | " + std::to_string(result.dabs) +
" | " + cell(result, "input.latency", "p95") + " | " + cell(result, "frame.wall", "median") + " | " +
cell(result, "frame.wall", "p95") + " | " + cell(result, "frame.wall", "max") + " | " +
cell(result, "frame.cpu", "p95") + " | " + cell(result, "frame.process", "p95") + " | " +
cell(result, "frame.draw", "p95") + " | " + cell(result, "gpu.canvas", "p95") + " | " +
cell(result, "gpu.window", "p95") + " |\n";
}
text += "\n";
}
void appendSectionBreakdown(std::string& text, const ScenarioResult& result) {
std::vector<const Metric*> sections;
for (const Metric& metric : result.sectionMetrics)
sections.push_back(&metric);
std::sort(sections.begin(), sections.end(), [](const Metric* left, const Metric* right) {
return left->statistics.total > right->statistics.total;
});
text += "#### " + result.name + "\n\n";
text += result.description + ". " + format("%.0f", result.inputMs) + " ms of input, " +
std::to_string(result.inputEvents) + " events, " + std::to_string(result.tiles) + " tiles, wall " +
format("%.0f", result.wallMs) + " ms, process CPU " + format("%.0f", result.processCpuMs) + " ms.\n\n";
text += "| section | calls | total ms | mean/frame | p95/frame | max/frame |\n";
text += "|---|---:|---:|---:|---:|---:|\n";
for (const Metric* metric : sections) {
const auto calls = result.counterTotals.find("calls " + metric->name);
text += "| " + metric->name + " | " +
format("%.0f", calls == result.counterTotals.end() ? 0.0 : calls->second) + " | " +
ms(metric->statistics.total) + " | " + ms(metric->statistics.mean) + " | " +
ms(metric->statistics.p95) + " | " + ms(metric->statistics.maximum) + " |\n";
}
for (const Metric& metric : result.gpuMetrics) {
text += "| " + metric.name + " (gpu) | " + std::to_string(metric.statistics.samples) + " | " +
ms(metric.statistics.total) + " | " + ms(metric.statistics.mean) + " | " + ms(metric.statistics.p95) +
" | " + ms(metric.statistics.maximum) + " |\n";
}
std::string counters;
for (const auto& [name, value] : result.counterTotals) {
if (name.rfind("calls ", 0) == 0) continue;
counters += (counters.empty() ? "" : ", ") + name + " " + format("%.0f", value);
}
if (!counters.empty()) text += "\nCounters: " + counters + ".\n";
text += "\n";
}
}
const Metric* ScenarioResult::metric(const std::string& name) const {
for (const std::vector<Metric>* list : { &frameMetrics, &sectionMetrics, &gpuMetrics }) {
for (const Metric& entry : *list) {
if (entry.name == name) return &entry;
}
}
return nullptr;
}
const std::vector<KeyMetric>& keyMetrics() {
static const std::vector<KeyMetric> metrics = {
{ "input.latency", "p95" }, { "frame.wall", "median" }, { "frame.wall", "p95" }, { "frame.cpu", "p95" },
{ "frame.process", "p95" }, { "frame.draw", "p95" }, { "brush.dab", "total" }, { "gpu.canvas", "p95" },
};
return metrics;
}
double statisticValue(const ProfileStatistics& statistics, const std::string& statistic) {
if (statistic == "mean") return statistics.mean;
if (statistic == "median") return statistics.median;
if (statistic == "p95") return statistics.p95;
if (statistic == "p99") return statistics.p99;
if (statistic == "max") return statistics.maximum;
if (statistic == "total") return statistics.total;
return 0;
}
const BaselineValue*
Baseline::find(const std::string& scenario, const std::string& metric, const std::string& statistic) const {
for (const BaselineValue& value : values) {
if (value.scenario == scenario && value.metric == metric && value.statistic == statistic) return &value;
}
return nullptr;
}
std::optional<Baseline> Baseline::parse(const std::string& tsv) {
std::istringstream input(tsv);
std::string line;
Baseline baseline;
bool sawHeader = false;
while (std::getline(input, line)) {
if (line.empty()) continue;
if (line[0] == '#') {
const std::vector<std::string> fields = split(line.substr(1), '\t');
if (fields.size() == 2 && fields[0] == "revision") baseline.revision = fields[1];
if (fields.size() == 3 && fields[0] == "group") baseline.groups[fields[1]] = fields[2];
continue;
}
const std::vector<std::string> fields = split(line, '\t');
if (!sawHeader) {
if (fields.size() < 4 || fields[0] != "scenario") return std::nullopt;
sawHeader = true;
continue;
}
if (fields.size() < 4) return std::nullopt;
BaselineValue value{ fields[0], fields[1], fields[2], std::stod(fields[3]), {} };
if (fields.size() >= 5) value.repeats = numbers(fields[4]);
if (value.repeats.empty()) value.repeats.push_back(value.value);
baseline.values.push_back(std::move(value));
}
if (!sawHeader) return std::nullopt;
return baseline;
}
std::vector<Metric> metricsFromRecordedFrames(const std::string& tsv) {
std::istringstream input(tsv);
std::string line;
if (!std::getline(input, line)) return {};
const std::vector<std::string> columns = split(line, '\t');
std::vector<std::vector<double>> values(columns.size());
while (std::getline(input, line)) {
const std::vector<std::string> fields = split(line, '\t');
for (size_t index = 1; index < fields.size() && index < columns.size(); index++)
values[index].push_back(std::stod(fields[index]));
}
std::vector<Metric> metrics;
for (size_t index = 1; index < columns.size(); index++) {
std::string name = columns[index];
if (name == "wall_ms") name = "frame.wall";
if (name == "cpu_ms") name = "frame.cpu";
std::vector<double> series = values[index];
if (name.rfind("gpu.", 0) == 0) std::erase_if(series, [](double value) { return value <= 0; });
metrics.push_back({ name, "ms", summarize(series), {}, true });
}
return metrics;
}
std::vector<ScenarioResult> resultsFromBaseline(const Baseline& stored) {
std::vector<ScenarioResult> results;
auto resultFor = [&](const std::string& name) -> ScenarioResult& {
for (ScenarioResult& result : results) {
if (result.name == name) return result;
}
ScenarioResult result;
result.name = name;
const auto group = stored.groups.find(name);
result.group = group == stored.groups.end() ? std::string("stored") : group->second;
results.push_back(std::move(result));
return results.back();
};
for (const BaselineValue& value : stored.values) {
ScenarioResult& result = resultFor(value.scenario);
if (value.statistic == "count") {
result.counterTotals[value.metric] = value.value;
continue;
}
auto metric = std::find_if(result.frameMetrics.begin(), result.frameMetrics.end(), [&](const Metric& entry) {
return entry.name == value.metric;
});
if (metric == result.frameMetrics.end()) {
result.frameMetrics.push_back({ value.metric, "ms", {}, {}, true });
metric = result.frameMetrics.end() - 1;
}
metric->repeats[value.statistic] = value.repeats;
if (value.statistic == "median") metric->statistics.median = value.value;
if (value.statistic == "p95") metric->statistics.p95 = value.value;
if (value.statistic == "mean") metric->statistics.mean = value.value;
if (value.statistic == "p99") metric->statistics.p99 = value.value;
if (value.statistic == "max") metric->statistics.maximum = value.value;
if (value.statistic == "total") metric->statistics.total = value.value;
}
return results;
}
std::vector<double> Metric::repeatValues(const std::string& statistic) const {
const auto found = repeats.find(statistic);
if (found != repeats.end() && !found->second.empty()) return found->second;
return { statisticValue(statistics, statistic) };
}
SampleSummary describeSamples(const std::vector<double>& samples) {
SampleSummary summary;
summary.count = samples.size();
if (samples.empty()) return summary;
const ProfileStatistics statistics = summarize(samples);
summary.median = statistics.median;
if (samples.size() < 2 || statistics.mean <= 0) return summary;
double squares = 0;
for (double value : samples)
squares += (value - statistics.mean) * (value - statistics.mean);
summary.variation = std::sqrt(squares / (double) (samples.size() - 1)) / statistics.mean;
return summary;
}
double mannWhitneyPValue(const std::vector<double>& before, const std::vector<double>& after) {
const size_t first = before.size();
const size_t second = after.size();
if (first < 2 || second < 2) return 1;
std::vector<std::pair<double, int>> pooled;
for (double value : before)
pooled.push_back({ value, 0 });
for (double value : after)
pooled.push_back({ value, 1 });
std::sort(pooled.begin(), pooled.end());
double rankSumAfter = 0;
double tieCorrection = 0;
for (size_t index = 0; index < pooled.size();) {
size_t end = index;
while (end < pooled.size() && pooled[end].first == pooled[index].first)
end++;
const double rank = ((double) index + (double) end + 1.0) / 2.0;
const double ties = (double) (end - index);
tieCorrection += ties * ties * ties - ties;
for (size_t member = index; member < end; member++) {
if (pooled[member].second == 1) rankSumAfter += rank;
}
index = end;
}
const double n1 = (double) first;
const double n2 = (double) second;
const double total = n1 + n2;
const double u = rankSumAfter - n2 * (n2 + 1.0) / 2.0;
if (tieCorrection == 0 && first <= kExactSampleLimit && second <= kExactSampleLimit)
return exactMannWhitneyPValue(first, second, u);
const double mean = n1 * n2 / 2.0;
const double variance = n1 * n2 / 12.0 * ((total + 1.0) - tieCorrection / (total * (total - 1.0)));
if (variance <= 0) return 1;
const double z = (std::fabs(u - mean) - 0.5) / std::sqrt(variance);
return std::min(1.0, 2.0 * normalTail(std::max(0.0, z)));
}
std::vector<double> falseDiscoveryAdjusted(const std::vector<double>& pValues) {
const size_t count = pValues.size();
std::vector<size_t> order(count);
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](size_t left, size_t right) { return pValues[left] < pValues[right]; });
std::vector<double> adjusted(count, 1.0);
double running = 1.0;
for (size_t rank = count; rank > 0; rank--) {
const size_t index = order[rank - 1];
running = std::min(running, pValues[index] * (double) count / (double) rank);
adjusted[index] = std::min(1.0, running);
}
return adjusted;
}
const char* verdictName(Verdict verdict) {
switch (verdict) {
case Verdict::Faster: return "faster";
case Verdict::Slower: return "slower";
case Verdict::WithinNoise: return "within noise";
case Verdict::Unmeasured: return "unmeasured";
}
return "";
}
std::string resultsTsv(const RunInfo& info, const std::vector<ScenarioResult>& results) {
std::string text = "#revision\t" + info.revision + "\n";
text += "#build\t" + info.buildType + "\n";
text += "#device\t" + info.device + "\n";
text += "#date\t" + info.date + "\n";
text += "#pacing\t" + info.pacing + "\n";
text += "#repeats\t" + std::to_string(info.repeats) + "\n";
for (const ScenarioResult& result : results)
text += "#group\t" + result.name + "\t" + result.group + "\n";
text += "scenario\tmetric\tstatistic\tvalue\trepeats\n";
for (const ScenarioResult& result : results) {
for (const std::vector<Metric>* list : { &result.frameMetrics, &result.sectionMetrics, &result.gpuMetrics }) {
for (const Metric& metric : *list) {
for (const char* statistic : kStatistics) {
text += result.name + "\t" + metric.name + "\t" + statistic + "\t" +
format("%.6f", statisticValue(metric.statistics, statistic)) + "\t" +
joined(metric.repeatValues(statistic)) + "\n";
}
}
}
for (const auto& [name, value] : result.counterTotals)
text += result.name + "\t" + name + "\tcount\t" + format("%.0f", value) + "\t\n";
}
return text;
}
std::vector<Change>
compare(const Baseline& baseline, const std::vector<ScenarioResult>& results, const ComparisonPolicy& policy) {
std::vector<Change> changes;
for (const ScenarioResult& result : results) {
for (const KeyMetric& key : keyMetrics()) {
const Metric* metric = result.metric(key.metric);
const BaselineValue* before = baseline.find(result.name, key.metric, key.statistic);
if (!metric || !before) continue;
const std::vector<double> afterSamples = metric->repeatValues(key.statistic);
Change change;
change.scenario = result.name;
change.group = result.group;
change.metric = key.metric;
change.statistic = key.statistic;
change.before = describeSamples(before->repeats);
change.after = describeSamples(afterSamples);
change.pValue = mannWhitneyPValue(before->repeats, afterSamples);
const bool sampled = change.before.count >= 2 && change.after.count >= 2;
const double difference = change.after.median - change.before.median;
const double noise = std::max(change.before.variation, change.after.variation) * change.before.median;
const bool large = std::fabs(difference) > policy.absoluteFloorMs &&
std::fabs(change.change()) > policy.minimumEffect && std::fabs(difference) > 2.0 * noise;
change.verdict = !large ? Verdict::WithinNoise : (sampled ? Verdict::Slower : Verdict::Unmeasured);
if (change.verdict == Verdict::Slower && difference < 0) change.verdict = Verdict::Faster;
changes.push_back(change);
}
}
std::vector<double> pValues;
for (const Change& change : changes)
pValues.push_back(change.pValue);
const std::vector<double> qValues = falseDiscoveryAdjusted(pValues);
for (size_t index = 0; index < changes.size(); index++) {
Change& change = changes[index];
change.qValue = qValues[index];
const bool decided = change.verdict == Verdict::Slower || change.verdict == Verdict::Faster;
if (decided && change.pValue >= policy.significance) change.verdict = Verdict::WithinNoise;
}
return changes;
}
std::vector<GroupChange> summarizeChanges(const std::vector<Change>& changes) {
std::vector<GroupChange> groups;
for (const Change& change : changes) {
auto found = std::find_if(groups.begin(), groups.end(), [&](const GroupChange& group) {
return group.group == change.group && group.metric == change.metric + " " + change.statistic;
});
if (found == groups.end()) {
groups.push_back({ change.group, change.metric + " " + change.statistic, 0, 0, 0, 0 });
found = groups.end() - 1;
}
found->scenarios++;
found->geometricMeanRatio += std::log(std::max(change.ratio(), 1e-9));
if (change.verdict == Verdict::Slower) found->slower++;
if (change.verdict == Verdict::Faster) found->faster++;
}
for (GroupChange& group : groups)
group.geometricMeanRatio = std::exp(group.geometricMeanRatio / (double) std::max<size_t>(group.scenarios, 1));
return groups;
}
size_t countVerdicts(const std::vector<Change>& changes, Verdict verdict) {
return (size_t) std::count_if(changes.begin(), changes.end(), [verdict](const Change& change) {
return change.verdict == verdict;
});
}
std::string markdownReport(
const RunInfo& info,
const std::vector<ScenarioResult>& results,
const std::optional<Baseline>& baseline,
const ComparisonPolicy& policy
) {
std::string text = "# sketch2d benchmark\n\n";
text += "| | |\n|---|---|\n";
text += "| revision | " + info.revision + " |\n";
text += "| build | " + info.buildType + " |\n";
text += "| device | " + info.device + " |\n";
text += "| date | " + info.date + " |\n";
text += "| inputs | " + info.inputs + " |\n";
text += "| pacing | " + info.pacing + " |\n";
text += "| repeats | " + std::to_string(info.repeats) + " |\n";
text += "| peak memory | " + format("%.0f", info.peakResidentMegabytes) + " MB |\n";
text += "| baseline | " + (baseline ? baseline->revision : std::string("none")) + " |\n\n";
text += "All times are milliseconds. Input latency is the time from an event's recorded timestamp to the end of "
"the frame that consumed it. Frame wall/cpu cover one main-loop iteration; process and draw are its "
"input-handling and painting halves. GPU columns are timestamp-query durations of the canvas render "
"pass and the window frame. Values in the summary are pooled over every frame of every repeat.\n\n";
if (baseline) {
const std::vector<Change> changes = compare(*baseline, results, policy);
text += "## Comparison with " + baseline->revision + "\n\n";
text += "Each key metric is computed once per repeat; the median over repeats is compared and its spread is "
"the coefficient of variation. A change is reported as faster or slower when the Mann-Whitney U test "
"(exact for small tie-free samples) gives p < " +
format("%.2f", policy.significance) + ", the medians differ by more than " +
format("%.0f", policy.minimumEffect * 100.0) + "% and " + format("%.2f", policy.absoluteFloorMs) +
" ms, and the difference exceeds twice the run-to-run spread. With a single repeat on either side "
"no significance test is possible; large differences are then listed as unmeasured. Testing many metrics "
"produces some flags by chance: the expected count of false flags is shown next to the verdicts, and q "
"is the Benjamini-Hochberg false-discovery-adjusted p over the whole run (q < 0.05 survives the "
"multiple comparisons).\n\n";
text +=
"Expected false flags at this significance: " + format("%.1f", policy.significance * (double) changes.size()) +
" of " + std::to_string(changes.size()) + " compared metrics.\n\n";
text += "| verdict | metrics |\n|---|---:|\n";
for (Verdict verdict : { Verdict::Slower, Verdict::Faster, Verdict::WithinNoise, Verdict::Unmeasured })
text +=
"| " + std::string(verdictName(verdict)) + " | " + std::to_string(countVerdicts(changes, verdict)) + " |\n";
text += "\n### By group\n\nGeometric mean of after/before median ratios over the group's scenarios.\n\n";
text += "| group | metric | scenarios | mean ratio | slower | faster |\n|---|---|---:|---:|---:|---:|\n";
for (const GroupChange& group : summarizeChanges(changes)) {
text += "| " + group.group + " | " + group.metric + " | " + std::to_string(group.scenarios) + " | " +
format("%.3f", group.geometricMeanRatio) + " | " + std::to_string(group.slower) + " | " +
std::to_string(group.faster) + " |\n";
}
text += "\n### Significant changes\n\n";
bool any = false;
for (const Change& change : changes) {
if (change.verdict != Verdict::Slower && change.verdict != Verdict::Faster) continue;
if (!any)
text += "| scenario | metric | before | after | change | p | q | verdict "
"|\n|---|---|---:|---:|---:|---:|---:|---|\n";
any = true;
text += "| " + change.scenario + " | " + change.metric + " " + change.statistic + " | " +
spread(change.before) + " | " + spread(change.after) + " | " + percent(change.change()) + " | " +
format("%.3f", change.pValue) + " | " + format("%.3f", change.qValue) + " | " +
verdictName(change.verdict) + " |\n";
}
if (!any) text += "None.\n";
text += "\n<details><summary>Every key metric against the baseline</summary>\n\n";
text +=
"| scenario | metric | before | after | change | p | q | verdict |\n|---|---|---:|---:|---:|---:|---:|---|\n";
for (const Change& change : changes) {
text += "| " + change.scenario + " | " + change.metric + " " + change.statistic + " | " +
spread(change.before) + " | " + spread(change.after) + " | " + percent(change.change()) + " | " +
format("%.3f", change.pValue) + " | " + format("%.3f", change.qValue) + " | " +
verdictName(change.verdict) + " |\n";
}
text += "\n</details>\n\n";
}
text += "## Summary\n\n";
for (const std::string& group : groupsInOrder(results)) {
text += "### " + group + "\n\n";
appendSummaryTable(text, results, group);
}
bool anyLive = false;
for (const ScenarioResult& result : results)
anyLive = anyLive || !result.liveMetrics.empty();
if (anyLive) {
text += "## Live session versus replay\n\n";
text += "Live is what the profiler measured while the session was recorded on the user's machine; replay is "
"this run. Large differences mean the replay does not reproduce the experience (other machine, "
"build or load), not that either number is wrong.\n\n";
for (const ScenarioResult& result : results) {
if (result.liveMetrics.empty()) continue;
text += "#### " + result.name + "\n\n";
text += "| metric | live median | live p95 | live max | replay median | replay p95 | replay max |\n";
text += "|---|---:|---:|---:|---:|---:|---:|\n";
for (const char* name : { "frame.wall",
"frame.cpu",
"frame.process",
"frame.draw",
"ui.dispatch",
"brush.dab",
"canvas.render",
"canvas.upload",
"gpu.canvas",
"gpu.window" }) {
const Metric* replay = result.metric(name);
const auto live =
std::find_if(result.liveMetrics.begin(), result.liveMetrics.end(), [&](const Metric& metric) {
return metric.name == name;
});
if (!replay || live == result.liveMetrics.end()) continue;
text += "| " + std::string(name) + " | " + ms(live->statistics.median) + " | " + ms(live->statistics.p95) +
" | " + ms(live->statistics.maximum) + " | " + ms(replay->statistics.median) + " | " +
ms(replay->statistics.p95) + " | " + ms(replay->statistics.maximum) + " |\n";
}
text += "\n";
}
}
text += "## Processing breakdown\n\n";
text += "Section times are inclusive: frame.present contains frame.draw, which contains ui.paint, canvas.view and "
"canvas.render (composite, mips, upload, record); frame.process contains ui.dispatch, which contains "
"stroke.paint and brush.dab (brush.stamp or brush.smudge_dab). Calls and counters are per repeat.\n\n";
for (const ScenarioResult& result : results)
appendSectionBreakdown(text, result);
return text;
}
}

View file

@ -0,0 +1,137 @@
#pragma once
#include "Profiler.hpp"
#include <map>
#include <optional>
#include <string>
#include <vector>
namespace tp::sketch2d::bench {
struct Metric {
std::string name;
std::string unit;
ProfileStatistics statistics;
std::map<std::string, std::vector<double>> repeats;
bool lowerIsBetter = true;
[[nodiscard]] std::vector<double> repeatValues(const std::string& statistic) const;
};
struct ScenarioResult {
std::string name;
std::string group;
std::string input;
std::string description;
double inputMs = 0;
double wallMs = 0;
double processCpuMs = 0;
size_t frames = 0;
size_t inputEvents = 0;
size_t dabs = 0;
size_t tiles = 0;
std::vector<Metric> frameMetrics;
std::vector<Metric> sectionMetrics;
std::vector<Metric> gpuMetrics;
std::vector<Metric> liveMetrics;
std::map<std::string, double> counterTotals;
[[nodiscard]] const Metric* metric(const std::string& name) const;
};
struct RunInfo {
std::string revision;
std::string buildType;
std::string device;
std::string date;
std::string inputs;
std::string pacing;
int repeats = 1;
double peakResidentMegabytes = 0;
};
struct BaselineValue {
std::string scenario;
std::string metric;
std::string statistic;
double value = 0;
std::vector<double> repeats;
};
struct Baseline {
std::string revision;
std::vector<BaselineValue> values;
std::map<std::string, std::string> groups;
[[nodiscard]] const BaselineValue*
find(const std::string& scenario, const std::string& metric, const std::string& statistic) const;
[[nodiscard]] static std::optional<Baseline> parse(const std::string& tsv);
};
struct SampleSummary {
size_t count = 0;
double median = 0;
double variation = 0;
};
[[nodiscard]] SampleSummary describeSamples(const std::vector<double>& samples);
[[nodiscard]] double mannWhitneyPValue(const std::vector<double>& before, const std::vector<double>& after);
[[nodiscard]] std::vector<double> falseDiscoveryAdjusted(const std::vector<double>& pValues);
enum class Verdict { Faster, Slower, WithinNoise, Unmeasured };
[[nodiscard]] const char* verdictName(Verdict verdict);
struct Change {
std::string scenario;
std::string group;
std::string metric;
std::string statistic;
SampleSummary before;
SampleSummary after;
double pValue = 1;
double qValue = 1;
Verdict verdict = Verdict::Unmeasured;
[[nodiscard]] double ratio() const { return before.median > 0 ? after.median / before.median : 1.0; }
[[nodiscard]] double change() const { return ratio() - 1.0; }
};
struct ComparisonPolicy {
double minimumEffect = 0.05;
double absoluteFloorMs = 0.05;
double significance = 0.01;
};
struct KeyMetric {
const char* metric;
const char* statistic;
};
struct GroupChange {
std::string group;
std::string metric;
size_t scenarios = 0;
double geometricMeanRatio = 1;
size_t slower = 0;
size_t faster = 0;
};
[[nodiscard]] const std::vector<KeyMetric>& keyMetrics();
[[nodiscard]] double statisticValue(const ProfileStatistics& statistics, const std::string& statistic);
[[nodiscard]] std::vector<Metric> metricsFromRecordedFrames(const std::string& tsv);
[[nodiscard]] std::vector<ScenarioResult> resultsFromBaseline(const Baseline& stored);
[[nodiscard]] std::string resultsTsv(const RunInfo& info, const std::vector<ScenarioResult>& results);
[[nodiscard]] std::vector<Change>
compare(const Baseline& baseline, const std::vector<ScenarioResult>& results, const ComparisonPolicy& policy);
[[nodiscard]] std::vector<GroupChange> summarizeChanges(const std::vector<Change>& changes);
[[nodiscard]] size_t countVerdicts(const std::vector<Change>& changes, Verdict verdict);
[[nodiscard]] std::string markdownReport(
const RunInfo& info,
const std::vector<ScenarioResult>& results,
const std::optional<Baseline>& baseline,
const ComparisonPolicy& policy
);
}

View file

@ -0,0 +1,262 @@
#include "BenchRunner.hpp"
#include "Sketch2DApplication.hpp"
#include <algorithm>
#include "Window.hpp"
#include <chrono>
namespace tp::sketch2d::bench {
namespace {
constexpr int kDrainFrameLimit = 400;
constexpr halnf kPrepaintDabSize = 128;
constexpr halnf kPrepaintStep = 48;
constexpr double kFrameWaitSliceMs = 2;
const char* const kRepeatStatistics[] = { "mean", "median", "p95", "p99", "max", "total" };
double nowMs() {
const auto now = std::chrono::steady_clock::now().time_since_epoch();
return std::chrono::duration<double, std::milli>(now).count();
}
class MetricSeries {
public:
void add(const std::string& name, const std::vector<double>& values) {
Entry& entry = entryFor(name);
entry.pooled.insert(entry.pooled.end(), values.begin(), values.end());
const ProfileStatistics repeat = summarize(values);
for (const char* statistic : kRepeatStatistics)
entry.repeats[statistic].push_back(statisticValue(repeat, statistic));
}
[[nodiscard]] std::vector<Metric> metrics() const {
std::vector<Metric> result;
for (const Entry& entry : mEntries) {
Metric metric;
metric.name = entry.name;
metric.unit = "ms";
metric.statistics = summarize(entry.pooled);
metric.repeats = entry.repeats;
result.push_back(std::move(metric));
}
return result;
}
private:
struct Entry {
std::string name;
std::vector<double> pooled;
std::map<std::string, std::vector<double>> repeats;
};
Entry& entryFor(const std::string& name) {
for (Entry& entry : mEntries) {
if (entry.name == name) return entry;
}
mEntries.push_back({ name, {}, {} });
return mEntries.back();
}
std::vector<Entry> mEntries;
};
std::vector<double> frameValues(const std::vector<FrameProfile>& frames, double FrameProfile::* field) {
std::vector<double> values;
for (const FrameProfile& frame : frames)
values.push_back(frame.*field);
return values;
}
InputRecording fittedToWindow(const InputRecording& input, Vec2F windowSize) {
if (input.windowSize.x <= 0 || input.windowSize.y <= 0) return input;
if (input.windowSize == windowSize) return input;
InputRecording fitted = input;
const Vec2F scale = { windowSize.x / input.windowSize.x, windowSize.y / input.windowSize.y };
for (RecordedInput& event : fitted.events) {
if (event.kind != RecordedInput::Kind::Move) continue;
event.vector = { event.vector.x * scale.x, event.vector.y * scale.y };
}
fitted.windowSize = windowSize;
return fitted;
}
}
void BenchRunner::apply(const Scenario& scenario) {
Sketch2DApplication& application = mApplication;
if (scenario.session) {
scenario.session->applyTo(application.document(), application.brush(), application.root().canvas.settings());
return;
}
Brush& target = application.brush();
target.apply(*findBrushPreset(scenario.brush));
target.setSize(scenario.size);
target.setStabilizer(scenario.stabilizer);
target.setEraserMode(scenario.eraserMode);
if (scenario.tune) scenario.tune(target);
ViewSettings& settings = application.root().canvas.settings();
settings.useBrushStabilizer = true;
application.document().resetView();
application.document().setZoom(scenario.zoom);
}
BenchRunner::BenchRunner(Sketch2DApplication& application, RunOptions options) :
mApplication(application),
mOptions(options) {
mApplication.window().setPlatformInputEnabled(false);
mApplication.start();
mApplication.window().injectInput().onKeyboardFocus(true);
for (int frame = 0; frame < 3; frame++)
mApplication.step(16);
}
BenchRunner::~BenchRunner() {
mApplication.finish();
mApplication.window().setPlatformInputEnabled(true);
}
void BenchRunner::resetDocument() {
Profiler::global().setEnabled(false);
mApplication.document().reset();
mApplication.root().refreshFromState();
for (int frame = 0; frame < 3; frame++)
mApplication.step(16);
}
void BenchRunner::prepaint() {
Document& document = mApplication.document();
Brush paint;
paint.apply(*findBrushPreset(BrushType::Round));
paint.setSize(kPrepaintDabSize);
paint.color = { 0.8f, 0.3f, 0.2f, 1.0f };
const RectF canvas = mApplication.root().canvas.windowRect();
const Vec2F topLeft = document.screenToWorld({ 0, 0 });
const Vec2F bottomRight = document.screenToWorld(canvas.size);
for (halnf y = topLeft.y; y <= bottomRight.y; y += kPrepaintStep / document.zoom()) {
for (halnf x = topLeft.x; x <= bottomRight.x; x += kPrepaintStep / document.zoom())
document.stampBrushDab({ x, y }, paint);
}
document.finishStroke("Prepaint");
for (int frame = 0; frame < 3; frame++)
mApplication.step(16);
}
void BenchRunner::replay(const InputRecording& input, std::vector<double>& latencies, size_t& frames) {
WindowInputObserver& injector = mApplication.window().injectInput();
Window& window = mApplication.window();
const double start = nowMs();
double previousFrame = start;
double oldestPending = -1;
size_t next = 0;
while (next < input.events.size() || oldestPending >= 0) {
const double elapsed = nowMs() - start;
while (next < input.events.size() && input.events[next].timeMs <= elapsed) {
if (oldestPending < 0) oldestPending = input.events[next].timeMs;
input.events[next].deliverTo(injector);
next++;
}
const double untilNext = next < input.events.size() ? input.events[next].timeMs - elapsed : 0.0;
if (mOptions.pacedByCompositor && window.isWaitingForFrame()) {
window.processEventsFor((halnf) std::clamp(untilNext, 0.05, kFrameWaitSliceMs));
continue;
}
const bool wantsFrame = oldestPending >= 0 || mApplication.ui().needsFrame();
if (!wantsFrame) {
window.processEventsFor((halnf) std::clamp(untilNext, 0.05, kFrameWaitSliceMs));
continue;
}
const double frameStart = nowMs();
mApplication.step((halnf) (frameStart - previousFrame));
previousFrame = frameStart;
frames++;
if (oldestPending >= 0) latencies.push_back(nowMs() - start - oldestPending);
oldestPending = -1;
}
}
void BenchRunner::drain(size_t& frames) {
for (int frame = 0; frame < kDrainFrameLimit; frame++) {
mApplication.step(16);
frames++;
if (!mApplication.ui().needsFrame() && !mApplication.root().canvas.isPainting()) break;
}
}
ScenarioResult BenchRunner::run(const Scenario& scenario, const InputRecording& rawInput) {
const InputRecording input = fittedToWindow(rawInput, mApplication.window().getSize());
ScenarioResult result;
result.name = scenario.name;
result.group = scenario.group;
result.input = scenario.input;
result.description = scenario.describe();
result.inputMs = input.durationMs();
result.inputEvents = input.events.size();
Profiler& profiler = Profiler::global();
MetricSeries frameSeries;
MetricSeries sectionSeries;
MetricSeries gpuSeries;
size_t frameCount = 0;
const int repeats = std::max(1, mOptions.repeats);
for (int repeat = 0; repeat < repeats; repeat++) {
resetDocument();
apply(scenario);
mApplication.root().refreshFromState();
if (scenario.prepaint) prepaint();
mApplication.step(16);
profiler.clear();
profiler.setEnabled(true);
const double cpuStart = Profiler::processCpuMs();
const double wallStart = nowMs();
std::vector<double> latencies;
replay(input, latencies, frameCount);
drain(frameCount);
result.wallMs += (nowMs() - wallStart) / repeats;
result.processCpuMs += (Profiler::processCpuMs() - cpuStart) / repeats;
profiler.setEnabled(false);
const std::vector<FrameProfile>& frames = profiler.frames();
result.frames += frames.size();
result.tiles = std::max<size_t>(result.tiles, mApplication.document().tileCount());
frameSeries.add("frame.wall", frameValues(frames, &FrameProfile::wallMs));
frameSeries.add("frame.cpu", frameValues(frames, &FrameProfile::cpuMs));
frameSeries.add("input.latency", latencies);
for (const std::string& name : profiler.counterNames())
result.counterTotals[name] += profiler.counterTotal(name) / repeats;
for (const std::string& name : profiler.sectionNames()) {
result.counterTotals["calls " + name] += (double) profiler.sectionCallTotal(name) / repeats;
sectionSeries.add(name, profiler.sectionSeries(name));
}
for (const std::string& name : profiler.gpuSectionNames()) {
std::vector<double> measured;
for (double value : profiler.gpuSeries(name)) {
if (value > 0) measured.push_back(value);
}
gpuSeries.add(name, measured);
}
}
result.frames /= (size_t) repeats;
result.dabs = (size_t) result.counterTotals["brush.dabs"];
result.frameMetrics = frameSeries.metrics();
result.sectionMetrics = sectionSeries.metrics();
result.gpuMetrics = gpuSeries.metrics();
return result;
}
}

View file

@ -0,0 +1,41 @@
#pragma once
#include "BenchReport.hpp"
#include "BenchScenario.hpp"
#include <map>
#include <string>
#include <vector>
namespace tp::sketch2d {
class Sketch2DApplication;
}
namespace tp::sketch2d::bench {
struct RunOptions {
int repeats = 1;
bool pacedByCompositor = true;
};
class BenchRunner {
public:
BenchRunner(Sketch2DApplication& application, RunOptions options);
~BenchRunner();
BenchRunner(const BenchRunner&) = delete;
BenchRunner& operator=(const BenchRunner&) = delete;
[[nodiscard]] ScenarioResult run(const Scenario& scenario, const InputRecording& input);
private:
void apply(const Scenario& scenario);
void resetDocument();
void prepaint();
void replay(const InputRecording& input, std::vector<double>& latencies, size_t& frames);
void drain(size_t& frames);
Sketch2DApplication& mApplication;
RunOptions mOptions;
};
}

View file

@ -0,0 +1,149 @@
#include "BenchScenario.hpp"
#include <cctype>
#include <cstdio>
namespace tp::sketch2d::bench {
namespace {
std::string number(halnf value) {
char text[32];
std::snprintf(text, sizeof(text), "%g", value);
return text;
}
std::string presetLabel(BrushType type) {
const BrushPreset* preset = findBrushPreset(type);
return preset ? preset->name : "brush";
}
std::string slug(std::string text) {
for (char& character : text) {
if (character == ' ') character = '-';
else character = (char) std::tolower((unsigned char) character);
}
return text;
}
Scenario base(const std::string& group, const std::string& input, BrushType brush, halnf size) {
Scenario scenario;
scenario.group = group;
scenario.input = input;
scenario.brush = brush;
scenario.size = size;
return scenario;
}
void addSizeSweep(std::vector<Scenario>& scenarios, const std::string& input) {
for (halnf size : { 4.0f, 20.0f, 80.0f, 250.0f }) {
Scenario scenario = base("brush size", input, BrushType::Round, size);
scenario.name = "round size " + number(size);
scenarios.push_back(scenario);
}
}
void addZoomSweep(std::vector<Scenario>& scenarios, const std::string& input) {
for (halnf zoom : { 0.25f, 1.0f, 4.0f }) {
Scenario scenario = base("zoom", input, BrushType::Round, 20);
scenario.zoom = zoom;
scenario.name = "round zoom " + number(zoom) + "x";
scenarios.push_back(scenario);
}
}
void addPresetSweep(std::vector<Scenario>& scenarios, const std::string& input) {
for (BrushType type : { BrushType::Ink,
BrushType::Pencil,
BrushType::Marker,
BrushType::Calligraphy,
BrushType::Jagged,
BrushType::Flat,
BrushType::Air }) {
Scenario scenario = base("brush preset", input, type, 40);
scenario.name = slug(presetLabel(type)) + " size 40";
scenarios.push_back(scenario);
}
}
void addParameterSweep(std::vector<Scenario>& scenarios, const std::string& input) {
Scenario textured = base("brush parameters", input, BrushType::Round, 60);
textured.name = "round textured";
textured.tune = [](Brush& brush) { brush.set(BrushParameter::Texture, 1); };
scenarios.push_back(textured);
Scenario dense = base("brush parameters", input, BrushType::Round, 60);
dense.name = "round spacing 1%";
dense.tune = [](Brush& brush) { brush.setSpacing(0.01f); };
scenarios.push_back(dense);
Scenario soft = base("brush parameters", input, BrushType::Round, 60);
soft.name = "round soft half opacity";
soft.tune = [](Brush& brush) {
brush.setHardness(0);
brush.setOpacity(0.5f);
};
scenarios.push_back(soft);
}
void addStabilizerSweep(std::vector<Scenario>& scenarios, const std::string& input) {
for (halnf amount : { 0.0f, 0.325f, 0.7f, 1.0f }) {
Scenario scenario = base("stabilizer", input, BrushType::Round, 20);
scenario.stabilizer = amount;
scenario.name = "round stabilizer " + number(amount);
scenarios.push_back(scenario);
}
}
void addEraserSweep(std::vector<Scenario>& scenarios, const std::string& input) {
for (halnf size : { 20.0f, 120.0f }) {
Scenario scenario = base("eraser", input, BrushType::Eraser, size);
scenario.prepaint = true;
scenario.name = "eraser size " + number(size);
scenarios.push_back(scenario);
}
Scenario zoomed = base("eraser", input, BrushType::Eraser, 40);
zoomed.prepaint = true;
zoomed.zoom = 0.25f;
zoomed.name = "eraser zoom 0.25x";
scenarios.push_back(zoomed);
Scenario mode = base("eraser", input, BrushType::Calligraphy, 40);
mode.prepaint = true;
mode.eraserMode = true;
mode.name = "calligraphy eraser mode";
scenarios.push_back(mode);
}
}
std::string Scenario::describe() const {
if (session) {
const SessionSnapshot& recorded = *session;
return "recorded session from " + recorded.environment.date + " on " + recorded.environment.device + " at " +
recorded.environment.revision + ": " + presetLabel(recorded.brush.type) + ", size " +
number(recorded.brush.size()) + ", zoom " + number(recorded.view.zoom) + "x, " +
std::to_string(recorded.document.layers.size()) + " layers";
}
std::string text =
presetLabel(brush) + ", size " + number(size) + ", zoom " + number(zoom) + "x, stabilizer " + number(stabilizer);
if (eraserMode) text += ", eraser mode";
if (prepaint) text += ", on painted layer";
return text;
}
std::vector<Scenario> brushAndEraserScenarios(const std::vector<std::string>& inputs) {
std::vector<Scenario> scenarios;
for (const std::string& input : inputs) {
const size_t first = scenarios.size();
addSizeSweep(scenarios, input);
addZoomSweep(scenarios, input);
addPresetSweep(scenarios, input);
addParameterSweep(scenarios, input);
addStabilizerSweep(scenarios, input);
addEraserSweep(scenarios, input);
if (inputs.size() < 2) continue;
for (size_t index = first; index < scenarios.size(); index++)
scenarios[index].name = input + " / " + scenarios[index].name;
}
return scenarios;
}
}

View file

@ -0,0 +1,36 @@
#pragma once
#include "Brush.hpp"
#include "Session.hpp"
#include "InputRecording.hpp"
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace tp::sketch2d::bench {
struct Scenario {
std::string name;
std::string group;
std::string input;
BrushType brush = BrushType::Round;
halnf size = 20;
halnf zoom = 1;
halnf stabilizer = 0;
bool eraserMode = false;
bool prepaint = false;
std::function<void(Brush&)> tune;
std::shared_ptr<const SessionSnapshot> session;
[[nodiscard]] std::string describe() const;
};
struct BenchInputs {
std::string name;
InputRecording recording;
};
[[nodiscard]] std::vector<Scenario> brushAndEraserScenarios(const std::vector<std::string>& inputs);
}

89
Sketch2D/bench/README.md Normal file
View file

@ -0,0 +1,89 @@
# Sketch2D benchmark
`sketch2dBench` replays scripted drawing input through the real Sketch2D application
(window, Vulkan, compositor pacing) and reports where the time goes.
## Running
tools/sketch2d-bench.sh # Release build, all brush and eraser scenarios
tools/sketch2d-bench.sh --baseline 1a2b3c4 # compare with a stored revision
tools/sketch2d-bench.sh --filter eraser # only matching scenarios
tools/sketch2d-bench.sh --repeats 10 # tighter statistics
Results go to `bench/history/<revision>/` (`report.md` for reading,
`results.tsv` for tools and later comparisons). Without `--baseline` the
newest earlier revision in `bench/history` is used. A revision with
uncommitted changes is stored as `<revision>-dirty`.
## Recording a session in the app
In Sketch2D open **Debug ▸ Record session**. A red `● REC 0:12` badge appears in
the toolbar; draw normally. Stop with **Debug ▸ Stop recording and save** or
by clicking the badge. The session is written to
`~/sketch2d/sessions/<date_time>/` (or `$MODULES_SESSION_DIR/<date_time>/`) and the
path is shown in a notice:
| file | contents |
|---|---|
| `session.txt` | revision, GPU, date, window size and scale, zoom and pan, the full brush (type, preset, colour, all 26 parameters, pressure flags, size limits, stabilizer, marker / linear / eraser modes), view settings, background, frame, every layer's properties and order, active layer |
| `layers/layer-N.tiles` | the exact pixels of every layer when recording started |
| `input.txt` | every raw window event before coalescing, with its time: pen tool, pressure, tilt, rotation, distance, device timestamp, positions, buttons, keys, scroll |
| `recorded-frames.tsv` | what the user actually experienced: per frame wall and CPU time, every profiled section, GPU times and counters |
Replay it:
tools/sketch2d-bench.sh --session ~/sketch2d/sessions/2026-10-15_20-49-31
This restores the document, view, brush and settings exactly, replays the
input with its original timing through the real window path, and writes
`replay/report.md` next to the recording. Besides the normal statistics the
report has a *Live session versus replay* table: the frame, stage and GPU
timings the user had while drawing beside the replayed ones. Add `--matrix`
to run the brush and eraser scenarios in the same report.
Text input recordings in `bench/recordings/*.input` (the `input.txt` format)
are also replayed as extra inputs for the whole matrix.
## What is measured
Per frame: wall time, thread CPU time, every profiled section (inclusive) and
GPU timestamp durations of the canvas render pass and the window frame.
Per input event: latency from its recorded time to the end of the frame that
consumed it. Per run: process CPU, peak memory, dabs, tiles, uploads.
Frames follow compositor frame callbacks like the real main loop; use
`--unpaced` to render back to back and expose raw throughput instead.
## Comparison
Each scenario is replayed `--repeats` times (default 5). Every key metric
(input latency p95, frame wall median/p95, frame CPU p95, process p95, draw
p95, total dab time, canvas GPU p95) is computed per repeat, so a comparison
has a sample on each side, not a single number. The report shows, for every
scenario and key metric:
- median and coefficient of variation before and after (`4.000 ±2%`)
- the relative change of the medians
- the two-sided Mann-Whitney U p-value (exact for small tie-free samples)
- q, the Benjamini-Hochberg false-discovery-adjusted p over the whole run
- a verdict: **slower / faster** when p < 0.01, the medians differ by more
than 5 % and 0.05 ms, and the difference exceeds twice the run-to-run
spread; otherwise **within noise**; **unmeasured** when a large difference
has only one repeat to test
With 200 metrics compared, about two flags are expected by chance; the
report prints that number next to the verdict counts. Each scenario group is
also summarised as the geometric mean of after/before ratios with counts of
slower and faster cases, so broad shifts are visible even when no single
case is decisive. `--fail-on-regression` returns exit status 3 when any
metric is significantly slower.
Two stored runs can be compared again without rerunning:
sketch2dBench --baseline bench/history/A/results.tsv --compare bench/history/B/results.tsv --out /tmp/ab
Measured on this machine (Radeon 760M, headless mutter), the same build run
twice gave 0 slower and 1 faster of 200 metrics (expected by chance: 2).
An artificial 10 µs cost per dab was reported as 32 slower metrics with
q = 0.02, largest where dabs are densest (+1579 % dab time at size 4,
+10 % at size 80).

View file

@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

BIN
Sketch2D/rsc/icons/crop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

BIN
Sketch2D/rsc/icons/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
Sketch2D/rsc/icons/fill.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
Sketch2D/rsc/icons/hand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
Sketch2D/rsc/icons/line.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

BIN
Sketch2D/rsc/icons/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

BIN
Sketch2D/rsc/icons/move.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
Sketch2D/rsc/icons/redo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
Sketch2D/rsc/icons/size.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
Sketch2D/rsc/icons/tool.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
Sketch2D/rsc/icons/undo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
Sketch2D/rsc/icons/view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
Sketch2D/rsc/icons/wand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
Sketch2D/rsc/icons/zoom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,23 @@
#include "AndroidEntry.hpp"
#include "AndroidUpdater.hpp"
#include "Sketch2DApplication.hpp"
#include "ThreadPlacement.hpp"
namespace {
android_app* gApp = nullptr;
int runSketch2D() {
tp::ThreadPlacement::reserveCallingThreadForInteraction();
tp::sketch2d::AndroidUpdateBackend updates(*gApp);
tp::sketch2d::Sketch2DApplication application;
application.checkForUpdates(updates);
application.run();
return 0;
}
}
extern "C" void android_main(android_app* app) {
gApp = app;
const char* assets[] = { "rsc", "rsc/icons" };
tp::runAndroidApplication(app, runSketch2D, assets, 2);
}

View file

@ -0,0 +1,162 @@
#include "AndroidUpdater.hpp"
#include <android/looper.h>
#include <android_native_app_glue.h>
#include <jni.h>
namespace tp::sketch2d {
namespace {
struct AttachedEnv {
explicit AttachedEnv(JavaVM* vm) :
vm(vm) {
if (vm->GetEnv((void**) &env, JNI_VERSION_1_6) == JNI_EDETACHED) {
vm->AttachCurrentThread(&env, nullptr);
attached = true;
}
}
~AttachedEnv() {
if (attached) vm->DetachCurrentThread();
}
JavaVM* vm;
JNIEnv* env = nullptr;
bool attached = false;
};
using ProgressCallback = std::function<void(int8, int8)>;
void reportProgress(JNIEnv*, jclass, jlong handle, jlong received, jlong total) {
const auto* callback = reinterpret_cast<const ProgressCallback*>(handle);
if (callback && *callback) (*callback)((int8) received, (int8) total);
}
std::string text(JNIEnv* env, jstring value) {
if (!value) return {};
const char* characters = env->GetStringUTFChars(value, nullptr);
std::string result = characters;
env->ReleaseStringUTFChars(value, characters);
return result;
}
std::optional<std::string> errorOrNothing(JNIEnv* env, jstring value) {
if (env->ExceptionCheck()) {
env->ExceptionClear();
return std::string("The updater failed unexpectedly.");
}
if (!value) return std::nullopt;
std::string message = text(env, value);
env->DeleteLocalRef(value);
return message;
}
}
AndroidUpdateBackend::AndroidUpdateBackend(android_app& app) :
mApp(app) {
AttachedEnv attached(app.activity->vm);
JNIEnv* env = attached.env;
jobject activity = app.activity->clazz;
jclass activityClass = env->GetObjectClass(activity);
jmethodID getClassLoader = env->GetMethodID(activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
jobject loader = env->CallObjectMethod(activity, getClassLoader);
jclass loaderClass = env->FindClass("java/lang/ClassLoader");
jmethodID loadClass = env->GetMethodID(loaderClass, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;");
jstring name = env->NewStringUTF("online.ilushabasic.sketch2d.Updater");
auto updater = (jclass) env->CallObjectMethod(loader, loadClass, name);
if (env->ExceptionCheck()) {
env->ExceptionClear();
updater = nullptr;
}
if (updater) {
mUpdater = (jclass) env->NewGlobalRef(updater);
const JNINativeMethod natives[] = { { "onProgress", "(JJJ)V", (void*) reportProgress } };
if (env->RegisterNatives(updater, natives, 1) != JNI_OK) env->ExceptionClear();
}
env->DeleteLocalRef(name);
if (updater) env->DeleteLocalRef(updater);
env->DeleteLocalRef(loaderClass);
env->DeleteLocalRef(loader);
env->DeleteLocalRef(activityClass);
}
AndroidUpdateBackend::~AndroidUpdateBackend() {
if (!mUpdater) return;
AttachedEnv attached(mApp.activity->vm);
attached.env->DeleteGlobalRef((jobject) mUpdater);
}
int8 AndroidUpdateBackend::installedVersionCode() {
if (!mUpdater) return 0;
AttachedEnv attached(mApp.activity->vm);
JNIEnv* env = attached.env;
auto updater = (jclass) mUpdater;
jmethodID method = env->GetStaticMethodID(updater, "installedVersionCode", "(Landroid/app/Activity;)J");
const jlong code = env->CallStaticLongMethod(updater, method, mApp.activity->clazz);
if (env->ExceptionCheck()) env->ExceptionClear();
return (int8) code;
}
int8 AndroidUpdateBackend::installedUpdateTime() {
if (!mUpdater) return 0;
AttachedEnv attached(mApp.activity->vm);
JNIEnv* env = attached.env;
auto updater = (jclass) mUpdater;
jmethodID method = env->GetStaticMethodID(updater, "installedUpdateTime", "(Landroid/app/Activity;)J");
const jlong time = env->CallStaticLongMethod(updater, method, mApp.activity->clazz);
if (env->ExceptionCheck()) env->ExceptionClear();
return (int8) time;
}
std::optional<std::string> AndroidUpdateBackend::fetchText(const std::string& url) {
if (!mUpdater) return std::nullopt;
AttachedEnv attached(mApp.activity->vm);
JNIEnv* env = attached.env;
auto updater = (jclass) mUpdater;
jmethodID method = env->GetStaticMethodID(updater, "fetchText", "(Ljava/lang/String;)Ljava/lang/String;");
jstring address = env->NewStringUTF(url.c_str());
auto result = (jstring) env->CallStaticObjectMethod(updater, method, address);
env->DeleteLocalRef(address);
if (env->ExceptionCheck()) {
env->ExceptionClear();
return std::nullopt;
}
if (!result) return std::nullopt;
std::string body = text(env, result);
env->DeleteLocalRef(result);
return body;
}
std::optional<std::string>
AndroidUpdateBackend::download(const Release& release, const std::function<void(int8, int8)>& onProgress) {
if (!mUpdater) return std::string("Updates are not available in this build.");
AttachedEnv attached(mApp.activity->vm);
JNIEnv* env = attached.env;
auto updater = (jclass) mUpdater;
jmethodID method = env->GetStaticMethodID(
updater, "download", "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;J)Ljava/lang/String;"
);
jstring address = env->NewStringUTF(release.apkUrl.c_str());
jstring sha256 = env->NewStringUTF(release.sha256.c_str());
const ProgressCallback callback = onProgress;
auto result = (jstring) env->CallStaticObjectMethod(
updater, method, mApp.activity->clazz, address, sha256, (jlong) reinterpret_cast<intptr_t>(&callback)
);
env->DeleteLocalRef(address);
env->DeleteLocalRef(sha256);
return errorOrNothing(env, result);
}
std::optional<std::string> AndroidUpdateBackend::install() {
if (!mUpdater) return std::string("Updates are not available in this build.");
AttachedEnv attached(mApp.activity->vm);
JNIEnv* env = attached.env;
auto updater = (jclass) mUpdater;
jmethodID method = env->GetStaticMethodID(updater, "install", "(Landroid/app/Activity;)Ljava/lang/String;");
auto result = (jstring) env->CallStaticObjectMethod(updater, method, mApp.activity->clazz);
return errorOrNothing(env, result);
}
void AndroidUpdateBackend::wakeMainThread() { ALooper_wake(mApp.looper); }
}

View file

@ -0,0 +1,26 @@
#pragma once
#include "Updates.hpp"
struct android_app;
namespace tp::sketch2d {
class AndroidUpdateBackend : public UpdateBackend {
public:
explicit AndroidUpdateBackend(android_app& app);
~AndroidUpdateBackend() override;
[[nodiscard]] int8 installedVersionCode() override;
[[nodiscard]] int8 installedUpdateTime() override;
[[nodiscard]] std::optional<std::string> fetchText(const std::string& url) override;
[[nodiscard]] std::optional<std::string>
download(const Release& release, const std::function<void(int8, int8)>& onProgress) override;
[[nodiscard]] std::optional<std::string> install() override;
void wakeMainThread() override;
private:
android_app& mApp;
void* mUpdater = nullptr;
};
}

View file

@ -0,0 +1,11 @@
#include "Sketch2DApplication.hpp"
#include "ThreadPlacement.hpp"
int main() {
tp::ThreadPlacement::reserveCallingThreadForInteraction();
tp::sketch2d::Sketch2DOptions options;
options.window = { MODULES_APP_ID, MODULES_APP_NAME };
tp::sketch2d::Sketch2DApplication application(options);
application.run();
return 0;
}

View file

@ -0,0 +1,61 @@
function(sketch2d_library name dir)
file(GLOB sources CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${dir}/private/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/${dir}/public/*.hpp)
add_library(${name} STATIC ${sources})
target_include_directories(${name} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/${dir}/public)
endfunction()
sketch2d_library(Sketch2DCommon common)
target_link_libraries(Sketch2DCommon PUBLIC Math Containers Modules Threading GraphicApplicationDiagnostics)
sketch2d_library(Sketch2DBrush brush)
target_link_libraries(Sketch2DBrush PUBLIC Sketch2DCommon)
sketch2d_library(Sketch2DEngine engine)
target_link_libraries(Sketch2DEngine PUBLIC Sketch2DBrush Threading)
set(SKETCH2D_TILE_SIZE 128 CACHE STRING "Edge length of a canvas tile in pixels (power of two, at least 64)")
target_compile_definitions(Sketch2DEngine PUBLIC SKETCH2D_TILE_SIZE=${SKETCH2D_TILE_SIZE})
sketch2d_library(Sketch2DDocument document)
target_link_libraries(Sketch2DDocument PUBLIC Sketch2DEngine)
sketch2d_library(Sketch2DLibrary library)
target_link_libraries(Sketch2DLibrary PUBLIC Sketch2DDocument GraphicApplication)
sketch2d_library(Sketch2DInterface ui)
target_link_libraries(Sketch2DInterface PUBLIC Sketch2DLibrary Widgets)
add_library(Sketch2DUI INTERFACE)
target_link_libraries(Sketch2DUI INTERFACE Sketch2DInterface)
if (MODULES_BUILD_DESKTOP OR ANDROID)
find_package(Vulkan REQUIRED)
find_program(GLSLC glslc HINTS ${Vulkan_GLSLC_EXECUTABLE})
if(NOT GLSLC)
set(GLSLC ${Vulkan_GLSLC_EXECUTABLE})
endif()
set(SHADER_GEN_DIR ${CMAKE_CURRENT_BINARY_DIR}/shaders_generated)
file(MAKE_DIRECTORY ${SHADER_GEN_DIR})
set(SHADER_INCS "")
foreach(SHADER tile.vert tile.frag solid.vert solid.frag)
add_custom_command(
OUTPUT ${SHADER_GEN_DIR}/${SHADER}.inc
COMMAND ${GLSLC} -O --target-env=vulkan1.0 -mfmt=c
${CMAKE_CURRENT_SOURCE_DIR}/render/shaders/${SHADER} -o ${SHADER_GEN_DIR}/${SHADER}.inc
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/render/shaders/${SHADER}
COMMENT "Compiling ${SHADER} to SPIR-V"
VERBATIM
)
list(APPEND SHADER_INCS ${SHADER_GEN_DIR}/${SHADER}.inc)
endforeach()
add_custom_target(sketch2dShaders DEPENDS ${SHADER_INCS})
sketch2d_library(Sketch2D render)
add_dependencies(Sketch2D sketch2dShaders)
target_include_directories(Sketch2D PRIVATE ${SHADER_GEN_DIR})
target_link_libraries(Sketch2D PUBLIC Sketch2DInterface GraphicApplication RasterRender Vulkan::Vulkan)
sketch2d_library(Sketch2DApplication app)
target_link_libraries(Sketch2DApplication PUBLIC Sketch2D WidgetsApp)
execute_process(COMMAND git rev-parse --short HEAD WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} OUTPUT_VARIABLE SKETCH2D_REVISION OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
target_compile_definitions(Sketch2DApplication PRIVATE SKETCH2D_REVISION="${SKETCH2D_REVISION}")
endif ()

View file

@ -0,0 +1,120 @@
#include "Sketch2DApplication.hpp"
#include "ThreadPlacement.hpp"
#include <thread>
namespace tp::sketch2d {
namespace {
Brush& initialBrush(Brush& brush) {
brush.apply(*findBrushPreset(BrushType::Round));
return brush;
}
}
Sketch2DOptions Sketch2DOptions::forTesting(const std::string& storageRoot) {
Sketch2DOptions options;
options.storage = AppStorage(storageRoot);
options.autosave = false;
return options;
}
Sketch2DApplication::Sketch2DApplication(Sketch2DOptions options) :
WidgetApplication(options.window),
mOptions(std::move(options)),
mIcons(canvas(), "rsc/icons"),
mRenderer(mWindow->vulkan()),
mViewport(canvas(), mRenderer),
mThumbnails(canvas()),
mRoot(mDocument, mViewport, initialBrush(mBrush), mIcons, SketchLibrary::rootIn(mOptions.storage)) {
mRoot.library.setAutosave(mOptions.autosave);
loadPreferences();
mDocument.setPaintWorkers(ThreadPlacement::workerCount());
mDocument.setEngineThreads(ThreadPlacement::workerCount());
addHangContributor([this]() {
std::vector<HangReportSection> sections;
sections.push_back({ "engine.txt", mDocument.engine().describeState() });
std::string document = "zoom " + std::to_string(mDocument.zoom()) + "\n";
document += "shown level " + std::to_string(LayerLevels::levelForZoom(mDocument.zoom())) + "\n";
document += "stroke preview level " + std::to_string(mDocument.previewLevel()) + "\n";
sections.push_back({ "document.txt", document });
return sections;
});
mRoot.toolBar.layersPopup.setThumbnails(&mThumbnails);
mRoot.library.setPreviewImages(&mPreviews);
mRoot.toolBar.layersPopup.refresh();
setDebugToolsEnabled(false);
mRoot.setSessionRecording(&debugTools().recording());
mRoot.isDebugUiShown = [this]() { return inspector().isVisible(); };
mRoot.showDebugUi = [this](bool shown) { inspector().setVisible(shown); };
mRoot.isFullScreen = [this]() { return mWindow->isFullScreen(); };
mRoot.setFullScreen = [this](bool fullScreen) { mWindow->setFullScreen(fullScreen); };
mWindow->setInputDeliveryPolicies({ DeliveryPolicy::Every, DeliveryPolicy::Accumulate });
setMenuBarVisible(false);
mStudioRoot = std::make_unique<StudioRoot>(mRoot, mBrush, mIcons);
ui().setInteractionMode(
mWindow->platformInfo().isTouchFirst() ? ui::InteractionMode::Touch : ui::InteractionMode::Pointer
);
setRoot(*mStudioRoot);
}
Sketch2DApplication::~Sketch2DApplication() {
savePreferences();
mRoot.library.saveIfChanged();
debugTools().recorder().cancel();
mRoot.setSessionRecording(nullptr);
clearRoot();
mStudioRoot.reset();
}
SessionPreferences Sketch2DApplication::currentPreferences() const {
return { mBrush, mRoot.canvas.settings(), mDocument.background() };
}
void Sketch2DApplication::loadPreferences() {
const std::string path = mOptions.storage.path("preferences.txt");
if (const std::optional<SessionPreferences> preferences = SessionPreferences::parse(readFileBytes(path))) {
mBrush = preferences->brush;
mRoot.canvas.settings() = preferences->settings;
mDocument.setBackground(preferences->background);
mRoot.refreshFromState();
}
mSavedPreferences = currentPreferences().serialize();
}
void Sketch2DApplication::savePreferences() {
const SessionPreferences preferences = currentPreferences();
const std::string serialized = preferences.serialize();
if (serialized == mSavedPreferences) return;
if (saveSessionPreferences(mOptions.storage.path("preferences.txt"), preferences)) mSavedPreferences = serialized;
}
void Sketch2DApplication::processFrame(EventHandler* eventHandler, halnf deltaTime) {
WidgetApplication::processFrame(eventHandler, deltaTime);
if (mUpdates) mUpdates->poll(ui());
mPreferencesSaveMs += deltaTime;
if (mPreferencesSaveMs >= kPreferencesSaveIntervalMs) {
mPreferencesSaveMs = 0;
savePreferences();
}
const bool focused = mWindow->isFocused();
if (mWasFocused && !focused) {
mRoot.library.saveIfChanged();
savePreferences();
}
mWasFocused = focused;
}
HangSettings Sketch2DApplication::hangSettings() const {
HangSettings settings = WidgetApplication::hangSettings();
settings.application = "sketch2d";
settings.build = SKETCH2D_REVISION;
return settings;
}
void Sketch2DApplication::checkForUpdates(UpdateBackend& backend) {
mRoot.versionText = "build " + std::to_string(backend.installedVersionCode()) + " (" SKETCH2D_REVISION ")";
mUpdates = std::make_unique<UpdateManager>(backend);
mUpdates->checkInBackground();
}
}

View file

@ -0,0 +1,67 @@
#pragma once
#include "CanvasPreviewImages.hpp"
#include "CanvasThumbnails.hpp"
#include "CanvasViewport.hpp"
#include "IconSet.hpp"
#include "Sketch2DUI.hpp"
#include "TileRenderer.hpp"
#include "StudioUI.hpp"
#include "Updates.hpp"
#include "WidgetApplication.hpp"
#include <memory>
namespace tp::sketch2d {
struct Sketch2DOptions {
AppStorage storage = SketchLibrary::applicationStorage();
bool autosave = true;
WindowIdentity window = WindowIdentity::generic();
[[nodiscard]] static Sketch2DOptions forTesting(const std::string& storageRoot);
};
class Sketch2DApplication : public ui::WidgetApplication {
public:
explicit Sketch2DApplication(Sketch2DOptions options = {});
~Sketch2DApplication() override;
[[nodiscard]] SessionRecorder& sessionRecorder() { return debugTools().recorder(); }
[[nodiscard]] Document& document() { return mDocument; }
[[nodiscard]] Brush& brush() { return mBrush; }
[[nodiscard]] Sketch2DRoot& root() { return mRoot; }
[[nodiscard]] TileRenderer& renderer() { return mRenderer; }
[[nodiscard]] StudioRoot* studioRoot() { return mStudioRoot.get(); }
void checkForUpdates(UpdateBackend& backend);
[[nodiscard]] AppStorage persistentAppStorage() const override { return mOptions.storage; }
protected:
void processFrame(EventHandler* eventHandler, halnf deltaTime) override;
[[nodiscard]] HangSettings hangSettings() const override;
private:
static constexpr halnf kPreferencesSaveIntervalMs = 1000;
[[nodiscard]] SessionPreferences currentPreferences() const;
void loadPreferences();
void savePreferences();
Sketch2DOptions mOptions;
bool mWasFocused = true;
std::string mSavedPreferences;
halnf mPreferencesSaveMs = 0;
Document mDocument;
Brush mBrush;
IconSet mIcons;
TileRenderer mRenderer;
CanvasViewport mViewport;
CanvasThumbnails mThumbnails;
CanvasPreviewImages mPreviews{ canvas() };
Sketch2DRoot mRoot;
std::unique_ptr<StudioRoot> mStudioRoot;
std::unique_ptr<UpdateManager> mUpdates;
};
}

View file

@ -0,0 +1,510 @@
#include "Brush.hpp"
#include <algorithm>
#include <cmath>
#include "Noise.hpp"
namespace tp::sketch2d {
namespace {
constexpr halnf kMinimumDabDistance = 0.25f;
constexpr halnf kPi = 3.14159265358979f;
constexpr halnf kMaximumHardness = 1.0f - 1.0f / 256.0f;
const BrushParameterInfo kParameters[kBrushParameterCount] = {
{ "size", 1, Brush::kMaximumSize, 10 },
{ "opacity", 0.01f, 1, 1 },
{ "flow", 0.01f, 1, 1 },
{ "texture", 0, 1, 0 },
{ "dampen", 1, 4, 1 },
{ "jagged", 0, 1, 1 },
{ "interior", 0, 1, 1 },
{ "hardness", 0, 1, 0.8f },
{ "roundness", 0.1f, 1, 1 },
{ "angle", 0, 360, 0 },
{ "spacing", 0.01f, 2, 0.1f },
{ "grain_scale", 1, 5, 2 },
{ "deckle_scale", 1, 10, 4 },
{ "resaturation", 0, 0.01f, 0 },
{ "smudge", 0, 0.99f, 0 },
{ "blur", 0, 6, 1 },
{ "min_size", 0, 1, 0 },
{ "min_opacity", 0, 1, 0 },
{ "min_flow", 0, 1, 0 },
{ "min_bristle", 0, 1, 0 },
{ "min_interior", 0, 1, 0 },
{ "firm_size", 0.1f, 4, 1 },
{ "firm_opacity", 0.1f, 4, 1 },
{ "firm_flow", 0.1f, 4, 1 },
{ "firm_bristle", 0.1f, 4, 1 },
{ "firm_interior", 0.1f, 4, 1 },
};
const char* const kPressureTargetNames[kPressureTargetCount] = {
"pressure_size", "pressure_opacity", "pressure_flow", "pressure_bristle", "pressure_interior",
};
struct PressureCurve {
BrushParameter minimum;
BrushParameter firmness;
};
const PressureCurve kCurves[kPressureTargetCount] = {
{ BrushParameter::MinSize, BrushParameter::FirmSize },
{ BrushParameter::MinOpacity, BrushParameter::FirmOpacity },
{ BrushParameter::MinFlow, BrushParameter::FirmFlow },
{ BrushParameter::MinBristle, BrushParameter::FirmBristle },
{ BrushParameter::MinInterior, BrushParameter::FirmInterior },
};
}
halnf tipFalloff(halnf distance, halnf hardness) {
if (distance >= 1.0f) return 0;
const halnf core = std::min(hardness, kMaximumHardness);
if (distance <= core) return 1;
const halnf along = (distance - core) / (1.0f - core);
return (1.0f + std::cos(kPi * along)) * 0.5f;
}
namespace {
struct FalloffTable {
std::array<halnf, PreparedDab::kFalloffSteps + 2> values{};
FalloffTable() {
for (int4 index = 0; index <= PreparedDab::kFalloffSteps; index++) {
const halnf along = (halnf) index / (halnf) PreparedDab::kFalloffSteps;
values[(size_t) index] = (1.0f + std::cos(kPi * along)) * 0.5f;
}
values[PreparedDab::kFalloffSteps + 1] = 0;
}
};
const FalloffTable& falloffTable() {
static const FalloffTable table;
return table;
}
}
PreparedDab::PreparedDab(const DabShape& dab) :
mShape(dab) {
const halnf radius = dab.radius();
if (radius <= 0.0f || dab.roundness <= 0.0f) return;
const halnf radians = dab.angleDegrees * kPi / 180.0f;
mCosine = std::cos(radians);
mSine = std::sin(radians);
mInverseAlong = 1.0f / radius;
mInverseAcross = 1.0f / (radius * dab.roundness);
mCore = std::min(dab.hardness, kMaximumHardness);
mRingScale = (halnf) kFalloffSteps / (1.0f - mCore);
mInterior = dab.interior;
const halnf along = mInverseAlong * mInverseAlong;
const halnf across = mInverseAcross * mInverseAcross;
mSpanSquare = along * mCosine * mCosine + across * mSine * mSine;
mSpanCross = 2.0f * mCosine * mSine * (along - across);
mSpanRow = along * mSine * mSine + across * mCosine * mCosine;
mEmpty = false;
(void) falloffTable();
}
bool PreparedDab::rowSpan(halnf y, halnf& from, halnf& to) const {
if (mEmpty) return false;
const double a = mSpanSquare;
const double b = (double) mSpanCross * y;
const double c = (double) mSpanRow * y * y - 1.0;
const double discriminant = b * b - 4.0 * a * c;
if (discriminant < 0.0) return false;
const double root = std::sqrt(discriminant);
from = (halnf) ((-b - root) / (2.0 * a));
to = (halnf) ((-b + root) / (2.0 * a));
return true;
}
halnf PreparedDab::ringFalloff(halnf distance) const {
const halnf position = (distance - mCore) * mRingScale;
const int4 index = (int4) position;
if (index >= kFalloffSteps) return 0;
const halnf* values = falloffTable().values.data();
const halnf low = values[index];
return low + (values[index + 1] - low) * (position - (halnf) index);
}
PaperTexture::PaperTexture(uint32_t seed) :
mValues((size_t) kSize * kSize) {
const TileableNoise noise(seed);
const halnf weights[] = { 1, 0.5f, 0.25f, 0.125f, 0.0625f, 0.03125f };
constexpr int4 kBasePeriod = 8;
for (int4 y = 0; y < kSize; y++) {
for (int4 x = 0; x < kSize; x++) {
const halnf u = (halnf) x * kBasePeriod / kSize;
const halnf v = (halnf) y * kBasePeriod / kSize;
const halnf value = noise.fractal(u, v, kBasePeriod, weights, 6);
mValues[(size_t) (y * kSize + x)] = value;
}
}
const auto [low, high] = std::minmax_element(mValues.begin(), mValues.end());
const halnf lowest = *low;
const halnf range = *high - *low;
for (halnf& value : mValues)
value = range > 0 ? (value - lowest) / range : 0.5f;
}
halnf PaperTexture::sample(halnf x, halnf y) const {
const halnf fx = std::floor(x);
const halnf fy = std::floor(y);
const int4 ix = (int4) fx;
const int4 iy = (int4) fy;
const halnf tx = x - fx;
const halnf ty = y - fy;
const halnf top = at(ix, iy) + (at(ix + 1, iy) - at(ix, iy)) * tx;
const halnf bottom = at(ix, iy + 1) + (at(ix + 1, iy + 1) - at(ix, iy + 1)) * tx;
return top + (bottom - top) * ty;
}
halnf PaperTexture::mean() const {
halnf sum = 0;
for (halnf value : mValues)
sum += value;
return sum / (halnf) mValues.size();
}
const PaperTexture& PaperTexture::grain() {
static const PaperTexture texture(0x4752414e);
return texture;
}
const PaperTexture& PaperTexture::deckle() {
static const PaperTexture texture(0x4445434b);
return texture;
}
halnf DabShape::textured(halnf value, Vec2F world, halnf distance) const {
if (value <= 0.0f) return 0;
if (jagged > 0.0f && distance > 0.0f) {
const halnf edge = PaperTexture::deckle().sample(world.x / deckleScale, world.y / deckleScale);
const halnf threshold = 1.0f - jagged * (1.0f - edge) * distance;
value *= std::clamp((threshold - distance * distance) * 8.0f + 0.5f, 0.0f, 1.0f);
}
if (texture > 0.0f) {
const halnf paper = PaperTexture::grain().sample(world.x / grainScale, world.y / grainScale);
const halnf tooth = std::clamp((paper - (1.0f - value) * 0.5f) * 2.0f, 0.0f, 1.0f);
value *= 1.0f - texture + texture * tooth;
}
return std::clamp(value, 0.0f, 1.0f);
}
halnf DabShape::coverage(halnf x, halnf y, halnf* distanceOut) const {
const halnf r = radius();
if (r <= 0.0f) return 0;
const halnf radians = angleDegrees * kPi / 180.0f;
const halnf cosine = std::cos(radians);
const halnf sine = std::sin(radians);
const halnf along = (x * cosine + y * sine) / r;
const halnf across = (y * cosine - x * sine) / (r * roundness);
const halnf distance = std::sqrt(along * along + across * across);
if (distanceOut) *distanceOut = distance;
const halnf falloff = tipFalloff(distance, hardness);
if (falloff <= 0.0f) return 0;
return falloff * (interior + (1.0f - interior) * distance);
}
const BrushParameterInfo& brushParameterInfo(BrushParameter parameter) { return kParameters[(size_t) parameter]; }
const char* pressureTargetName(PressureTarget target) { return kPressureTargetNames[(size_t) target]; }
Brush::Brush() {
for (size_t index = 0; index < kBrushParameterCount; index++)
mValues[index] = kParameters[index].fallback;
}
void Brush::set(BrushParameter parameter, halnf value) {
const BrushParameterInfo& info = brushParameterInfo(parameter);
halnf minimum = info.minimum;
halnf maximum = info.maximum;
if (parameter == BrushParameter::Size) {
minimum = mMinimumSize;
maximum = mMaximumSize;
}
mValues[(size_t) parameter] = std::clamp(value, minimum, maximum);
}
void Brush::setStabilizer(halnf amount) { mStabilizer = std::clamp(amount, 0.0f, 1.0f); }
void Brush::setSizeLimits(halnf minimum, halnf maximum) {
const BrushParameterInfo& info = brushParameterInfo(BrushParameter::Size);
mMinimumSize = std::clamp(minimum, info.minimum, info.maximum);
mMaximumSize = std::clamp(maximum, mMinimumSize, info.maximum);
set(BrushParameter::Size, size());
}
halnf Brush::dampedPressure(halnf pressure) const {
const halnf clamped = std::clamp(pressure, 0.0f, 1.0f);
const halnf dampen = get(BrushParameter::Dampen);
return dampen == 1.0f ? clamped : std::pow(clamped, dampen);
}
halnf Brush::pressureScale(PressureTarget target, halnf pressure) const {
if (!usesPressure(target)) return 1;
const PressureCurve& curve = kCurves[(size_t) target];
const halnf minimum = get(curve.minimum);
const halnf firmness = get(curve.firmness);
const halnf damped = dampedPressure(pressure);
return minimum + (1.0f - minimum) * std::pow(damped, firmness);
}
DabShape Brush::dabAt(halnf pressure) const {
DabShape dab;
dab.size = size() * pressureScale(PressureTarget::Size, pressure);
dab.opacity = opacity() * pressureScale(PressureTarget::Opacity, pressure);
dab.flow = flow() * pressureScale(PressureTarget::Flow, pressure);
dab.bristle = pressureScale(PressureTarget::Bristle, pressure);
dab.interior = get(BrushParameter::Interior) * pressureScale(PressureTarget::Interior, pressure);
dab.hardness = hardness();
dab.roundness = get(BrushParameter::Roundness);
dab.angleDegrees = get(BrushParameter::Angle);
dab.texture = get(BrushParameter::Texture);
dab.grainScale = get(BrushParameter::GrainScale);
dab.jagged = type == BrushType::Jagged ? get(BrushParameter::Jagged) : 0.0f;
dab.deckleScale = get(BrushParameter::DeckleScale);
return dab;
}
halnf Brush::dabDistance(halnf pressure) const {
const halnf distance = dabAt(pressure).size * spacing();
return distance > kMinimumDabDistance ? distance : kMinimumDabDistance;
}
}
namespace tp::sketch2d {
namespace {
using P = BrushParameter;
using namespace PressureFlag;
std::vector<BrushPreset> makePresets() {
return {
{ "Ink",
"/brush/ink",
"25F36A7E62CE4BB18B2287F6F5CB0A92",
BrushType::Ink,
kSize,
{ { P::Size, 7 },
{ P::Spacing, 0.025f },
{ P::FirmSize, 2.2f },
{ P::MinSize, 0.15f },
{ P::FirmOpacity, 1 } } },
{ "Pencil",
"/brush/pencil",
"DE3DE44D1B3E49DFBB194A309A0FDC2F",
BrushType::Pencil,
kSize | kFlow,
{ { P::Size, 2 },
{ P::Hardness, 0 },
{ P::Flow, 0.5f },
{ P::Spacing, 0.15f },
{ P::FirmSize, 1 },
{ P::MinSize, 0.7f },
{ P::FirmFlow, 2.2f } } },
{ "Marker",
"/brush/marker",
"6F4C6CDA177E4A6DABF4078792991930",
BrushType::Marker,
0,
{ { P::Size, 22 },
{ P::Opacity, 0.5f },
{ P::Spacing, 0.025f },
{ P::FirmSize, 2 },
{ P::MinSize, 0.3f },
{ P::FirmFlow, 1.75f },
{ P::FirmOpacity, 1.75f },
{ P::MinOpacity, 0 } },
Brush::kMaximumSize,
true },
{ "Calligraphy",
"/brush/calligraphy",
"96D6DB1B3450420D9E4FF4AE458DCCED",
BrushType::Calligraphy,
kSize,
{ { P::Size, 12 },
{ P::Angle, 45 },
{ P::Spacing, 0.05f },
{ P::Roundness, 0.25f },
{ P::FirmSize, 2 },
{ P::MinSize, 0.2f } } },
{ "Jagged brush",
"/brush/jagged-brush",
"E0ECBB60CA26486A97B080E31EC12B93",
BrushType::Jagged,
kSize | kFlow | kInterior,
{ { P::Size, 36 },
{ P::Spacing, 0.025f },
{ P::Flow, 0.5f },
{ P::Texture, 0.8f },
{ P::Jagged, 0.9f },
{ P::Interior, 0.8f },
{ P::FirmSize, 1 },
{ P::MinSize, 0.5f },
{ P::FirmFlow, 1.75f },
{ P::FirmOpacity, 1.75f },
{ P::MinOpacity, 0 },
{ P::FirmInterior, 0.8f },
{ P::MinInterior, 0.3f } } },
{ "Round brush",
"/brush/round-brush",
"529AB1FF4AB7462C8F0106663715356B",
BrushType::Round,
kSize | kFlow,
{ { P::Size, 20 },
{ P::Spacing, 0.025f },
{ P::FirmSize, 1.8f },
{ P::MinSize, 0.25f },
{ P::FirmFlow, 1.8f },
{ P::FirmOpacity, 1.8f },
{ P::MinOpacity, 0 } } },
{ "Flat brush",
"/brush/flat-brush",
"630B01DF64EB4683A957DD62D47B90CD",
BrushType::Flat,
kFlow,
{ { P::Size, 24 },
{ P::Spacing, 0.025f },
{ P::Texture, 0.8f },
{ P::Angle, 45 },
{ P::FirmSize, 1 },
{ P::MinSize, 0.25f },
{ P::FirmFlow, 1.75f },
{ P::FirmOpacity, 1.75f },
{ P::MinOpacity, 0 } } },
{ "Air brush",
"/brush/air-brush",
"6EFCF4E46C0640079055DDFF658D9A84",
BrushType::Air,
kFlow | kOpacity,
{ { P::Size, 48 },
{ P::Spacing, 0.05f },
{ P::Hardness, 0 },
{ P::Flow, 0.25f },
{ P::FirmSize, 1.75f },
{ P::MinSize, 0 },
{ P::FirmFlow, 1 } } },
{ "Eraser",
"/brush-eraser/",
"8C85D19797EF4C059B2CDFC3DCAC1A0B",
BrushType::Eraser,
kFlow,
{ { P::Size, 32 },
{ P::Spacing, 0.05f },
{ P::FirmSize, 1 },
{ P::MinSize, 0.25f },
{ P::FirmFlow, 1 },
{ P::FirmOpacity, 1.75f },
{ P::MinOpacity, 0 } } },
{ "Smudge",
"/brush-smudge/",
"ECCD1B016E8F4CD189B0E6DD44107FBF",
BrushType::Smudge,
kFlow,
{ { P::Size, 40 },
{ P::Hardness, 0.5f },
{ P::Smudge, 0.8f },
{ P::Flow, 0.1f },
{ P::Spacing, 0.01f },
{ P::FirmFlow, 1.75f },
{ P::MinFlow, 0 } },
200 },
};
}
}
const std::vector<BrushPreset>& brushPresets() {
static const std::vector<BrushPreset> presets = makePresets();
return presets;
}
namespace {
constexpr BrushParameter kTextureParameters[] = {
BrushParameter::Texture, BrushParameter::GrainScale, BrushParameter::DeckleScale, BrushParameter::Jagged
};
}
const BrushPreset* Brush::preset() const {
for (const BrushPreset& candidate : brushPresets())
if (!mPresetName.empty() && mPresetName == candidate.name && candidate.type == type) return &candidate;
return findBrushPreset(type);
}
void Brush::resetEverything() {
if (const BrushPreset* original = preset()) apply(*original);
}
void Brush::resetParameters() {
const BrushPreset* original = preset();
if (!original) return;
Brush fresh;
fresh.color = color;
fresh.apply(*original);
fresh.mStabilizer = mStabilizer;
fresh.mEraserMode = mEraserMode;
for (const BrushParameter texture : kTextureParameters)
fresh.set(texture, get(texture));
*this = fresh;
}
void Brush::resetStabilization() { mStabilizer = kDefaultStabilizer; }
void Brush::resetTextures() {
const BrushPreset* original = preset();
if (!original) return;
Brush fresh;
fresh.apply(*original);
for (const BrushParameter texture : kTextureParameters)
set(texture, fresh.get(texture));
}
void Brush::scaleSize(halnf factor) {
const halnf current = size();
halnf next = current * factor;
if (factor > 1 && next < current + 1) next = current + 1;
if (factor < 1 && next > current - 1) next = current - 1;
setSize(std::round(next));
}
void Brush::adjust(BrushParameter parameter, halnf delta) {
if (parameter == BrushParameter::Angle) {
const halnf angle = std::fmod(get(parameter) + delta, 360.0f);
set(parameter, angle < 0 ? angle + 360.0f : angle);
return;
}
set(parameter, get(parameter) + delta);
}
const BrushPreset* findBrushPreset(BrushType type) {
for (const BrushPreset& preset : brushPresets()) {
if (preset.type == type) return &preset;
}
return nullptr;
}
void Brush::apply(const BrushPreset& preset) {
const RGBA keptColor = color;
*this = Brush();
color = keptColor;
type = preset.type;
mPresetName = preset.name;
setSizeLimits(1, preset.maximumSize);
setMarkerBlend(preset.markerBlend);
setLinearMixing(preset.pressureFlags & PressureFlag::kLinearMixing);
for (const BrushPresetValue& value : preset.values)
set(value.parameter, value.value);
setUsesPressure(PressureTarget::Size, preset.pressureFlags & PressureFlag::kSize);
setUsesPressure(PressureTarget::Flow, preset.pressureFlags & PressureFlag::kFlow);
setUsesPressure(PressureTarget::Opacity, preset.pressureFlags & PressureFlag::kOpacity);
setUsesPressure(PressureTarget::Bristle, preset.pressureFlags & PressureFlag::kBristle);
setUsesPressure(PressureTarget::Interior, preset.pressureFlags & PressureFlag::kInterior);
}
}

View file

@ -0,0 +1,185 @@
#include "DabMask.hpp"
#include <algorithm>
#include <array>
#include <cstring>
namespace tp::sketch2d {
namespace {
constexpr int4 kLargestCachedSide = 256;
constexpr int4 kAreaSamples = 4;
uint64_t mix(uint64_t hash, uint64_t value) {
hash ^= value + 0x9e3779b97f4a7c15ull + (hash << 6) + (hash >> 2);
return hash;
}
uint64_t bitsOf(halnf value) {
uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof bits);
return bits;
}
uint16_t toFixed(halnf value) {
return (uint16_t) std::lround(std::clamp(value, 0.0f, 1.0f) * (halnf) DabMask::kOne);
}
}
namespace {
struct SizeTable {
std::array<halnf, DabQuantizer::kSizeSteps> values{};
SizeTable() {
const std::pair<int4, halnf> runs[] = {
{ 32, 0.25f }, { 64, 0.5f }, { 96, 1.0f }, { 128, 2.0f }, { 192, 4.0f }
};
halnf value = 0;
int4 index = 0;
for (const auto& [count, step] : runs)
for (int4 run = 0; run < count; run++)
values[(size_t) index++] = value += step;
}
};
const SizeTable& sizeTable() {
static const SizeTable table;
return table;
}
}
halnf DabQuantizer::size(halnf size) {
const auto& values = sizeTable().values;
if (size <= values.front()) return values.front();
if (size >= values.back()) return size;
const auto upper = std::lower_bound(values.begin(), values.end(), size);
const auto lower = upper - 1;
return size - *lower < *upper - size ? *lower : *upper;
}
halnf DabQuantizer::angle(halnf degrees) {
const halnf turn = degrees / 360.0f;
const halnf wrapped = turn - std::floor(turn);
const int4 step = (int4) std::lround(wrapped * (halnf) kAngleSteps) % kAngleSteps;
return (halnf) step * 360.0f / (halnf) kAngleSteps;
}
halnf DabQuantizer::fraction(halnf value) {
return std::round(std::clamp(value, 0.0f, 1.0f) * (halnf) kFractionSteps) / (halnf) kFractionSteps;
}
DabShape DabQuantizer::shape(DabShape shape) {
const halnf across = shape.size * shape.roundness;
shape.size = size(shape.size);
if (shape.roundness < 1.0f && shape.size > 0.0f) shape.roundness = std::min(1.0f, size(across) / shape.size);
shape.angleDegrees = angle(shape.angleDegrees);
shape.interior = fraction(shape.interior);
shape.hardness = fraction(shape.hardness);
return shape;
}
int4 DabPlacement::stepsFor(halnf size) {
if (size > 256.0f) return 2;
if (size > 64.0f) return 4;
if (size > 16.0f) return 8;
return 16;
}
DabPlacement DabPlacement::snap(Vec2F center, halnf size) {
DabPlacement placement;
placement.steps = stepsFor(size);
const halnf steps = (halnf) placement.steps;
const int4 x = (int4) std::lround(center.x * steps);
const int4 y = (int4) std::lround(center.y * steps);
placement.center = { (halnf) x / steps, (halnf) y / steps };
placement.phaseX = ((x % placement.steps) + placement.steps) % placement.steps;
placement.phaseY = ((y % placement.steps) + placement.steps) % placement.steps;
return placement;
}
DabMaskCache& DabMaskCache::shared() {
static DabMaskCache cache;
return cache;
}
uint64_t DabMaskCache::keyOf(const DabShape& shape, const DabPlacement& placement, halnf scale) {
uint64_t hash = 0;
for (const halnf value : { shape.size, shape.interior, shape.hardness, shape.roundness, shape.angleDegrees, scale })
hash = mix(hash, bitsOf(value));
hash = mix(hash, (uint64_t) placement.steps);
hash = mix(hash, (uint64_t) placement.phaseX);
return mix(hash, (uint64_t) placement.phaseY);
}
std::shared_ptr<DabMask> DabMaskCache::rasterize(const PreparedDab& dab, const DabPlacement& placement, halnf scale) {
auto mask = std::make_shared<DabMask>();
if (dab.isEmpty()) return mask;
const halnf inverse = 1.0f / scale;
const halnf fractionX = (halnf) placement.phaseX / (halnf) placement.steps;
const halnf fractionY = (halnf) placement.phaseY / (halnf) placement.steps;
const int4 reach = (int4) std::ceil(dab.shape().radius() * inverse) + 1;
mask->left = -reach;
mask->top = -reach;
mask->width = reach * 2 + 1;
mask->height = reach * 2 + 1;
const size_t area = (size_t) mask->width * (size_t) mask->height;
mask->coverage.assign(area, 0);
mask->distance.assign(area, 0);
const int4 samples = scale > 1.0f ? kAreaSamples : 1;
const halnf weight = 1.0f / (halnf) (samples * samples);
for (int4 y = mask->top; y < mask->top + mask->height; y++) {
for (int4 x = mask->left; x < mask->left + mask->width; x++) {
halnf total = 0;
halnf nearest = 1;
for (int4 sy = 0; sy < samples; sy++) {
const halnf wy = ((halnf) y + ((halnf) sy + 0.5f) / (halnf) samples - fractionY) * scale;
for (int4 sx = 0; sx < samples; sx++) {
const halnf wx = ((halnf) x + ((halnf) sx + 0.5f) / (halnf) samples - fractionX) * scale;
halnf distance = 0;
const halnf value = dab.coverage(wx, wy, distance);
if (value <= 0.0f) continue;
total += value;
nearest = std::min(nearest, distance);
}
}
if (total <= 0.0f) continue;
const size_t index = mask->index(x, y);
mask->coverage[index] = std::max<uint16_t>(1, toFixed(total * weight));
mask->distance[index] = toFixed(nearest);
}
}
return mask;
}
std::shared_ptr<const DabMask>
DabMaskCache::mask(const PreparedDab& dab, const DabPlacement& placement, halnf scale) {
const int4 side = (int4) std::ceil(dab.shape().size / scale) + 3;
if (side > kLargestCachedSide) return nullptr;
const uint64_t key = keyOf(dab.shape(), placement, scale);
{
std::lock_guard lock(mMutex);
auto found = mMasks.find(key);
if (found != mMasks.end()) {
mHits++;
return found->second;
}
}
std::shared_ptr<const DabMask> created = rasterize(dab, placement, scale);
std::lock_guard lock(mMutex);
mMisses++;
if (mMasks.size() >= kCapacity) mMasks.clear();
return mMasks.emplace(key, std::move(created)).first->second;
}
size_t DabMaskCache::size() const {
std::lock_guard lock(mMutex);
return mMasks.size();
}
void DabMaskCache::clear() {
std::lock_guard lock(mMutex);
mMasks.clear();
mHits = 0;
mMisses = 0;
}
}

View file

@ -0,0 +1,262 @@
#pragma once
#include "Color.hpp"
#include "Environment.hpp"
#include "Vec.hpp"
#include <array>
#include <cmath>
#include <cstdint>
#include <string>
#include <vector>
namespace tp::sketch2d {
enum class BrushType {
Round,
Flat,
Air,
Pencil,
Marker,
Ink,
Calligraphy,
Jagged,
Eraser,
Smudge,
};
enum class BrushParameter {
Size,
Opacity,
Flow,
Texture,
Dampen,
Jagged,
Interior,
Hardness,
Roundness,
Angle,
Spacing,
GrainScale,
DeckleScale,
Resaturation,
Smudge,
Blur,
MinSize,
MinOpacity,
MinFlow,
MinBristle,
MinInterior,
FirmSize,
FirmOpacity,
FirmFlow,
FirmBristle,
FirmInterior,
Count,
};
constexpr size_t kBrushParameterCount = (size_t) BrushParameter::Count;
struct BrushParameterInfo {
const char* name;
halnf minimum;
halnf maximum;
halnf fallback;
};
[[nodiscard]] const BrushParameterInfo& brushParameterInfo(BrushParameter parameter);
enum class PressureTarget { Size, Opacity, Flow, Bristle, Interior, Count };
constexpr size_t kPressureTargetCount = (size_t) PressureTarget::Count;
[[nodiscard]] const char* pressureTargetName(PressureTarget target);
struct DabShape {
halnf size = 0;
halnf opacity = 0;
halnf flow = 0;
halnf bristle = 0;
halnf interior = 0;
halnf hardness = 1;
halnf roundness = 1;
halnf angleDegrees = 0;
halnf texture = 0;
halnf grainScale = 2;
halnf jagged = 0;
halnf deckleScale = 4;
[[nodiscard]] halnf radius() const { return size * 0.5f; }
[[nodiscard]] halnf coverage(halnf x, halnf y, halnf* distanceOut = nullptr) const;
[[nodiscard]] halnf textured(halnf coverage, Vec2F world, halnf distance) const;
};
[[nodiscard]] halnf tipFalloff(halnf distance, halnf hardness);
class PreparedDab {
public:
static constexpr int4 kFalloffSteps = 1024;
PreparedDab() = default;
explicit PreparedDab(const DabShape& dab);
[[nodiscard]] const DabShape& shape() const { return mShape; }
[[nodiscard]] bool isEmpty() const { return mEmpty; }
[[nodiscard]] bool plain() const { return mShape.texture <= 0.0f && mShape.jagged <= 0.0f; }
[[nodiscard]] bool rowSpan(halnf y, halnf& from, halnf& to) const;
[[nodiscard]] halnf coverage(halnf x, halnf y, halnf& distance) const {
const halnf along = (x * mCosine + y * mSine) * mInverseAlong;
const halnf across = (y * mCosine - x * mSine) * mInverseAcross;
const halnf squared = along * along + across * across;
if (squared >= 1.0f) return 0;
distance = std::sqrt(squared);
const halnf falloff = distance <= mCore ? 1.0f : ringFalloff(distance);
return falloff * (mInterior + (1.0f - mInterior) * distance);
}
private:
[[nodiscard]] halnf ringFalloff(halnf distance) const;
DabShape mShape;
halnf mCosine = 1;
halnf mSine = 0;
halnf mInverseAlong = 0;
halnf mInverseAcross = 0;
halnf mCore = 1;
halnf mRingScale = 0;
halnf mInterior = 1;
halnf mSpanSquare = 0;
halnf mSpanCross = 0;
halnf mSpanRow = 0;
bool mEmpty = true;
};
class PaperTexture {
public:
static constexpr int4 kSize = 256;
explicit PaperTexture(uint32_t seed);
[[nodiscard]] halnf at(int4 x, int4 y) const {
return mValues[(size_t) ((y & (kSize - 1)) * kSize + (x & (kSize - 1)))];
}
[[nodiscard]] halnf sample(halnf x, halnf y) const;
[[nodiscard]] halnf mean() const;
[[nodiscard]] static const PaperTexture& grain();
[[nodiscard]] static const PaperTexture& deckle();
private:
std::vector<halnf> mValues;
};
struct BrushPreset;
class Brush {
public:
static constexpr halnf kDefaultStabilizer = 0.325f;
static constexpr halnf kMaximumSize = 10000;
Brush();
BrushType type = BrushType::Round;
RGBA color = { 0.8f, 0.8f, 0.8f, 1.0f };
[[nodiscard]] halnf get(BrushParameter parameter) const { return mValues[(size_t) parameter]; }
void set(BrushParameter parameter, halnf value);
[[nodiscard]] bool usesPressure(PressureTarget target) const { return mPressure[(size_t) target]; }
void setUsesPressure(PressureTarget target, bool uses) { mPressure[(size_t) target] = uses; }
[[nodiscard]] bool markerBlend() const { return mMarkerBlend; }
void setMarkerBlend(bool marker) { mMarkerBlend = marker; }
[[nodiscard]] bool linearMixing() const { return mLinearMixing; }
void setLinearMixing(bool linear) { mLinearMixing = linear; }
[[nodiscard]] bool smudges() const { return type == BrushType::Smudge; }
[[nodiscard]] bool eraserMode() const { return mEraserMode; }
void setEraserMode(bool erase) { mEraserMode = erase; }
[[nodiscard]] bool erases() const { return type == BrushType::Eraser || (mEraserMode && !smudges()); }
[[nodiscard]] halnf stabilizer() const { return mStabilizer; }
void setStabilizer(halnf amount);
[[nodiscard]] halnf minimumSize() const { return mMinimumSize; }
[[nodiscard]] halnf maximumSize() const { return mMaximumSize; }
void setSizeLimits(halnf minimum, halnf maximum);
[[nodiscard]] halnf size() const { return get(BrushParameter::Size); }
[[nodiscard]] halnf radius() const { return size() * 0.5f; }
[[nodiscard]] halnf opacity() const { return get(BrushParameter::Opacity); }
[[nodiscard]] halnf flow() const { return get(BrushParameter::Flow); }
[[nodiscard]] halnf hardness() const { return get(BrushParameter::Hardness); }
[[nodiscard]] halnf spacing() const { return get(BrushParameter::Spacing); }
void setSize(halnf size) { set(BrushParameter::Size, size); }
void setOpacity(halnf opacity) { set(BrushParameter::Opacity, opacity); }
void setFlow(halnf flow) { set(BrushParameter::Flow, flow); }
void setHardness(halnf hardness) { set(BrushParameter::Hardness, hardness); }
void setSpacing(halnf spacing) { set(BrushParameter::Spacing, spacing); }
[[nodiscard]] halnf dampedPressure(halnf pressure) const;
[[nodiscard]] halnf pressureScale(PressureTarget target, halnf pressure) const;
[[nodiscard]] DabShape dabAt(halnf pressure) const;
[[nodiscard]] halnf dabDistance(halnf pressure) const;
void apply(const BrushPreset& preset);
[[nodiscard]] const BrushPreset* preset() const;
void resetEverything();
void resetParameters();
void resetStabilization();
void resetTextures();
void scaleSize(halnf factor);
void adjust(BrushParameter parameter, halnf delta);
[[nodiscard]] const std::string& presetName() const { return mPresetName; }
void setPresetName(const std::string& name) { mPresetName = name; }
[[nodiscard]] bool operator==(const Brush& other) const = default;
private:
std::array<halnf, kBrushParameterCount> mValues{};
std::array<bool, kPressureTargetCount> mPressure{};
halnf mStabilizer = kDefaultStabilizer;
bool mMarkerBlend = false;
bool mLinearMixing = false;
bool mEraserMode = false;
halnf mMinimumSize = 1;
halnf mMaximumSize = kMaximumSize;
std::string mPresetName;
};
struct BrushPresetValue {
BrushParameter parameter;
halnf value;
};
struct BrushPreset {
const char* name;
const char* path;
const char* guid;
BrushType type;
uint32_t pressureFlags;
std::vector<BrushPresetValue> values;
halnf maximumSize = Brush::kMaximumSize;
bool markerBlend = false;
};
namespace PressureFlag {
constexpr uint32_t kStabilizer = 0x800;
constexpr uint32_t kSize = 0x1000;
constexpr uint32_t kFlow = 0x2000;
constexpr uint32_t kOpacity = 0x4000;
constexpr uint32_t kBristle = 0x8000;
constexpr uint32_t kInterior = 0x10000;
constexpr uint32_t kLinearMixing = 0x40000;
}
[[nodiscard]] const std::vector<BrushPreset>& brushPresets();
[[nodiscard]] const BrushPreset* findBrushPreset(BrushType type);
}

View file

@ -0,0 +1,74 @@
#pragma once
#include "Brush.hpp"
#include <cstdint>
#include <memory>
#include <mutex>
#include <unordered_map>
#include <vector>
namespace tp::sketch2d {
struct DabMask {
static constexpr int4 kOne = 1 << 14;
int4 left = 0;
int4 top = 0;
int4 width = 0;
int4 height = 0;
std::vector<uint16_t> coverage;
std::vector<uint16_t> distance;
[[nodiscard]] bool isEmpty() const { return width <= 0 || height <= 0; }
[[nodiscard]] size_t index(int4 x, int4 y) const {
return (size_t) (y - top) * (size_t) width + (size_t) (x - left);
}
};
struct DabPlacement {
Vec2F center;
int4 phaseX = 0;
int4 phaseY = 0;
int4 steps = 16;
[[nodiscard]] static int4 stepsFor(halnf size);
[[nodiscard]] static DabPlacement snap(Vec2F center, halnf size);
};
struct DabQuantizer {
static constexpr int4 kSizeSteps = 512;
static constexpr int4 kAngleSteps = 1024;
static constexpr int4 kFractionSteps = 64;
[[nodiscard]] static halnf size(halnf size);
[[nodiscard]] static halnf angle(halnf degrees);
[[nodiscard]] static halnf fraction(halnf value);
[[nodiscard]] static DabShape shape(DabShape shape);
};
class DabMaskCache {
public:
static constexpr size_t kCapacity = 4096;
[[nodiscard]] static DabMaskCache& shared();
[[nodiscard]] std::shared_ptr<const DabMask>
mask(const PreparedDab& dab, const DabPlacement& placement, halnf scale);
[[nodiscard]] size_t size() const;
[[nodiscard]] uint64_t hits() const { return mHits; }
[[nodiscard]] uint64_t misses() const { return mMisses; }
void clear();
[[nodiscard]] static std::shared_ptr<DabMask>
rasterize(const PreparedDab& dab, const DabPlacement& placement, halnf scale);
private:
[[nodiscard]] static uint64_t keyOf(const DabShape& shape, const DabPlacement& placement, halnf scale);
mutable std::mutex mMutex;
std::unordered_map<uint64_t, std::shared_ptr<const DabMask>> mMasks;
uint64_t mHits = 0;
uint64_t mMisses = 0;
};
}

View file

@ -0,0 +1,53 @@
#include "ChannelView.hpp"
namespace tp::sketch2d {
namespace {
ChannelMatrix greyFrom(halnf r, halnf g, halnf b) { return { r, g, b, 0, r, g, b, 0, r, g, b, 0, 0, 0, 0, 1 }; }
}
const char* viewChannelName(ViewChannel channel) {
switch (channel) {
case ViewChannel::Red: return "Red";
case ViewChannel::Green: return "Green";
case ViewChannel::Blue: return "Blue";
case ViewChannel::AlphaOnBlack: return "Alpha";
case ViewChannel::AlphaOnWhite: return "Alpha on white";
case ViewChannel::Lightness: return "Lightness";
}
return "";
}
ChannelMatrix channelMatrix(ViewChannel channel) {
switch (channel) {
case ViewChannel::Red: return greyFrom(1, 0, 0);
case ViewChannel::Green: return greyFrom(0, 1, 0);
case ViewChannel::Blue: return greyFrom(0, 0, 1);
case ViewChannel::AlphaOnBlack: return { 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1 };
case ViewChannel::AlphaOnWhite: return { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
case ViewChannel::Lightness: return greyFrom(kLumaRed, kLumaGreen, kLumaBlue);
}
return greyFrom(1, 0, 0);
}
RGBA applyChannelView(ViewChannel channel, const RGBA& premultiplied) {
const ChannelMatrix matrix = channelMatrix(channel);
const halnf in[4] = { premultiplied.r, premultiplied.g, premultiplied.b, premultiplied.a };
halnf out[4] = {};
for (size_t row = 0; row < 4; row++) {
for (size_t column = 0; column < 4; column++)
out[row] += matrix[row * 4 + column] * in[column];
}
return { out[0], out[1], out[2], out[3] };
}
RGBA channelBackground(ViewChannel channel, const RGBA& background) {
if (channel == ViewChannel::AlphaOnBlack) return { 0, 0, 0, 1 };
if (channel == ViewChannel::AlphaOnWhite) return { 1, 1, 1, 1 };
const RGBA premultiplied = {
background.r * background.a, background.g * background.a, background.b * background.a, background.a
};
return applyChannelView(channel, premultiplied);
}
}

View file

@ -0,0 +1,152 @@
#include "Stabilizer.hpp"
#include "Profiling.hpp"
#include <algorithm>
#include <cmath>
namespace tp::sketch2d {
namespace {
constexpr halnf kEmitDistance = 0.75f;
constexpr halnf kSettledDistance = 0.05f;
constexpr halnf kFastStrokeShare = 0.35f;
constexpr halnf kFastCurvatureLength = 120.0f;
Vec2F lerp(Vec2F from, Vec2F to, halnf amount) { return from + (to - from) * amount; }
halnf lerp(halnf from, halnf to, halnf amount) { return from + (to - from) * amount; }
halnf distance(Vec2F a, Vec2F b) { return (halnf) (b - a).length(); }
}
halnf Stabilizer::smoothingLength(halnf amount) {
if (amount <= 0.0f) return 0;
return 100.0f * std::pow(amount, 1.6f);
}
halnf Stabilizer::ropeLength(halnf amount) {
if (amount <= 0.0f) return 0;
return 6.0f * amount;
}
halnf Stabilizer::catchUpMs(halnf amount) {
if (amount <= 0.0f) return 0;
return 20.0f + 160.0f * amount;
}
void Stabilizer::begin(const StrokePoint& point, halnf amount) {
mActive = true;
mAmount = std::clamp(amount, 0.0f, 1.0f);
mIdleMs = 0;
mPen = point;
mAnchor = point;
mFirstStage = point;
mSmoothed = point;
mEmitted = point;
mDirection = { 0, 0 };
mStraightness = 0;
mOutput.clear();
mOutput.push_back(point);
}
void Stabilizer::moveTo(const StrokePoint& point) {
TP_PROFILE_SCOPE("stroke.stabilizer");
if (!mActive) return;
mIdleMs = 0;
if (mAmount <= 0.0f) {
mPen = point;
mAnchor = point;
mFirstStage = point;
mSmoothed = point;
emit();
return;
}
const StrokePoint from = mPen;
const halnf length = distance(from.position, point.position);
const int4 steps = std::max(1, (int4) std::ceil(length / kStepLength));
for (int4 index = 1; index <= steps; index++) {
const halnf amount = (halnf) index / (halnf) steps;
step(
{ lerp(from.position, point.position, amount), lerp(from.pressure, point.pressure, amount) },
length / (halnf) steps
);
}
mPen = point;
}
void Stabilizer::step(const StrokePoint& target, halnf travelled) {
const halnf rope = ropeLength(mAmount);
const Vec2F offset = target.position - mAnchor.position;
const halnf reach = (halnf) offset.length();
if (reach > rope) {
const Vec2F moved = offset * ((reach - rope) / reach);
mAnchor.position += moved;
trackDirection(moved);
}
mAnchor.pressure = target.pressure;
const halnf length = smoothingLength(mAmount) * (1.0f - kFastStrokeShare * mStraightness);
follow(1.0f - std::exp(-2.0f * travelled / length));
emit();
}
void Stabilizer::trackDirection(Vec2F moved) {
const halnf length = (halnf) moved.length();
if (length <= 0.0f) return;
const Vec2F unit = moved / length;
const halnf memory = std::exp(-length / kFastCurvatureLength);
const halnf agreement = mDirection.x * unit.x + mDirection.y * unit.y;
mStraightness = mStraightness * memory + std::max(0.0f, agreement) * (1.0f - memory);
mDirection = mDirection * memory + unit * (1.0f - memory);
const halnf directionLength = (halnf) mDirection.length();
if (directionLength > 0.0f) mDirection = mDirection / directionLength;
}
void Stabilizer::follow(halnf amount) {
mFirstStage.position = lerp(mFirstStage.position, mAnchor.position, amount);
mFirstStage.pressure = lerp(mFirstStage.pressure, mAnchor.pressure, amount);
mSmoothed.position = lerp(mSmoothed.position, mFirstStage.position, amount);
mSmoothed.pressure = lerp(mSmoothed.pressure, mFirstStage.pressure, amount);
}
void Stabilizer::advance(halnf deltaMs) {
TP_PROFILE_SCOPE("stroke.stabilizer");
if (!mActive || mAmount <= 0.0f || deltaMs <= 0.0f) return;
mIdleMs += deltaMs;
if (mIdleMs < kIdleBeforeCatchUpMs) return;
mAnchor = mPen;
follow(1.0f - std::exp(-2.0f * deltaMs / catchUpMs(mAmount)));
if (isSettled()) {
mFirstStage = mPen;
mSmoothed = mPen;
if (distance(mEmitted.position, mSmoothed.position) > 0.0f) {
mEmitted = mSmoothed;
mOutput.push_back(mSmoothed);
}
return;
}
emit();
}
void Stabilizer::finish() {
if (!mActive) return;
if (distance(mEmitted.position, mSmoothed.position) > 0.0f) mOutput.push_back(mSmoothed);
mActive = false;
}
void Stabilizer::emit() {
if (distance(mEmitted.position, mSmoothed.position) < kEmitDistance) return;
mEmitted = mSmoothed;
mOutput.push_back(mSmoothed);
}
bool Stabilizer::isSettled() const { return distance(mSmoothed.position, mPen.position) < kSettledDistance; }
std::vector<StrokePoint> Stabilizer::takeOutput() {
std::vector<StrokePoint> output;
output.swap(mOutput);
return output;
}
}

View file

@ -0,0 +1,128 @@
#include "StrokePath.hpp"
#include <algorithm>
#include <cmath>
namespace tp::sketch2d {
namespace {
halnf catmullRom(halnf p0, halnf p1, halnf p2, halnf p3, halnf t) {
const halnf t2 = t * t;
const halnf t3 = t2 * t;
return 0.5f * (2.0f * p1 + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 +
(3.0f * p1 - p0 - 3.0f * p2 + p3) * t3);
}
halnf distance(Vec2F a, Vec2F b) {
const Vec2F d = b - a;
return std::sqrt(d.x * d.x + d.y * d.y);
}
}
StrokePath::StrokePath(std::vector<Sample> samples) :
mSamples(std::move(samples)) {}
void StrokePath::begin(const Sample& first) {
mSamples.clear();
mSamples.push_back(first);
mLengths.assign(1, 0.0f);
}
void StrokePath::add(const Sample& sample) {
if (!mSamples.empty() && distance(mSamples.back().position, sample.position) <= 0.0f) {
mSamples.back().pressure = sample.pressure;
return;
}
mSamples.push_back(sample);
}
StrokePath::Sample StrokePath::at(halnf parameter) const {
if (mSamples.empty()) return {};
const int4 last = (int4) mSamples.size() - 1;
parameter = std::clamp(parameter, 0.0f, (halnf) last);
const int4 index = std::min((int4) std::floor(parameter), std::max(last - 1, 0));
const halnf t = parameter - (halnf) index;
const Sample& p1 = mSamples[(size_t) index];
const Sample& p2 = mSamples[(size_t) std::min(index + 1, last)];
const Sample& p0 = mSamples[(size_t) std::max(index - 1, 0)];
const Sample& p3 = mSamples[(size_t) std::min(index + 2, last)];
Sample result;
result.position = { catmullRom(p0.position.x, p1.position.x, p2.position.x, p3.position.x, t),
catmullRom(p0.position.y, p1.position.y, p2.position.y, p3.position.y, t) };
result.pressure = std::clamp(p1.pressure + (p2.pressure - p1.pressure) * t, 0.0f, 1.0f);
return result;
}
halnf StrokePath::endParameter(bool finished) const {
const halnf last = (halnf) mSamples.size() - 1.0f;
return finished ? last : std::max(0.0f, last - 1.0f);
}
void StrokePath::measureSegments(halnf end) {
const size_t needed = (size_t) std::ceil(end) * (size_t) kSegmentSteps + 1;
while (mLengths.size() < needed) {
const size_t step = mLengths.size();
const halnf from = (halnf) (step - 1) / (halnf) kSegmentSteps;
const halnf to = (halnf) step / (halnf) kSegmentSteps;
mLengths.push_back(mLengths.back() + distance(at(from).position, at(to).position));
}
}
halnf StrokePath::lengthAt(halnf parameter) const {
const halnf scaled = parameter * (halnf) kSegmentSteps;
const size_t index = std::min((size_t) scaled, mLengths.size() - 2);
const halnf t = scaled - (halnf) index;
return mLengths[index] + (mLengths[index + 1] - mLengths[index]) * t;
}
halnf StrokePath::parameterAtLength(halnf length) const {
const auto upper = std::lower_bound(mLengths.begin(), mLengths.end(), length);
if (upper == mLengths.begin()) return 0;
if (upper == mLengths.end()) return (halnf) (mLengths.size() - 1) / (halnf) kSegmentSteps;
const size_t index = (size_t) (upper - mLengths.begin()) - 1;
const halnf span = mLengths[index + 1] - mLengths[index];
halnf t = span > 0.0f ? (length - mLengths[index]) / span : 0.0f;
const halnf start = (halnf) index / (halnf) kSegmentSteps;
const halnf width = 1.0f / (halnf) kSegmentSteps;
const Vec2F origin = at(start).position;
const halnf wanted = length - mLengths[index];
for (int4 iteration = 0; iteration < kRefineSteps && span > 0.0f; iteration++) {
const halnf reached = distance(origin, at(start + t * width).position);
t = std::clamp(t + (wanted - reached) / span, 0.0f, 1.0f);
}
return start + t * width;
}
void StrokePath::advance(Walk& walk, const Spacing& spacing, const Emit& emit, bool finished) {
advanceTo(walk, endParameter(finished), spacing, emit);
}
void StrokePath::advanceTo(Walk& walk, halnf end, const Spacing& spacing, const Emit& emit) {
if (mSamples.size() < 2) return;
end = std::min(end, endParameter(true));
if (end <= walk.parameter) return;
measureSegments(end);
const halnf endLength = lengthAt(end);
while (true) {
const Sample from = at(walk.parameter);
const halnf step = std::max(spacing(from.pressure), kMinimumSpacing);
const halnf target = walk.length + step;
if (target > endLength + step * kEndTolerance) return;
walk.length = std::min(target, endLength);
walk.parameter = std::min(parameterAtLength(walk.length), end);
emit(at(walk.parameter));
}
}
void StrokePath::forEachPoint(halnf from, halnf to, const std::function<void(Vec2F from, Vec2F to)>& visit) const {
if (mSamples.empty()) return;
const halnf step = 1.0f / (halnf) kSegmentSteps;
Vec2F previous = at(from).position;
for (halnf parameter = std::floor(from * (halnf) kSegmentSteps) * step + step; parameter < to; parameter += step) {
const Vec2F next = at(parameter).position;
visit(previous, next);
previous = next;
}
visit(previous, at(to).position);
}
}

View file

@ -0,0 +1,100 @@
#include "ThreadPlacement.hpp"
#include "ThreadPinning.hpp"
#include <algorithm>
#include <mutex>
#include <thread>
#if defined(__linux__) || defined(__ANDROID__)
#include <sched.h>
#define TP_THREAD_PLACEMENT_SUPPORTED
#endif
namespace tp {
namespace {
struct Reservation {
std::mutex mutex;
bool made = false;
ThreadPlacement::Layout layout;
};
Reservation& reservation() {
static Reservation shared;
return shared;
}
int runningCpu() {
#ifdef TP_THREAD_PLACEMENT_SUPPORTED
return sched_getcpu();
#else
return -1;
#endif
}
}
ThreadPlacement::Layout
ThreadPlacement::layoutFor(int interactiveCpu, const std::vector<std::vector<int>>& siblingGroups) {
Layout layout;
for (const std::vector<int>& group : siblingGroups) {
const bool owned = std::count(group.begin(), group.end(), interactiveCpu) > 0;
std::vector<int>& target = owned ? layout.interactive : layout.workers;
target.insert(target.end(), group.begin(), group.end());
}
if (layout.workers.empty()) layout.workers = layout.interactive;
return layout;
}
void ThreadPlacement::reserveCallingThreadForInteraction() {
#ifdef TP_THREAD_PLACEMENT_SUPPORTED
const std::vector<int> allowed = ThreadPinning::allowedCpus();
if (allowed.size() < 2) return;
const int running = runningCpu();
const int interactive = std::count(allowed.begin(), allowed.end(), running) ? running : allowed.front();
Layout layout = layoutFor(interactive, ThreadPinning::siblingGroups(allowed));
Reservation& shared = reservation();
{
std::lock_guard lock(shared.mutex);
shared.layout = layout;
shared.made = true;
}
ThreadPinning::pinCallingThread(layout.interactive);
#endif
}
void ThreadPlacement::placeCallingThreadAsWorker() {
#ifdef TP_THREAD_PLACEMENT_SUPPORTED
std::vector<int> workers;
{
Reservation& shared = reservation();
std::lock_guard lock(shared.mutex);
if (!shared.made) return;
workers = shared.layout.workers;
}
ThreadPinning::pinCallingThread(workers);
#endif
}
ThreadPlacement::Layout ThreadPlacement::current() {
Reservation& shared = reservation();
std::lock_guard lock(shared.mutex);
return shared.made ? shared.layout : Layout{};
}
std::vector<int> ThreadPlacement::callingThreadCpus() {
#ifdef TP_THREAD_PLACEMENT_SUPPORTED
return ThreadPinning::allowedCpus();
#else
return {};
#endif
}
size_t ThreadPlacement::workerCount() {
const Layout layout = current();
if (!layout.workers.empty() && layout.workers != layout.interactive)
return std::max<size_t>(2, layout.workers.size());
const unsigned hardware = std::thread::hardware_concurrency();
return hardware > 2 ? (size_t) hardware - 1 : 2;
}
}

View file

@ -0,0 +1,22 @@
#pragma once
#include "Color.hpp"
#include <array>
namespace tp::sketch2d {
enum class ViewChannel { Red, Green, Blue, AlphaOnBlack, AlphaOnWhite, Lightness };
constexpr int4 kViewChannelCount = 6;
constexpr halnf kLumaRed = 4899.0f / 16384.0f;
constexpr halnf kLumaGreen = 9617.0f / 16384.0f;
constexpr halnf kLumaBlue = 1868.0f / 16384.0f;
using ChannelMatrix = std::array<halnf, 16>;
[[nodiscard]] const char* viewChannelName(ViewChannel channel);
[[nodiscard]] ChannelMatrix channelMatrix(ViewChannel channel);
[[nodiscard]] RGBA applyChannelView(ViewChannel channel, const RGBA& premultiplied);
[[nodiscard]] RGBA channelBackground(ViewChannel channel, const RGBA& background);
}

View file

@ -0,0 +1,129 @@
#pragma once
#include "Environment.hpp"
#include <string>
#include <utility>
#include <vector>
namespace tp::sketch2d {
template <typename State>
class History {
public:
static constexpr int4 kMinimumLimit = 2;
static constexpr int4 kMaximumLimit = 1000;
static constexpr int4 kDefaultLimit = 30;
struct Entry {
std::string label;
std::string mergeKey;
State state;
};
void reset(std::string label, State state) {
mRevision++;
mEntries.clear();
mEntryRevisions.clear();
mEntries.push_back({ std::move(label), {}, std::move(state) });
mEntryRevisions.push_back(++mNextEntry);
mCurrent = 0;
}
void commit(std::string label, State state, std::string mergeKey = {}) {
mRevision++;
if (!mergeKey.empty() && !mEntries.empty() && mEntries[mCurrent].mergeKey == mergeKey && !canRedo()) {
mEntries[mCurrent].state = std::move(state);
if (mEntryRevisions.size() == mEntries.size()) mEntryRevisions[mCurrent] = ++mNextEntry;
return;
}
mEntries.erase(mEntries.begin() + (std::ptrdiff_t) mCurrent + 1, mEntries.end());
if (mEntryRevisions.size() > mCurrent + 1)
mEntryRevisions.erase(mEntryRevisions.begin() + (std::ptrdiff_t) mCurrent + 1, mEntryRevisions.end());
mEntries.push_back({ std::move(label), std::move(mergeKey), std::move(state) });
while (mEntryRevisions.size() + 1 < mEntries.size())
mEntryRevisions.push_back(++mNextEntry);
mEntryRevisions.push_back(++mNextEntry);
mCurrent = mEntries.size() - 1;
trim();
}
[[nodiscard]] bool canUndo() const { return mCurrent > 0; }
[[nodiscard]] bool canRedo() const { return mCurrent + 1 < mEntries.size(); }
const State* undo() { return canUndo() ? jumpTo(mCurrent - 1) : nullptr; }
const State* redo() { return canRedo() ? jumpTo(mCurrent + 1) : nullptr; }
const State* jumpTo(size_t index) {
if (index >= mEntries.size()) return nullptr;
mCurrent = index;
mRevision++;
return &mEntries[mCurrent].state;
}
void clear() {
if (mEntries.empty()) return;
Entry current = std::move(mEntries[mCurrent]);
const ualni revision = currentEntryRevision();
current.mergeKey.clear();
mEntries.clear();
mEntryRevisions.clear();
mEntries.push_back(std::move(current));
mEntryRevisions.push_back(revision ? revision : ++mNextEntry);
mCurrent = 0;
}
void setLimit(int4 limit) {
mLimit = limit < kMinimumLimit ? kMinimumLimit : (limit > kMaximumLimit ? kMaximumLimit : limit);
trim();
}
[[nodiscard]] int4 limit() const { return mLimit; }
[[nodiscard]] size_t currentIndex() const { return mCurrent; }
[[nodiscard]] ualni revision() const { return mRevision; }
[[nodiscard]] const std::vector<Entry>& entries() const { return mEntries; }
[[nodiscard]] const Entry& current() const { return mEntries[mCurrent]; }
[[nodiscard]] Entry* entryOfRevision(ualni revision) {
for (size_t index = 0; index < mEntries.size(); index++)
if (mEntryRevisions.size() == mEntries.size() && mEntryRevisions[index] == revision) return &mEntries[index];
return nullptr;
}
template <typename Fn>
void forEachEntryFrom(ualni revision, Fn&& fn) {
if (mEntryRevisions.size() != mEntries.size()) return;
bool found = false;
for (size_t index = 0; index < mEntries.size(); index++) {
found = found || mEntryRevisions[index] == revision;
if (found) fn(mEntries[index]);
}
}
[[nodiscard]] ualni currentEntryRevision() const {
return mEntryRevisions.size() == mEntries.size() ? mEntryRevisions[mCurrent] : 0;
}
[[nodiscard]] const std::string& undoLabel() const { return mEntries[mCurrent].label; }
[[nodiscard]] const std::string& redoLabel() const { return mEntries[mCurrent + 1].label; }
private:
void trim() {
const size_t capacity = (size_t) mLimit + 1;
while (mEntries.size() > capacity && mCurrent > 0) {
mEntries.erase(mEntries.begin());
if (!mEntryRevisions.empty()) mEntryRevisions.erase(mEntryRevisions.begin());
mCurrent--;
}
while (mEntries.size() > capacity) {
mEntries.pop_back();
if (mEntryRevisions.size() > mEntries.size()) mEntryRevisions.pop_back();
}
}
std::vector<Entry> mEntries;
size_t mCurrent = 0;
int4 mLimit = kDefaultLimit;
ualni mRevision = 0;
std::vector<ualni> mEntryRevisions;
ualni mNextEntry = 0;
};
}

View file

@ -0,0 +1,28 @@
#pragma once
#include "Profiler.hpp"
namespace tp::sketch2d {
// The refactor made Profiler instance-based and dropped the global accessor and the TP_PROFILE macros. Sketch2D keeps
// its per-section instrumentation, so it carries the same small convenience layer on the shared Profiler.
[[nodiscard]] inline Profiler& profiler() {
static Profiler instance;
return instance;
}
}
#define TP_PROFILE_CONCAT_INNER(a, b) a##b
#define TP_PROFILE_CONCAT(a, b) TP_PROFILE_CONCAT_INNER(a, b)
// Times the enclosing scope and records one sample in the profiler's section channel.
#define TP_PROFILE_SCOPE(name) \
static const ::tp::ProfileId TP_PROFILE_CONCAT(tpProfileId, __LINE__) = ::tp::sketch2d::profiler().section(name); \
::tp::ProfileScope TP_PROFILE_CONCAT(tpProfileScope, __LINE__)( \
::tp::sketch2d::profiler(), TP_PROFILE_CONCAT(tpProfileId, __LINE__))
// Adds one counter amount (dabs, pixels, tiles) to the profiler's counter channel.
#define TP_PROFILE_COUNT(name, amount) \
static const ::tp::ProfileId TP_PROFILE_CONCAT(tpProfileCounterId, __LINE__) = \
::tp::sketch2d::profiler().counter(name); \
::tp::sketch2d::profiler().addCounter(TP_PROFILE_CONCAT(tpProfileCounterId, __LINE__), amount)

View file

@ -0,0 +1,57 @@
#pragma once
#include "Environment.hpp"
#include "Vec.hpp"
#include <vector>
namespace tp::sketch2d {
struct StrokePoint {
Vec2F position = { 0, 0 };
halnf pressure = 1;
};
class Stabilizer {
public:
static constexpr halnf kDefaultAmount = 0.325f;
static constexpr halnf kStepLength = 0.5f;
static constexpr halnf kIdleBeforeCatchUpMs = 40;
void begin(const StrokePoint& point, halnf amount);
void moveTo(const StrokePoint& point);
void advance(halnf deltaMs);
void finish();
[[nodiscard]] std::vector<StrokePoint> takeOutput();
[[nodiscard]] bool isActive() const { return mActive; }
[[nodiscard]] bool isSmoothing() const { return mAmount > 0; }
[[nodiscard]] halnf amount() const { return mAmount; }
[[nodiscard]] const StrokePoint& pen() const { return mPen; }
[[nodiscard]] const StrokePoint& smoothed() const { return mSmoothed; }
[[nodiscard]] bool isSettled() const;
[[nodiscard]] static halnf smoothingLength(halnf amount);
[[nodiscard]] static halnf ropeLength(halnf amount);
[[nodiscard]] static halnf catchUpMs(halnf amount);
private:
void step(const StrokePoint& target, halnf travelled);
void follow(halnf amount);
void trackDirection(Vec2F moved);
void emit();
bool mActive = false;
halnf mAmount = 0;
halnf mIdleMs = 0;
StrokePoint mPen;
StrokePoint mAnchor;
StrokePoint mFirstStage;
StrokePoint mSmoothed;
StrokePoint mEmitted;
Vec2F mDirection = { 0, 0 };
halnf mStraightness = 0;
std::vector<StrokePoint> mOutput;
};
}

View file

@ -0,0 +1,53 @@
#pragma once
#include "Vec.hpp"
#include <functional>
#include <vector>
namespace tp::sketch2d {
class StrokePath {
public:
struct Sample {
Vec2F position = { 0, 0 };
halnf pressure = 1;
};
struct Walk {
halnf parameter = 0;
halnf length = 0;
};
using Spacing = std::function<halnf(halnf pressure)>;
using Emit = std::function<void(const Sample& dab)>;
static constexpr halnf kMinimumSpacing = 0.001f;
static constexpr int4 kBisectSteps = 24;
static constexpr halnf kEndTolerance = 0.001f;
static constexpr int4 kSegmentSteps = 32;
static constexpr int4 kRefineSteps = 2;
StrokePath() = default;
explicit StrokePath(std::vector<Sample> samples);
void begin(const Sample& first);
void add(const Sample& sample);
void advance(Walk& walk, const Spacing& spacing, const Emit& emit, bool finished);
void advanceTo(Walk& walk, halnf end, const Spacing& spacing, const Emit& emit);
[[nodiscard]] size_t sampleCount() const { return mSamples.size(); }
[[nodiscard]] const std::vector<Sample>& samples() const { return mSamples; }
[[nodiscard]] Sample at(halnf parameter) const;
[[nodiscard]] halnf endParameter(bool finished) const;
void forEachPoint(halnf from, halnf to, const std::function<void(Vec2F from, Vec2F to)>& visit) const;
private:
[[nodiscard]] halnf parameterAtLength(halnf length) const;
[[nodiscard]] halnf lengthAt(halnf parameter) const;
void measureSegments(halnf end);
std::vector<Sample> mSamples;
std::vector<halnf> mLengths = { 0.0f };
};
}

View file

@ -0,0 +1,27 @@
#pragma once
#include <cstddef>
#include <vector>
namespace tp {
// The interaction/worker CPU-placement policy the refactor lifted out of the old Modules helper. The primitives now
// live in Threading::ThreadPinning; Sketch2D keeps the policy so the UI thread and its workers stay on separate
// physical cores the way they did before.
class ThreadPlacement {
public:
struct Layout {
std::vector<int> interactive;
std::vector<int> workers;
};
[[nodiscard]] static Layout layoutFor(int interactiveCpu, const std::vector<std::vector<int>>& siblingGroups);
static void reserveCallingThreadForInteraction();
static void placeCallingThreadAsWorker();
[[nodiscard]] static Layout current();
[[nodiscard]] static std::vector<int> callingThreadCpus();
[[nodiscard]] static size_t workerCount();
};
}

View file

@ -0,0 +1,57 @@
#include "BrushCursor.hpp"
#include <algorithm>
#include <cmath>
namespace tp::sketch2d {
const char* brushCursorStyleName(BrushCursorStyle style) {
switch (style) {
case BrushCursorStyle::Contour: return "Contour";
case BrushCursorStyle::Brush: return "Brush";
case BrushCursorStyle::Arrow: return "Arrow";
case BrushCursorStyle::Cross: return "Cross";
}
return "Contour";
}
BrushCursorShape BrushCursorShape::build(
const BrushCursorSettings& settings,
const Brush& brush,
Vec2F center,
halnf zoom,
halnf viewRotation,
halnf pressure
) {
BrushCursorShape shape;
shape.center = center;
const bool special = brush.erases() || brush.smudges();
const bool wantsContour =
settings.style == BrushCursorStyle::Contour && (!special || settings.contourForEraserAndSmudge);
if (!wantsContour) {
shape.crosshair = settings.style == BrushCursorStyle::Cross;
return shape;
}
const DabShape dab = brush.dabAt(pressure);
const halnf radius = dab.radius() * zoom;
const halnf roundness = std::clamp(dab.roundness, 0.01f, 1.0f);
const halnf angle = dab.angleDegrees * 3.14159265f / 180.0f + viewRotation;
const halnf cosine = std::cos(angle);
const halnf sine = std::sin(angle);
shape.contour = true;
shape.radius = radius;
shape.spotlight = settings.spotlight;
shape.crosshair = settings.crosshairForSmallContour && radius * 2.0f < kSmallContourPixels;
shape.outline.reserve((size_t) kContourSegments + 1);
for (int4 index = 0; index <= kContourSegments; index++) {
const halnf t = (halnf) index / (halnf) kContourSegments * 2.0f * 3.14159265f;
const halnf x = std::cos(t) * radius;
const halnf y = std::sin(t) * radius * roundness;
shape.outline.push_back({ center.x + x * cosine - y * sine, center.y + x * sine + y * cosine });
}
return shape;
}
}

View file

@ -0,0 +1,48 @@
#include "CanvasFrameGeometry.hpp"
#include <algorithm>
namespace tp::sketch2d {
RectF frameOnScreen(const Document& document) {
const RectF world = document.frame().rect();
const Vec2F topLeft = document.worldToScreen(world.pos);
const Vec2F bottomRight = document.worldToScreen(world.pos + world.size);
return { topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y };
}
RectF frameInView(const Document& document) {
const RectF world = document.frame().rect();
const Vec2F topLeft = document.worldToView(world.pos);
const Vec2F bottomRight = document.worldToView(world.pos + world.size);
return { topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y };
}
RectF visibleCanvasArea(const Document& document, Vec2F viewport) {
const RectF bounds = document.viewBoundsOfScreen(viewport);
if (document.isInfinite()) return bounds;
const RectF frame = frameInView(document);
const halnf x0 = std::clamp(frame.pos.x, bounds.pos.x, bounds.pos.x + bounds.size.x);
const halnf y0 = std::clamp(frame.pos.y, bounds.pos.y, bounds.pos.y + bounds.size.y);
const halnf x1 = std::clamp(frame.pos.x + frame.size.x, bounds.pos.x, bounds.pos.x + bounds.size.x);
const halnf y1 = std::clamp(frame.pos.y + frame.size.y, bounds.pos.y, bounds.pos.y + bounds.size.y);
return { x0, y0, std::max(0.0f, x1 - x0), std::max(0.0f, y1 - y0) };
}
std::vector<ShadowBand> canvasShadow(const RectF& frame) {
std::vector<ShadowBand> bands;
const halnf step = kCanvasShadowSpread / (halnf) kCanvasShadowSteps;
for (int4 index = kCanvasShadowSteps; index >= 1; index--) {
const halnf grow = step * (halnf) index;
const halnf falloff = 1.0f - (halnf) (index - 1) / (halnf) kCanvasShadowSteps;
const halnf alpha = kCanvasShadowAlpha * falloff * falloff / (halnf) kCanvasShadowSteps;
bands.push_back(
{ { frame.pos.x - grow, frame.pos.y - grow + grow / 3, frame.size.x + grow * 2, frame.size.y + grow * 2 },
{ 0, 0, 0, alpha } }
);
}
return bands;
}
}

View file

@ -0,0 +1,21 @@
#include "Checkerboard.hpp"
#include <cmath>
namespace tp::sketch2d {
std::vector<CheckerCell> checkerCells(const Document& document, Vec2F viewport) {
std::vector<CheckerCell> cells;
const Vec2F origin = document.worldToScreen({ 0, 0 });
const halnf firstX = std::fmod(origin.x, kCheckerCellPixels * 2) - kCheckerCellPixels * 2;
const halnf firstY = std::fmod(origin.y, kCheckerCellPixels * 2) - kCheckerCellPixels * 2;
cells.push_back({ { 0, 0 }, viewport, kCheckerLight });
for (halnf y = firstY; y < viewport.y; y += kCheckerCellPixels) {
const int4 row = (int4) std::lround((y - firstY) / kCheckerCellPixels);
for (halnf x = firstX + (row % 2 == 0 ? kCheckerCellPixels : 0); x < viewport.x; x += kCheckerCellPixels * 2)
cells.push_back({ { x, y }, { kCheckerCellPixels, kCheckerCellPixels }, kCheckerDark });
}
return cells;
}
}

View file

@ -0,0 +1,202 @@
#include "ColorModel.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <ctime>
#include <fstream>
#include <sstream>
namespace tp::sketch2d {
namespace {
halnf wrapHue(halnf degrees) { return std::fmod(std::fmod(degrees, 360.0f) + 360.0f, 360.0f); }
int4 channelByte(halnf channel) { return (int4) std::lround(std::clamp(channel, 0.0f, 1.0f) * 255.0f); }
int4 hexDigit(char digit) {
if (digit >= '0' && digit <= '9') return digit - '0';
const char lower = (char) std::tolower((unsigned char) digit);
if (lower >= 'a' && lower <= 'f') return lower - 'a' + 10;
return -1;
}
}
const HueColor ColorPair::kDefaultPrimary = { 220.0f, 0.6f, 0.5f };
const HueColor ColorPair::kDefaultSecondary = { 0.0f, 0.0f, 1.0f };
HueColor hueColorOf(const RGBA& color) {
const halnf high = std::max({ color.r, color.g, color.b });
const halnf low = std::min({ color.r, color.g, color.b });
const halnf chroma = high - low;
HueColor hue{ 0, 0, high };
if (high <= 0 || chroma <= 1e-6f) return hue;
hue.saturation = chroma / high;
halnf sector = 0;
if (high == color.r) sector = std::fmod((color.g - color.b) / chroma, 6.0f);
else if (high == color.g) sector = (color.b - color.r) / chroma + 2.0f;
else sector = (color.r - color.g) / chroma + 4.0f;
hue.hueDegrees = wrapHue(sector * 60.0f);
return hue;
}
HslColor hslOf(const HueColor& color) {
const halnf lightness = color.value * (1 - color.saturation / 2);
const halnf denominator = std::min(lightness, 1 - lightness);
const halnf saturation = denominator <= 1e-6f ? 0 : (color.value - lightness) / denominator;
return { color.hueDegrees, std::clamp(saturation, 0.0f, 1.0f), lightness };
}
HueColor hueColorOf(const HslColor& color) {
const halnf value = color.lightness + color.saturation * std::min(color.lightness, 1 - color.lightness);
const halnf saturation = value <= 1e-6f ? 0 : 2 * (1 - color.lightness / value);
return { color.hueDegrees, std::clamp(saturation, 0.0f, 1.0f), std::clamp(value, 0.0f, 1.0f) };
}
std::string colorHex(const RGBA& color) {
char text[8];
std::snprintf(text, sizeof(text), "%02X%02X%02X", channelByte(color.r), channelByte(color.g), channelByte(color.b));
return text;
}
std::optional<RGBA> parseColorHex(const std::string& text) {
std::string digits;
for (const char c : text)
if (!std::isspace((unsigned char) c) && c != '#') digits += c;
if (digits.size() == 3) digits = { digits[0], digits[0], digits[1], digits[1], digits[2], digits[2] };
if (digits.size() != 6) return std::nullopt;
int4 bytes[3];
for (int4 index = 0; index < 3; index++) {
const int4 high = hexDigit(digits[(size_t) index * 2]);
const int4 low = hexDigit(digits[(size_t) index * 2 + 1]);
if (high < 0 || low < 0) return std::nullopt;
bytes[index] = high * 16 + low;
}
return RGBA((halnf) bytes[0] / 255.0f, (halnf) bytes[1] / 255.0f, (halnf) bytes[2] / 255.0f, 1.0f);
}
void ColorPair::setPrimary(const RGBA& color) {
const HueColor converted = hueColorOf(color);
const bool gray = converted.saturation <= 1e-6f || converted.value <= 1e-6f;
mPrimary = { gray ? mPrimary.hueDegrees : converted.hueDegrees, converted.saturation, converted.value };
}
void ColorPair::resetToDefaults() {
mPrimary.value = 0.0f;
mSecondary.value = 1.0f;
}
void ColorPair::swap() { std::swap(mPrimary, mSecondary); }
void ColorPair::cooler(halnf degrees) {
const halnf hue = wrapHue(mPrimary.hueDegrees);
if (hue < kCoolHue) mPrimary.hueDegrees = std::min(hue + degrees, kCoolHue);
else mPrimary.hueDegrees = std::max(hue - degrees, kCoolHue);
}
void ColorPair::warmer(halnf degrees) {
const halnf hue = wrapHue(mPrimary.hueDegrees);
if (hue <= kCoolHue) mPrimary.hueDegrees = std::max(hue - degrees, 0.0f);
else mPrimary.hueDegrees = wrapHue(std::min(hue + degrees, 360.0f));
}
void ColorPair::complement() { mPrimary.hueDegrees = wrapHue(mPrimary.hueDegrees + 180.0f); }
void ColorPair::rotateHue(halnf degrees) { mPrimary.hueDegrees = wrapHue(mPrimary.hueDegrees + degrees); }
void ColorPair::changeSaturation(halnf delta) {
mPrimary.saturation = std::clamp(mPrimary.saturation + delta, 0.0f, 1.0f);
}
void ColorPair::changeValue(halnf delta) { setValue(mPrimary.value + delta); }
void ColorPair::setValue(halnf value) { mPrimary.value = std::clamp(value, 0.0f, 1.0f); }
namespace {
bool sameHex(const RGBA& left, const RGBA& right) { return colorHex(left) == colorHex(right); }
}
void ColorHistory::recordStroke(const HueColor& color) {
const RGBA rgb = color.rgba();
int4 strokes = 0;
for (size_t index = 0; index < mEntries.size(); index++) {
if (!sameHex(mEntries[index].color.rgba(), rgb)) continue;
strokes = mEntries[index].strokes;
mEntries.erase(mEntries.begin() + (std::ptrdiff_t) index);
break;
}
mEntries.insert(mEntries.begin(), { color, strokes + 1 });
if (mEntries.size() > kCapacity) mEntries.resize(kCapacity);
}
void ColorHistory::restore(std::vector<Entry> entries) {
mEntries = std::move(entries);
if (mEntries.size() > kCapacity) mEntries.resize(kCapacity);
}
bool SwatchSet::add(const RGBA& color) {
for (const RGBA& existing : mColors)
if (sameHex(existing, color)) return false;
if (mColors.size() >= kCapacity) return false;
mColors.push_back(RGBA(color.r, color.g, color.b, 1));
return true;
}
void SwatchSet::remove(size_t index) {
if (index < mColors.size()) mColors.erase(mColors.begin() + (std::ptrdiff_t) index);
}
std::string SwatchSet::toJson(int8 created, int8 modified) const {
std::string text = "{\n\t\"object\" : \"swatches\",\n";
text += "\t\"created\" : \"" + std::to_string(created) + "\",\n";
text += "\t\"modified\" : \"" + std::to_string(modified) + "\",\n";
text += "\t\"data\" : [\n";
for (size_t index = 0; index < mColors.size(); index++) {
text += "\t\t{ \"hex\" : \"#" + colorHex(mColors[index]) + "\" }";
text += index + 1 < mColors.size() ? ",\n" : "\n";
}
return text + "\t]\n}\n";
}
std::optional<SwatchSet> SwatchSet::fromJson(const std::string& text) {
const size_t object = text.find("\"object\"");
if (object == std::string::npos) return std::nullopt;
const size_t kind = text.find('"', text.find(':', object));
if (kind == std::string::npos || text.compare(kind, 10, "\"swatches\"") != 0) return std::nullopt;
const size_t data = text.find("\"data\"");
if (data == std::string::npos) return std::nullopt;
SwatchSet set;
size_t cursor = text.find('[', data);
if (cursor == std::string::npos) return std::nullopt;
const size_t end = text.find(']', cursor);
while (true) {
const size_t hex = text.find("\"hex\"", cursor);
if (hex == std::string::npos || hex > end) break;
const size_t open = text.find('"', text.find(':', hex));
const size_t close = open == std::string::npos ? open : text.find('"', open + 1);
if (close == std::string::npos) return std::nullopt;
const auto parsed = parseColorHex(text.substr(open + 1, close - open - 1));
if (!parsed) return std::nullopt;
set.add(*parsed);
cursor = close + 1;
}
return set;
}
bool SwatchSet::save(const std::string& path) const {
std::ofstream file(path, std::ios::binary | std::ios::trunc);
if (!file) return false;
const int8 now = (int8) std::time(nullptr);
file << toJson(now, now);
return (bool) file;
}
SwatchSet SwatchSet::load(const std::string& path) {
std::ifstream file(path, std::ios::binary);
if (!file) return {};
std::stringstream text;
text << file.rdbuf();
return fromJson(text.str()).value_or(SwatchSet());
}
}

View file

@ -0,0 +1,508 @@
#include "Compositor.hpp"
#include "Profiling.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
namespace tp::sketch2d {
namespace {
halnf clamp01(halnf value) { return value < 0 ? 0 : (value > 1 ? 1 : value); }
halnf unpremultiplied(halnf channel, halnf alpha) { return alpha > 0 ? clamp01(channel / alpha) : 0; }
halnf colorDodge(halnf below, halnf above) { return above >= 1 ? 1 : std::min(1.0f, below / (1 - above)); }
halnf colorBurn(halnf below, halnf above) { return above <= 0 ? 0 : std::max(0.0f, 1 + (below - 1) / above); }
struct Premultiplied {
halnf c[3];
halnf a;
};
Premultiplied fromPixel(const BlendPixel& pixel, halnf scale) {
return { { pixel.r * scale, pixel.g * scale, pixel.b * scale }, pixel.a * scale };
}
halnf luma(const halnf c[3]) { return 0.2990f * c[0] + 0.5870f * c[1] + 0.1140f * c[2]; }
void colorFromLumaChroma(halnf result[3], halnf y, halnf cb, halnf cr, halnf alpha) {
const halnf dr = 1.402f * cr;
const halnf dg = -0.344136f * cb - 0.714136f * cr;
const halnf db = 1.772f * cb;
halnf scale = 1;
for (const halnf delta : { dr, dg, db }) {
if (y + delta < 0 && delta != 0) scale = std::min(scale, -y / delta);
if (y + delta > alpha && delta != 0) scale = std::min(scale, (alpha - y) / delta);
}
scale = std::max(0.0f, scale);
result[0] = y + dr * scale;
result[1] = y + dg * scale;
result[2] = y + db * scale;
}
halnf blendedChannel(BlendMode mode, halnf d, halnf da, halnf s, halnf sa, halnf opacity) {
switch (mode) {
case BlendMode::Multiply: return s * d;
case BlendMode::Screen: return sa * da - (sa - s) * (da - d);
case BlendMode::Overlay: return d > da / 2 ? sa * da - 2 * (sa - s) * (da - d) : 2 * s * d;
case BlendMode::Lighten: return std::max(unpremultiplied(d, da), unpremultiplied(s, sa)) * sa * da;
case BlendMode::Darken: return std::min(unpremultiplied(d, da), unpremultiplied(s, sa)) * sa * da;
case BlendMode::ColorDodge: return colorDodge(unpremultiplied(d, da), unpremultiplied(s, sa)) * sa * da;
case BlendMode::ColorBurn: return colorBurn(unpremultiplied(d, da), unpremultiplied(s, sa)) * sa * da;
case BlendMode::SoftDodge: return std::min((s + 1) * d, da) * sa;
case BlendMode::SoftBurn: return std::min(((s * opacity - sa + 1) * 0.5f + 0.5f) * d, da) * sa;
default: return s;
}
}
BlendPixel pixelAt(const Tile* tile, ualni index) {
if (!tile) return {};
const uint1* px = tile->pixels() + index * 4;
return { px[0] / 255.0f, px[1] / 255.0f, px[2] / 255.0f, px[3] / 255.0f };
}
void store(uint1* px, const BlendPixel& pixel) {
px[0] = (uint1) std::lround(clamp01(pixel.r) * 255.0f);
px[1] = (uint1) std::lround(clamp01(pixel.g) * 255.0f);
px[2] = (uint1) std::lround(clamp01(pixel.b) * 255.0f);
px[3] = (uint1) std::lround(clamp01(pixel.a) * 255.0f);
}
ualni mix(ualni hash, ualni value) {
hash ^= value + 0x9e3779b97f4a7c15ull + (hash << 6) + (hash >> 2);
return hash;
}
}
BlendPixel blendPixel(BlendMode mode, const BlendPixel& below, const BlendPixel& above, halnf opacity) {
const Premultiplied d = fromPixel(below, 1);
const Premultiplied s = fromPixel(above, opacity);
if (s.a <= 0) return below;
halnf result[3];
if (mode == BlendMode::Normal) {
for (int4 channel = 0; channel < 3; channel++)
result[channel] = s.c[channel] + d.c[channel] * (1 - s.a);
return { result[0], result[1], result[2], s.a + d.a * (1 - s.a) };
}
if (mode == BlendMode::Color) {
halnf over[3];
for (int4 channel = 0; channel < 3; channel++)
over[channel] = s.c[channel] + d.c[channel] * (1 - s.a);
const halnf overAlpha = s.a + d.a * (1 - s.a);
const halnf cb = (0.5f * over[2] - 0.331264f * over[1] - 0.168736f * over[0]) / overAlpha;
const halnf cr = (0.5f * over[0] - 0.418688f * over[1] - 0.081312f * over[2]) / overAlpha;
colorFromLumaChroma(result, luma(d.c), cb * d.a, cr * d.a, d.a);
for (halnf& channel : result)
channel *= s.a;
} else {
for (int4 channel = 0; channel < 3; channel++)
result[channel] = blendedChannel(mode, d.c[channel], d.a, s.c[channel], s.a, opacity);
}
for (int4 channel = 0; channel < 3; channel++)
result[channel] += d.c[channel] * (1 - s.a) + s.c[channel] * (1 - d.a);
return { result[0], result[1], result[2], s.a + d.a * (1 - s.a) };
}
ualni Compositor::signatureOf(const std::vector<StackEntry>& stack) {
ualni hash = 1469598103934665603ull;
for (const StackEntry& entry : stack) {
const Layer::Properties& properties = entry.layer->properties();
hash = mix(hash, entry.layer->id());
hash = mix(hash, (ualni) (uintptr_t) entry.tile);
hash = mix(hash, (ualni) std::lround(properties.opacity * 65535.0f));
hash = mix(hash, (ualni) properties.blend);
hash = mix(hash, properties.clipped ? 1 : 0);
}
return hash;
}
void Compositor::PixelRect::include(const PixelRect& other) {
if (other.isEmpty()) return;
if (isEmpty()) {
*this = other;
return;
}
x0 = std::min(x0, other.x0);
y0 = std::min(y0, other.y0);
x1 = std::max(x1, other.x1);
y1 = std::max(y1, other.y1);
}
Compositor::PixelRect Compositor::PixelRect::whole() { return { 0, 0, kTileSize, kTileSize }; }
DirtyBands Compositor::pendingChanges(const std::vector<StackEntry>& stack) {
DirtyBands changed;
for (const StackEntry& entry : stack) {
if (entry.tile) changed.include(entry.tile->compositeBands());
}
return changed;
}
bool Compositor::isPlainCopy(const std::vector<StackEntry>& stack) {
if (stack.size() != 1 || !stack.front().tile) return false;
const Layer& layer = *stack.front().layer;
return layer.blend() == BlendMode::Normal && !layer.clipped() && layer.opacity() >= 1.0f;
}
bool Compositor::isNormalStack(const std::vector<StackEntry>& stack) {
for (const StackEntry& entry : stack) {
if (entry.layer->blend() != BlendMode::Normal || entry.layer->clipped()) return false;
}
return true;
}
void Compositor::compositeRows(const std::vector<StackEntry>& stack, uint1* out, int4 x0, int4 x1, int4 y) {
const ualni rowStart = (ualni) y * kTileSize;
if (isPlainCopy(stack)) {
std::memcpy(
out + (rowStart + (ualni) x0) * 4,
stack.front().tile->pixels() + (rowStart + (ualni) x0) * 4,
(size_t) (x1 - x0) * 4
);
return;
}
if (isNormalStack(stack)) {
uint4 opacity[64];
const size_t layers = std::min(stack.size(), (size_t) 64);
for (size_t index = 0; index < layers; index++)
opacity[index] = (uint4) std::lround(std::clamp(stack[index].layer->opacity(), 0.0f, 1.0f) * 255.0f);
for (int4 x = x0; x < x1; x++) {
const ualni index = rowStart + (ualni) x;
uint4 result[4] = { 0, 0, 0, 0 };
for (size_t layer = 0; layer < layers; layer++) {
const Tile* tile = stack[layer].tile;
if (!tile) continue;
const uint1* above = tile->pixels() + index * 4;
if (above[3] == 0 || opacity[layer] == 0) continue;
uint4 source[4];
for (int4 channel = 0; channel < 4; channel++)
source[channel] = (above[channel] * opacity[layer] + 127) / 255;
const uint4 keep = 255 - source[3];
for (int4 channel = 0; channel < 4; channel++)
result[channel] = source[channel] + (result[channel] * keep + 127) / 255;
}
uint1* target = out + index * 4;
for (int4 channel = 0; channel < 4; channel++)
target[channel] = (uint1) std::min(result[channel], 255u);
}
return;
}
for (int4 x = x0; x < x1; x++) {
const ualni index = rowStart + (ualni) x;
BlendPixel result;
halnf clipBase = 0;
for (const StackEntry& entry : stack) {
const Layer& layer = *entry.layer;
BlendPixel above = pixelAt(entry.tile, index);
if (layer.clipped()) {
above.r *= clipBase;
above.g *= clipBase;
above.b *= clipBase;
above.a *= clipBase;
} else {
clipBase = above.a;
}
if (above.a <= 0) continue;
result = blendPixel(layer.blend(), result, above, layer.opacity());
}
store(out + index * 4, result);
}
}
void Compositor::composite(const std::vector<StackEntry>& stack, Tile& target, const PixelRect& rect) {
for (int4 y = rect.y0; y < rect.y1; y++)
compositeRows(stack, target.pixels(), rect.x0, rect.x1, y);
target.markDirty({ (halnf) rect.x0, (halnf) rect.y0, (halnf) (rect.x1 - rect.x0), (halnf) (rect.y1 - rect.y0) });
}
void Compositor::composite(const std::vector<StackEntry>& stack, Tile& target, const DirtyBands& bands) {
for (int4 band = 0; band < DirtyBands::kBands; band++) {
const DirtySpan& span = bands.band(band);
if (span.isEmpty()) continue;
for (int4 y = span.y0; y < span.y1; y++)
compositeRows(stack, target.pixels(), span.x0, span.x1, y);
target.markDirty({ (halnf) span.x0, (halnf) span.y0, (halnf) (span.x1 - span.x0), (halnf) (span.y1 - span.y0) });
}
}
bool Compositor::isVisibleIn(const Document&, const Layer& layer, bool& baseVisible) {
if (!layer.clipped()) baseVisible = layer.visible();
return layer.visible() && (!layer.clipped() || baseVisible);
}
std::vector<Compositor::StackEntry> Compositor::stackAt(const Document& document, const TileCoord& coord) {
std::vector<StackEntry> stack;
bool baseVisible = false;
for (const auto& layer : document.layers()) {
if (!isVisibleIn(document, *layer, baseVisible)) continue;
Tile* preview = layer->levels().basePreview(coord);
stack.push_back({ layer.get(), preview ? preview : layer->tileIfExists(coord) });
}
return stack;
}
void Compositor::flatten(const std::vector<const Layer*>& layers, const TileCoord& coord, Tile& target) {
std::vector<StackEntry> stack;
for (const Layer* layer : layers)
stack.push_back({ layer, layer->tileIfExists(coord) });
composite(stack, target, PixelRect::whole());
}
LayerLevels* Compositor::levelsOf(const Layer& layer) const { return &layer.levels(); }
void Compositor::syncLevels(const Document& document, int4 level) {
const Engine& engine = document.engine();
for (const auto& layer : document.layers()) {
const Layer* owner = layer.get();
layer->levels().setPendingCheck([&engine, owner](const TileCoord& coord) {
return engine.hasWork(*owner, coord);
});
layer->levels().update(*layer, level, mParallel);
}
}
void Compositor::update(const Document& document) { update(document, 0); }
void Compositor::update(const Document& document, int4 level) {
{
TP_PROFILE_SCOPE("canvas.mips");
syncLevels(document, level);
}
const bool levelChanged = false;
mLevel = level;
TP_PROFILE_SCOPE("canvas.stack");
if (level == 0) {
updateBase(document, levelChanged);
return;
}
updateLevel(document, level, levelChanged);
}
void Compositor::updateLevel(const Document& document, int4 level, bool levelChanged) {
LevelCache& cache = mLevels[level];
mFrame++;
mLastComposited = 0;
mLastPixels = 0;
if (!levelChanged) mRemoved.clear();
mChanged.clear();
ualni layout = 1469598103934665603ull;
for (const auto& layer : document.layers()) {
const Layer::Properties& properties = layer->properties();
layout = mix(layout, layer->id());
layout = mix(layout, layer->serial());
layout = mix(layout, properties.visible ? 1 : 0);
layout = mix(layout, (ualni) std::lround(properties.opacity * 65535.0f));
layout = mix(layout, (ualni) properties.blend);
layout = mix(layout, properties.clipped ? 1 : 0);
}
const bool full = !cache.hasLayout || layout != cache.layout;
cache.layout = layout;
cache.hasLayout = true;
Map<ualni, TileCoord> coords;
for (const auto& layer : document.layers()) {
LayerLevels* levels = levelsOf(*layer);
if (!levels) continue;
if (full) {
if (layer->visible())
levels->forEachTile(level, [&coords](const TileCoord& coord, Tile*) { coords.put(coord.key(), coord); });
levels->takeChanges(level, [](const TileCoord&) {});
} else {
levels->takeChanges(level, [&coords, &layer](const TileCoord& coord) {
if (layer->visible()) coords.put(coord.key(), coord);
});
}
}
if (full)
for (auto it : cache.tiles)
coords.put(it->key, it->val.coord);
struct Job {
std::vector<StackEntry> stack;
Tile* target;
};
std::vector<Job> jobs;
for (auto it : coords) {
const TileCoord coord = it->val;
std::vector<StackEntry> stack;
bool baseVisible = false;
bool any = false;
for (const auto& layer : document.layers()) {
if (!isVisibleIn(document, *layer, baseVisible)) continue;
LayerLevels* levels = levelsOf(*layer);
Tile* tile = levels ? levels->tileAt(level, coord) : nullptr;
any = any || tile;
stack.push_back({ layer.get(), tile });
}
auto found = cache.tiles.presents(coord.key());
if (!any) {
if (found.isValid()) {
mRemoved.push_back(cache.tiles.getSlotVal(found).tile);
cache.tiles.remove(coord.key());
mChanged.push_back(coord);
}
continue;
}
if (!found.isValid()) cache.tiles.put(coord.key(), { coord, std::make_shared<Tile>(), 0, mFrame });
CompositeTile& entry = cache.tiles.get(coord.key());
entry.frame = mFrame;
entry.signature = signatureOf(stack);
jobs.push_back({ std::move(stack), entry.tile.get() });
mChanged.push_back(coord);
mLastComposited++;
mLastPixels += (ualni) kTileSize * kTileSize;
}
const auto run = [&jobs](size_t index) {
Job& job = jobs[index];
for (int4 y = 0; y < kTileSize; y++)
compositeRows(job.stack, job.target->pixels(), 0, kTileSize, y);
};
if (mParallel) mParallel->run(jobs.size(), run);
else
for (size_t index = 0; index < jobs.size(); index++)
run(index);
for (Job& job : jobs)
job.target->markDirty({ 0, 0, (halnf) kTileSize, (halnf) kTileSize });
}
void Compositor::updateBase(const Document& document, bool levelChanged) {
LevelCache& cache = mLevels[0];
mFrame++;
mLastComposited = 0;
mLastPixels = 0;
ualni layout = 1469598103934665603ull;
for (const auto& layer : document.layers()) {
const Layer::Properties& properties = layer->properties();
layout = mix(layout, layer->id());
layout = mix(layout, layer->serial());
layout = mix(layout, properties.visible ? 1 : 0);
layout = mix(layout, (ualni) std::lround(properties.opacity * 65535.0f));
layout = mix(layout, (ualni) properties.blend);
layout = mix(layout, properties.clipped ? 1 : 0);
}
const bool full = !cache.hasLayout || layout != cache.layout;
cache.layout = layout;
cache.hasLayout = true;
Map<ualni, TileCoord> coords;
if (full) {
document.forEachVisibleTile([&](const TileCoord& coord, Tile*) { coords.put(coord.key(), coord); });
for (auto it : cache.tiles)
coords.put(it->key, it->val.coord);
}
for (const auto& layer : document.layers()) {
if (!full && layer->visible())
layer->forEachChanged([&](const TileCoord& coord) { coords.put(coord.key(), coord); });
layer->clearChanges();
}
mChanged.clear();
if (!levelChanged) mRemoved.clear();
struct Job {
std::vector<StackEntry> stack;
Tile* target;
DirtyBands bands;
bool whole;
};
std::vector<Job> jobs;
for (auto it : coords) {
const TileCoord coord = it->val;
std::vector<StackEntry> stack = stackAt(document, coord);
if (std::none_of(stack.begin(), stack.end(), [](const StackEntry& entry) { return entry.tile; })) {
auto gone = cache.tiles.presents(coord.key());
if (gone.isValid()) {
mRemoved.push_back(cache.tiles.getSlotVal(gone).tile);
cache.tiles.remove(coord.key());
mChanged.push_back(coord);
}
continue;
}
const ualni signature = signatureOf(stack);
const DirtyBands pending = pendingChanges(stack);
for (const StackEntry& entry : stack) {
if (!entry.tile) continue;
entry.tile->clearDirty();
entry.tile->clearCompositeBands();
}
auto found = cache.tiles.presents(coord.key());
if (found.isValid()) {
CompositeTile& existing = cache.tiles.getSlotVal(found);
existing.frame = mFrame;
const bool same = existing.signature == signature;
existing.signature = signature;
if (same && pending.isEmpty()) continue;
mLastPixels += same ? pending.area() : (ualni) kTileSize * kTileSize;
jobs.push_back({ std::move(stack), existing.tile.get(), pending, !same });
mChanged.push_back(coord);
mLastComposited++;
continue;
}
CompositeTile created{ coord, std::make_shared<Tile>(), signature, mFrame };
cache.tiles.put(coord.key(), created);
mChanged.push_back(coord);
jobs.push_back({ std::move(stack), created.tile.get(), {}, true });
mLastComposited++;
mLastPixels += (ualni) kTileSize * kTileSize;
}
const auto run = [&jobs](size_t index) {
Job& job = jobs[index];
if (job.whole) {
for (int4 y = 0; y < kTileSize; y++)
compositeRows(job.stack, job.target->pixels(), 0, kTileSize, y);
return;
}
for (int4 band = 0; band < DirtyBands::kBands; band++) {
const DirtySpan& span = job.bands.band(band);
for (int4 y = span.y0; y < span.y1; y++)
compositeRows(job.stack, job.target->pixels(), span.x0, span.x1, y);
}
};
if (mParallel) mParallel->run(jobs.size(), run);
else
for (size_t index = 0; index < jobs.size(); index++)
run(index);
for (Job& job : jobs) {
if (job.whole) {
job.target->markDirty({ 0, 0, (halnf) kTileSize, (halnf) kTileSize });
continue;
}
for (int4 band = 0; band < DirtyBands::kBands; band++) {
const DirtySpan& span = job.bands.band(band);
if (span.isEmpty()) continue;
job.target->markDirty(
{ (halnf) span.x0, (halnf) span.y0, (halnf) (span.x1 - span.x0), (halnf) (span.y1 - span.y0) }
);
}
}
}
Tile* Compositor::tileAt(int4 level, const TileCoord& coord) const {
auto found = mLevels[level].tiles.presents(coord.key());
return found.isValid() ? mLevels[level].tiles.getSlotVal(found).tile.get() : nullptr;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,119 @@
#include "LayerThumbnail.hpp"
#include <algorithm>
#include <cmath>
namespace tp::sketch2d {
RectF LayerThumbnail::documentBounds(const Document& document) {
bool any = false;
int4 minX = 0;
int4 minY = 0;
int4 maxX = 0;
int4 maxY = 0;
document.forEachTile([&](const TileCoord& coord, Tile*) {
if (!any) {
minX = maxX = coord.x;
minY = maxY = coord.y;
any = true;
return;
}
minX = std::min(minX, coord.x);
minY = std::min(minY, coord.y);
maxX = std::max(maxX, coord.x);
maxY = std::max(maxY, coord.y);
});
if (!any) return { 0, 0, (halnf) kTileSize, (halnf) kTileSize };
const halnf width = (halnf) ((maxX - minX + 1) * kTileSize);
const halnf height = (halnf) ((maxY - minY + 1) * kTileSize);
const halnf side = std::max(width, height);
return {
(halnf) (minX * kTileSize) - (side - width) / 2, (halnf) (minY * kTileSize) - (side - height) / 2, side, side
};
}
std::vector<uint1> LayerThumbnail::render(const Layer& layer, const RectF& worldBounds, int4 size) {
const halnf worldStep = worldBounds.size.x / (halnf) size;
LayerLevels& levels = layer.levels();
int4 level = 0;
while (level + 1 < LayerLevels::kLevels && (halnf) (2 << level) <= worldStep)
level++;
levels.update(layer, level);
if (level > 0 && levels.builtLevels() >= level) return renderLevel(layer, levels, level, worldBounds, size);
std::vector<uint1> pixels((size_t) size * size * 4, 0);
const halnf step = worldStep;
const int4 samples = std::clamp((int4) std::ceil(step / 8.0f), 1, 8);
for (int4 y = 0; y < size; y++) {
for (int4 x = 0; x < size; x++) {
halnf sum[4] = { 0, 0, 0, 0 };
for (int4 sy = 0; sy < samples; sy++) {
for (int4 sx = 0; sx < samples; sx++) {
const halnf wx = worldBounds.pos.x + ((halnf) x + ((halnf) sx + 0.5f) / (halnf) samples) * step;
const halnf wy = worldBounds.pos.y + ((halnf) y + ((halnf) sy + 0.5f) / (halnf) samples) * step;
const int4 px = (int4) std::floor(wx);
const int4 py = (int4) std::floor(wy);
const TileCoord coord = { floordiv(px, kTileSize), floordiv(py, kTileSize) };
const Tile* tile = layer.tileIfExists(coord);
if (!tile) continue;
const int4 lx = px - coord.x * kTileSize;
const int4 ly = py - coord.y * kTileSize;
const uint1* source = tile->pixels() + ((ualni) ly * kTileSize + (ualni) lx) * 4;
for (int4 channel = 0; channel < 4; channel++)
sum[channel] += source[channel];
}
}
const halnf count = (halnf) (samples * samples);
const halnf alpha = sum[3] / count;
uint1* out = pixels.data() + ((size_t) y * size + (size_t) x) * 4;
for (int4 channel = 0; channel < 3; channel++)
out[channel] = alpha > 0 ? (uint1) std::lround(std::min(255.0f, sum[channel] / count * 255.0f / alpha)) : 0;
out[3] = (uint1) std::lround(alpha);
}
}
return pixels;
}
std::vector<uint1> LayerThumbnail::renderLevel(
const Layer& layer, const LayerLevels& levels, int4 level, const RectF& worldBounds, int4 size
) {
(void) layer;
std::vector<uint1> pixels((size_t) size * size * 4, 0);
const halnf scale = (halnf) (1 << level);
const halnf step = worldBounds.size.x / (halnf) size / scale;
const int4 samples = std::clamp((int4) std::ceil(step), 1, 4);
for (int4 y = 0; y < size; y++) {
for (int4 x = 0; x < size; x++) {
halnf sum[4] = { 0, 0, 0, 0 };
for (int4 sy = 0; sy < samples; sy++) {
for (int4 sx = 0; sx < samples; sx++) {
const halnf wx = worldBounds.pos.x / scale + ((halnf) x + ((halnf) sx + 0.5f) / (halnf) samples) * step;
const halnf wy = worldBounds.pos.y / scale + ((halnf) y + ((halnf) sy + 0.5f) / (halnf) samples) * step;
const int4 px = (int4) std::floor(wx);
const int4 py = (int4) std::floor(wy);
const TileCoord coord = { floordiv(px, kTileSize), floordiv(py, kTileSize) };
const Tile* tile = levels.tileAt(level, coord);
if (!tile) continue;
const uint1* source =
tile->pixels() +
((ualni) (py - coord.y * kTileSize) * kTileSize + (ualni) (px - coord.x * kTileSize)) * 4;
for (int4 channel = 0; channel < 4; channel++)
sum[channel] += source[channel];
}
}
const halnf count = (halnf) (samples * samples);
const halnf alpha = sum[3] / count;
uint1* out = pixels.data() + ((size_t) y * size + (size_t) x) * 4;
for (int4 channel = 0; channel < 3; channel++)
out[channel] = alpha > 0 ? (uint1) std::lround(std::min(255.0f, sum[channel] / count * 255.0f / alpha)) : 0;
out[3] = (uint1) std::lround(alpha);
}
}
return pixels;
}
}

View file

@ -0,0 +1,154 @@
#include "StrokeTrace.hpp"
#include "DabMask.hpp"
#include <algorithm>
#include <cmath>
namespace tp::sketch2d {
std::optional<TracedDab>
traceDab(const Brush& brush, const DabShape& requested, const DabTarget& target, Vec2F center) {
const DabShape dab = DabQuantizer::shape(requested);
const halnf radius = dab.radius();
if (radius <= 0.0f) return std::nullopt;
if (brush.erases() && target.keepAlpha) return std::nullopt;
auto work = std::make_shared<DabWork>();
work->dab = PreparedDab(dab);
if (work->dab.isEmpty()) return std::nullopt;
const bool masked = !brush.erases() && !brush.markerBlend() && !target.keepAlpha;
work->center = center;
work->strength = masked ? dab.flow : dab.flow * dab.opacity;
work->opacity = masked ? dab.opacity : 1.0f;
work->source[0] = brush.color.r * 255.0f;
work->source[1] = brush.color.g * 255.0f;
work->source[2] = brush.color.b * 255.0f;
work->erase = brush.erases();
work->marker = brush.markerBlend();
work->keepAlpha = target.keepAlpha;
work->selection = target.selection;
TracedDab traced;
traced.minX = (int4) std::floor(center.x - radius);
traced.minY = (int4) std::floor(center.y - radius);
traced.maxX = (int4) std::ceil(center.x + radius);
traced.maxY = (int4) std::ceil(center.y + radius);
traced.work = std::move(work);
return traced;
}
void StrokeStyle::forEachDab(Vec2F center, halnf pressure, const std::function<void(TracedDab dab)>& emit) const {
const DabShape shape = brush.dabAt(pressure);
if (std::optional<TracedDab> dab = traceDab(brush, shape, target, center)) emit(std::move(*dab));
if (!mirror) return;
if (std::optional<TracedDab> dab = traceDab(brush, shape, target, mirror(center))) emit(std::move(*dab));
}
namespace {
struct TileBox {
int4 left = 1;
int4 top = 1;
int4 right = 0;
int4 bottom = 0;
static TileBox around(Vec2F from, Vec2F to, halnf reach) {
return { floordiv((int4) std::floor(std::min(from.x, to.x) - reach), kTileSize),
floordiv((int4) std::floor(std::min(from.y, to.y) - reach), kTileSize),
floordiv((int4) std::ceil(std::max(from.x, to.x) + reach), kTileSize),
floordiv((int4) std::ceil(std::max(from.y, to.y) + reach), kTileSize) };
}
[[nodiscard]] bool contains(int4 x, int4 y) const { return x >= left && x <= right && y >= top && y <= bottom; }
};
void coverTiles(const TileBox& box, TileBox& last, Map<ualni, TileCoord>& tiles) {
for (int4 ty = box.top; ty <= box.bottom; ty++)
for (int4 tx = box.left; tx <= box.right; tx++)
if (!last.contains(tx, ty)) tiles.put(TileCoord{ tx, ty }.key(), { tx, ty });
last = box;
}
}
void StrokeTrace::record(TracedDab dab) {
mKeepsAlpha = mKeepsAlpha || dab.work->keepAlpha;
for (int4 ty = floordiv(dab.minY, kTileSize); ty <= floordiv(dab.maxY - 1, kTileSize); ty++)
for (int4 tx = floordiv(dab.minX, kTileSize); tx <= floordiv(dab.maxX - 1, kTileSize); tx++)
mRecordedTiles.put(TileCoord{ tx, ty }.key(), { tx, ty });
mRecorded++;
index(std::move(dab));
}
void StrokeTrace::setRange(PathRange range) {
mKeepsAlpha = mKeepsAlpha || range.style.target.keepAlpha;
mRange = std::move(range);
}
void StrokeTrace::index(TracedDab dab) {
const ualni position = mDabs.size();
for (int4 ty = floordiv(dab.minY, kTileSize); ty <= floordiv(dab.maxY - 1, kTileSize); ty++)
for (int4 tx = floordiv(dab.minX, kTileSize); tx <= floordiv(dab.maxX - 1, kTileSize); tx++) {
const ualni key = TileCoord{ tx, ty }.key();
auto found = mTileDabs.presents(key);
if (!found.isValid()) {
mTileDabs.put(key, {});
found = mTileDabs.presents(key);
}
mTileDabs.getSlotVal(found).push_back(position);
}
mDabs.push_back(std::move(dab));
}
StrokePath::Walk StrokeTrace::endWalk() {
expand();
return mEndWalk;
}
void StrokeTrace::expand() {
std::call_once(mExpanded, [this]() {
if (!mRange) return;
PathRange& range = *mRange;
StrokePath::Walk walk = range.from ? *range.from : range.previous->endWalk();
range.previous.reset();
const Brush& brush = range.style.brush;
range.path.advanceTo(
walk,
range.toParameter,
[&brush](halnf pressure) { return brush.dabDistance(pressure); },
[this, &range](const StrokePath::Sample& sample) {
range.style.forEachDab(sample.position, sample.pressure, [this](TracedDab dab) { index(std::move(dab)); });
}
);
mEndWalk = walk;
});
}
const std::vector<ualni>* StrokeTrace::dabsFor(const TileCoord& coord) const {
auto found = mTileDabs.presents(coord.key());
return found.isValid() ? &mTileDabs.getSlotVal(found) : nullptr;
}
std::vector<TileCoord> StrokeTrace::tiles() const {
Map<ualni, TileCoord> tiles = mRecordedTiles;
collectRangeTiles(tiles);
std::vector<TileCoord> result;
result.reserve(tiles.size());
for (auto it : tiles)
result.push_back(it->val);
return result;
}
void StrokeTrace::collectRangeTiles(Map<ualni, TileCoord>& tiles) const {
if (!mRange) return;
const PathRange& range = *mRange;
const halnf reach = range.style.brush.dabAt(1.0f).radius() + 2.0f;
TileBox last;
TileBox mirrored;
range.path.forEachPoint(range.fromParameter, range.toParameter, [&](Vec2F from, Vec2F to) {
coverTiles(TileBox::around(from, to, reach), last, tiles);
if (range.style.mirror)
coverTiles(TileBox::around(range.style.mirror(from), range.style.mirror(to), reach), mirrored, tiles);
});
}
}

View file

@ -0,0 +1,49 @@
#pragma once
#include "Brush.hpp"
#include "Document.hpp"
#include "Vec.hpp"
#include <string>
#include <vector>
namespace tp::sketch2d {
enum class BrushCursorStyle { Contour, Brush, Arrow, Cross };
inline constexpr int4 kBrushCursorStyleCount = 4;
[[nodiscard]] const char* brushCursorStyleName(BrushCursorStyle style);
struct BrushCursorSettings {
BrushCursorStyle style = BrushCursorStyle::Contour;
bool contourForEraserAndSmudge = true;
bool crosshairForSmallContour = true;
bool spotlight = false;
HueColor spotlightColor = { 60.0f, 1.0f, 1.0f };
halnf spotlightAlpha = 0.4f;
bool showForPen = true;
};
struct BrushCursorShape {
static constexpr halnf kSmallContourPixels = 8.0f;
static constexpr halnf kCrossArm = 6.0f;
static constexpr int4 kContourSegments = 64;
bool contour = false;
bool crosshair = false;
bool spotlight = false;
Vec2F center = { 0, 0 };
halnf radius = 0;
std::vector<Vec2F> outline;
static BrushCursorShape build(
const BrushCursorSettings& settings,
const Brush& brush,
Vec2F center,
halnf zoom,
halnf viewRotation,
halnf pressure = 1.0f
);
};
}

View file

@ -0,0 +1,22 @@
#pragma once
#include "Document.hpp"
#include <vector>
namespace tp::sketch2d {
struct ShadowBand {
RectF rect;
RGBA color;
};
constexpr halnf kCanvasShadowAlpha = 0.6f;
constexpr int4 kCanvasShadowSteps = 6;
constexpr halnf kCanvasShadowSpread = 18.0f;
[[nodiscard]] RectF frameOnScreen(const Document& document);
[[nodiscard]] RectF frameInView(const Document& document);
[[nodiscard]] RectF visibleCanvasArea(const Document& document, Vec2F viewport);
[[nodiscard]] std::vector<ShadowBand> canvasShadow(const RectF& frame);
}

View file

@ -0,0 +1,20 @@
#pragma once
#include "Document.hpp"
#include <vector>
namespace tp::sketch2d {
struct CheckerCell {
Vec2F position;
Vec2F size;
RGBA color;
};
constexpr halnf kCheckerCellPixels = 8;
inline const RGBA kCheckerLight = { 0.80f, 0.80f, 0.80f, 1.0f };
inline const RGBA kCheckerDark = { 0.62f, 0.62f, 0.62f, 1.0f };
[[nodiscard]] std::vector<CheckerCell> checkerCells(const Document& document, Vec2F viewport);
}

View file

@ -0,0 +1,89 @@
#pragma once
#include "Document.hpp"
#include <optional>
#include <string>
#include <vector>
namespace tp::sketch2d {
struct HslColor {
halnf hueDegrees = 0;
halnf saturation = 0;
halnf lightness = 0;
};
[[nodiscard]] HueColor hueColorOf(const RGBA& color);
[[nodiscard]] HslColor hslOf(const HueColor& color);
[[nodiscard]] HueColor hueColorOf(const HslColor& color);
[[nodiscard]] std::string colorHex(const RGBA& color);
[[nodiscard]] std::optional<RGBA> parseColorHex(const std::string& text);
class ColorHistory {
public:
static constexpr size_t kCapacity = 7;
struct Entry {
HueColor color;
int4 strokes = 0;
};
void recordStroke(const HueColor& color);
void restore(std::vector<Entry> entries);
void clear() { mEntries.clear(); }
[[nodiscard]] const std::vector<Entry>& entries() const { return mEntries; }
private:
std::vector<Entry> mEntries;
};
class SwatchSet {
public:
static constexpr size_t kCapacity = 64;
bool add(const RGBA& color);
void remove(size_t index);
[[nodiscard]] const std::vector<RGBA>& colors() const { return mColors; }
[[nodiscard]] std::string toJson(int8 created, int8 modified) const;
[[nodiscard]] static std::optional<SwatchSet> fromJson(const std::string& text);
bool save(const std::string& path) const;
[[nodiscard]] static SwatchSet load(const std::string& path);
private:
std::vector<RGBA> mColors;
};
class ColorPair {
public:
static constexpr halnf kCoolHue = 240.0f;
static constexpr halnf kHueStep = 10.0f;
static constexpr halnf kAmountStep = 0.05f;
static const HueColor kDefaultPrimary;
static const HueColor kDefaultSecondary;
[[nodiscard]] const HueColor& primary() const { return mPrimary; }
[[nodiscard]] const HueColor& secondary() const { return mSecondary; }
void setPrimary(const HueColor& color) { mPrimary = color; }
void setPrimary(const RGBA& color);
void setSecondary(const HueColor& color) { mSecondary = color; }
void resetToDefaults();
void swap();
void cooler(halnf degrees = kHueStep);
void warmer(halnf degrees = kHueStep);
void complement();
void rotateHue(halnf degrees);
void changeSaturation(halnf delta);
void changeValue(halnf delta);
void setValue(halnf value);
[[nodiscard]] bool operator==(const ColorPair& other) const = default;
private:
HueColor mPrimary = kDefaultPrimary;
HueColor mSecondary = kDefaultSecondary;
};
}

View file

@ -0,0 +1,104 @@
#pragma once
#include "Document.hpp"
#include "LayerLevels.hpp"
#include "ParallelFor.hpp"
#include <memory>
#include <vector>
namespace tp::sketch2d {
struct BlendPixel {
halnf r = 0;
halnf g = 0;
halnf b = 0;
halnf a = 0;
};
[[nodiscard]] BlendPixel blendPixel(BlendMode mode, const BlendPixel& below, const BlendPixel& above, halnf opacity);
class Compositor {
public:
static void flatten(const std::vector<const Layer*>& layers, const TileCoord& coord, Tile& target);
struct StackEntry {
const Layer* layer;
Tile* tile;
};
static void compositeRows(const std::vector<StackEntry>& stack, uint1* out, int4 x0, int4 x1, int4 y);
[[nodiscard]] static ualni signatureOf(const std::vector<StackEntry>& stack);
[[nodiscard]] static bool isVisibleIn(const Document& document, const Layer& layer, bool& baseVisible);
struct PixelRect {
int4 x0 = 0;
int4 y0 = 0;
int4 x1 = 0;
int4 y1 = 0;
[[nodiscard]] bool isEmpty() const { return x1 <= x0 || y1 <= y0; }
void include(const PixelRect& other);
static PixelRect whole();
};
struct CompositeTile {
TileCoord coord;
std::shared_ptr<Tile> tile;
ualni signature = 0;
ualni frame = 0;
};
void update(const Document& document);
void update(const Document& document, int4 level);
[[nodiscard]] int4 level() const { return mLevel; }
[[nodiscard]] LayerLevels* levelsOf(const Layer& layer) const;
void setParallel(ParallelFor* parallel) { mParallel = parallel; }
[[nodiscard]] Tile* tileAt(const TileCoord& coord) const { return tileAt(mLevel, coord); }
[[nodiscard]] Tile* tileAt(int4 level, const TileCoord& coord) const;
[[nodiscard]] ualni tileCount() const { return tileCount(mLevel); }
[[nodiscard]] ualni tileCount(int4 level) const { return mLevels[level].tiles.size(); }
[[nodiscard]] ualni lastCompositedTiles() const { return mLastComposited; }
[[nodiscard]] ualni lastCompositedPixels() const { return mLastPixels; }
template <typename Fn>
void forEachTile(Fn&& fn) const {
forEachTile(mLevel, fn);
}
template <typename Fn>
void forEachTile(int4 level, Fn&& fn) const {
for (auto it : mLevels[level].tiles)
fn(it->val.coord, it->val.tile.get());
}
[[nodiscard]] const std::vector<TileCoord>& lastChanged() const { return mChanged; }
[[nodiscard]] const std::vector<std::shared_ptr<Tile>>& lastRemoved() const { return mRemoved; }
private:
[[nodiscard]] static std::vector<StackEntry> stackAt(const Document& document, const TileCoord& coord);
[[nodiscard]] static DirtyBands pendingChanges(const std::vector<StackEntry>& stack);
static void composite(const std::vector<StackEntry>& stack, Tile& target, const PixelRect& rect);
static void composite(const std::vector<StackEntry>& stack, Tile& target, const DirtyBands& bands);
[[nodiscard]] static bool isPlainCopy(const std::vector<StackEntry>& stack);
[[nodiscard]] static bool isNormalStack(const std::vector<StackEntry>& stack);
void syncLevels(const Document& document, int4 level);
void updateBase(const Document& document, bool levelChanged);
void updateLevel(const Document& document, int4 level, bool levelChanged);
struct LevelCache {
Map<ualni, CompositeTile> tiles;
ualni layout = 0;
bool hasLayout = false;
};
LevelCache mLevels[LayerLevels::kLevels];
int4 mLevel = 0;
ualni mFrame = 0;
ualni mLastComposited = 0;
ualni mLastPixels = 0;
ParallelFor* mParallel = nullptr;
std::vector<TileCoord> mChanged;
std::vector<std::shared_ptr<Tile>> mRemoved;
};
}

View file

@ -0,0 +1,417 @@
#pragma once
#include "Selection.hpp"
#include "Brush.hpp"
#include "History.hpp"
#include "Layer.hpp"
#include "Engine.hpp"
#include "StrokePainter.hpp"
#include "StrokeTrace.hpp"
#include "Map.hpp"
#include <atomic>
#include <memory>
#include <functional>
#include <optional>
#include <vector>
namespace tp::sketch2d {
struct HueColor {
halnf hueDegrees = 0;
halnf saturation = 0;
halnf value = 1;
[[nodiscard]] RGBA rgba(halnf alpha = 1.0f) const;
[[nodiscard]] bool operator==(const HueColor& other) const = default;
};
struct CanvasBackground {
static constexpr halnf kDefaultValue = 0.12f;
HueColor color = { 0, 0, kDefaultValue };
bool transparent = false;
[[nodiscard]] RGBA rgba() const { return color.rgba(transparent ? 0.0f : 1.0f); }
[[nodiscard]] bool operator==(const CanvasBackground& other) const = default;
};
struct CanvasFrame {
static constexpr int4 kCoordinateLimit = 0x200000;
static constexpr int4 kMaximumSide = 50000;
static constexpr int4 kDefaultHalfSize = 250;
int4 left = 0;
int4 top = 0;
int4 right = 0;
int4 bottom = 0;
bool infinite = true;
[[nodiscard]] bool isEmpty() const { return right <= left || bottom <= top; }
[[nodiscard]] int4 width() const { return right - left; }
[[nodiscard]] int4 height() const { return bottom - top; }
[[nodiscard]] RectF rect() const { return { (halnf) left, (halnf) top, (halnf) width(), (halnf) height() }; }
void clamp();
[[nodiscard]] bool operator==(const CanvasFrame& other) const = default;
};
struct BucketSettings {
halnf threshold = 0.1f;
bool onlyAlpha = false;
bool allLayers = true;
int4 spread = 1;
};
enum class BucketResult { Filled, Leak, Empty, TooBig, Locked };
enum class GradientShape { Linear, Radial };
struct GradientSettings {
GradientShape shape = GradientShape::Linear;
bool toTransparent = false;
bool eraser = false;
};
enum class GradientResult { Drawn, NoFrame, Locked };
struct LayerTransform {
Vec2F pivot = { 0, 0 };
Vec2F scale = { 1, 1 };
halnf rotation = 0;
Vec2F offset = { 0, 0 };
[[nodiscard]] Vec2F apply(Vec2F point) const;
[[nodiscard]] Vec2F invert(Vec2F point) const;
[[nodiscard]] bool isIdentity() const;
};
enum class CropResult { Cropped, NoRegion, AlphaLocked, Unchanged };
enum class AdjustmentKind { BrightnessExposure, HueSaturation, ColorBalance, Colorize, Alpha };
struct Adjustment {
AdjustmentKind kind = AdjustmentKind::BrightnessExposure;
halnf brightness = 0;
halnf exposure = 0;
halnf hueDegrees = 0;
halnf saturation = 0;
halnf lightness = 0;
halnf red = 0;
halnf green = 0;
halnf blue = 0;
halnf alpha = 0;
void apply(halnf rgba[4]) const;
[[nodiscard]] bool isNeutral() const;
};
struct MoveSettings {
bool duplicate = false;
};
struct DocumentState {
std::vector<Layer::State> layers;
ualni active = 0;
CanvasBackground background;
CanvasFrame frame;
Selection selection;
};
struct SmudgePatch {
int4 diameter = 0;
std::vector<halnf> rgba;
[[nodiscard]] bool isEmpty() const { return diameter == 0; }
[[nodiscard]] halnf* at(int4 x, int4 y) { return rgba.data() + ((size_t) y * diameter + x) * 4; }
[[nodiscard]] const halnf* at(int4 x, int4 y) const { return rgba.data() + ((size_t) y * diameter + x) * 4; }
void clear() {
diameter = 0;
rgba.clear();
}
};
class Document {
public:
Document();
~Document();
void reset();
Document(const Document&) = delete;
Document& operator=(const Document&) = delete;
void pan(const Vec2F& screenDelta);
void zoomAt(const Vec2F& screenPoint, halnf factor);
void rotateAt(const Vec2F& screenPoint, halnf radians);
void setRotationAt(const Vec2F& screenPoint, halnf radians);
void resetView();
[[nodiscard]] const Vec2F& panWorld() const { return mPan; }
void setPanWorld(const Vec2F& pan) { mPan = pan; }
[[nodiscard]] halnf zoom() const { return mZoom; }
void setZoom(halnf z) { mZoom = z < 0.02f ? 0.02f : (z > 64.0f ? 64.0f : z); }
[[nodiscard]] halnf rotation() const { return mRotation; }
void setRotation(halnf radians);
[[nodiscard]] bool isRotated() const { return mRotation != 0; }
[[nodiscard]] Vec2F screenToWorld(const Vec2F& screen) const;
[[nodiscard]] Vec2F worldToScreen(const Vec2F& world) const;
[[nodiscard]] Vec2F worldToView(const Vec2F& world) const { return (world - mPan) * mZoom; }
[[nodiscard]] Vec2F screenToView(const Vec2F& screen) const;
[[nodiscard]] RectF viewBoundsOfScreen(Vec2F screenSize) const;
[[nodiscard]] ualni tileCount() const;
template <typename Fn>
void forEachVisibleTile(Fn&& fn) const {
for (const auto& layer : mLayers) {
if (!layer->visible()) continue;
layer->forEachTile(fn);
}
}
template <typename Fn>
void forEachTile(Fn&& fn) const {
for (const auto& layer : mLayers)
layer->forEachTile(fn);
}
void setPaintWorkers(size_t count);
static constexpr double kEngineSliceMs = 8.0;
static constexpr halnf kPreviewDabStep = 0.25f;
static constexpr halnf kPreviewPathStep = 0.5f;
void setEngineThreads(size_t count) { mEngine.setThreads(count); }
[[nodiscard]] Engine& engine() const { return mEngine; }
[[nodiscard]] size_t paintWorkers() const { return mPainter.workers(); }
void submitPaint();
void settlePaint() const;
[[nodiscard]] size_t deferredDabs() const { return mOpenTrace ? mOpenTrace->recordedDabs() : 0; }
[[nodiscard]] bool hasOpenPathRange() const { return mPathRangeOpen; }
[[nodiscard]] int4 previewLevel() const { return mPreviewLevel; }
[[nodiscard]] RGBA sampleColor(Vec2F world, bool allLayers) const;
static constexpr int4 kBucketMaximumPixels = 16 * 1024 * 1024;
static constexpr int4 kBucketOpenMargin = 256;
BucketResult bucketFill(Vec2F world, const RGBA& color, const BucketSettings& settings);
BucketResult magicWand(Vec2F world, const BucketSettings& settings, SelectionMode mode);
struct FloodRegion {
BucketResult result = BucketResult::Empty;
int4 left = 0;
int4 top = 0;
int4 width = 0;
int4 height = 0;
std::vector<uint1> mask;
};
[[nodiscard]] FloodRegion floodRegion(Vec2F world, const BucketSettings& settings, bool refuseLeaks = true) const;
GradientResult
drawGradient(Vec2F fromWorld, Vec2F toWorld, const RGBA& start, const RGBA& end, const GradientSettings& settings);
[[nodiscard]] static halnf gradientPosition(Vec2F point, Vec2F from, Vec2F to, GradientShape shape);
bool beginMove(bool duplicate);
bool beginTransform();
void previewTransform(const LayerTransform& transform);
bool finishTransform();
void cancelTransform();
[[nodiscard]] bool isTransforming() const { return mTransformSource.has_value(); }
[[nodiscard]] bool transformBounds(int4& left, int4& top, int4& right, int4& bottom) const;
bool flipActiveLayer(bool horizontal);
[[nodiscard]] const Selection& selection() const { return mSelection; }
Selection& editSelection() { return mSelection; }
[[nodiscard]] bool hasSelection() const { return mSelection.isActive(); }
bool deselect();
bool selectAllCanvas();
bool selectAllAsRectangle();
bool selectAllAsLasso();
enum class PixelFilter { Invert, Desaturate, ValueToAlpha, ValueToAlphaInverted };
enum class FilterResult { Done, Empty, Locked, AlphaLocked };
FilterResult applyFilter(PixelFilter filter);
struct PixelClip {
int4 left = 0;
int4 top = 0;
int4 width = 0;
int4 height = 0;
std::vector<uint1> pixels;
[[nodiscard]] bool isEmpty() const { return width <= 0 || height <= 0; }
};
[[nodiscard]] PixelClip copyPixels() const;
Layer* pasteAsLayer(const PixelClip& clip, const std::string& name);
FilterResult applyPixelFunction(const std::function<void(halnf rgba[4])>& function, bool changesAlpha);
[[nodiscard]] static halnf pixelValue(const halnf rgba[4]);
bool beginAdjustment();
void previewAdjustment(const Adjustment& adjustment);
bool finishAdjustment();
void cancelAdjustment();
[[nodiscard]] bool isAdjusting() const { return mAdjustSource.has_value(); }
bool invertSelection();
enum class SelectionEditResult { Done, NoSelection, Inverted, AlphaLocked, Locked };
SelectionEditResult fillSelection(const RGBA& color);
SelectionEditResult fillSelectionUnderneath(const RGBA& color);
SelectionEditResult clearSelection();
CropResult cropTo(int4 left, int4 top, int4 right, int4 bottom);
[[nodiscard]] bool layerBounds(const Layer& layer, int4& left, int4& top, int4& right, int4& bottom) const;
void moveTo(int4 dx, int4 dy);
bool finishMove();
void cancelMove();
[[nodiscard]] bool isMoving() const { return mMoveSource.has_value(); }
[[nodiscard]] size_t paintBacklog() const { return mPainter.backlog() + mEngine.pending(); }
[[nodiscard]] double takePaintWorkerMs() { return mPainter.takeWorkerMs(); }
template <typename Fn>
void whilePaintPaused(Fn&& fn) const {
mPainter.whilePaused([&]() { mEngine.whilePaused(fn); });
}
[[nodiscard]] std::string strokeRefusal(const Brush& brush) const;
void beginPathStroke(const Brush& brush, StrokeMirror mirror, const StrokePath::Sample& first);
void addPathSample(const StrokePath::Sample& sample);
void advancePathStroke(bool finished);
void stampBrushDab(const Vec2F& worldCenter, const Brush& brush, halnf pressure = 1.0f);
void stampDab(const Vec2F& worldCenter, const Brush& brush, const DabShape& dab);
void restoreStrokeBase();
void smudgeDab(const Vec2F& worldCenter, const Brush& brush, const DabShape& dab);
void finishStroke(const std::string& label = "Paint");
void discardStroke();
static constexpr size_t kMaximumLayerNameLength = 63;
static constexpr size_t kMaximumLayers = 256;
Layer* addLayer(const std::string& name);
Layer* duplicateLayer(const Layer& source);
void removeActiveLayer();
bool renameLayer(Layer& layer, const std::string& name);
bool moveActiveLayer(int4 offset);
[[nodiscard]] bool canAddLayer() const { return mLayers.size() < kMaximumLayers; }
[[nodiscard]] std::string uniqueLayerName(const std::string& name) const;
[[nodiscard]] size_t indexOf(const Layer& layer) const;
[[nodiscard]] Layer* activeLayer() const { return mActive; }
void setActiveLayer(Layer* layer);
bool selectOnly(Layer& layer);
bool selectAdjacent(int4 offset);
bool selectAllLayers();
bool toggleLayerSelection(Layer& layer);
bool selectRangeTo(Layer& layer);
[[nodiscard]] size_t selectedCount() const;
[[nodiscard]] const std::vector<std::unique_ptr<Layer>>& layers() const { return mLayers; }
[[nodiscard]] ualni layerCount() const { return mLayers.size(); }
[[nodiscard]] std::string nextLayerName() const { return uniqueLayerName("Layer"); }
void clearActiveLayer();
[[nodiscard]] std::vector<Layer*> mergeCandidates() const;
bool mergeLayers(const std::vector<Layer*>& layers);
bool mergeVisibleLayers();
bool bakeOpacity(Layer& layer);
void setLayerVisible(Layer& layer, bool visible);
void setLayerOpacity(Layer& layer, halnf opacity);
[[nodiscard]] std::vector<Layer*> targetLayers() const;
[[nodiscard]] Layer* layerBelow(const Layer& layer) const;
[[nodiscard]] Layer* layerAbove(const Layer& layer) const;
[[nodiscard]] bool canEdit(const Layer& layer) const { return !layer.locked(); }
bool toggleVisible();
bool toggleClip();
bool toggleLock();
bool toggleAlphaLock();
bool cycleBlend(int4 direction);
bool resetBlend();
bool setBlend(BlendMode blend);
bool changeOpacity(halnf delta);
bool setOpacity(halnf opacity);
[[nodiscard]] const CanvasFrame& frame() const { return mFrame; }
[[nodiscard]] bool isInfinite() const { return mFrame.infinite; }
bool setInfinite(bool infinite, Vec2F viewCenterWorld = { 0, 0 });
[[nodiscard]] CanvasFrame proposedFrame(Vec2F viewCenterWorld) const;
bool setFrame(int4 left, int4 top, int4 right, int4 bottom);
[[nodiscard]] bool contentBounds(int4& left, int4& top, int4& right, int4& bottom) const;
[[nodiscard]] const CanvasBackground& background() const { return mBackground; }
bool setBackground(const CanvasBackground& background);
void previewBackground(const CanvasBackground& background) { mBackground = background; }
[[nodiscard]] DocumentState state() const;
[[nodiscard]] DocumentState stateWithoutSettling() const;
void restore(const DocumentState& state);
void load(const DocumentState& state, const std::string& label);
void commit(const std::string& label, const std::string& mergeKey = {});
[[nodiscard]] History<DocumentState>& history() { return mHistory; }
[[nodiscard]] const History<DocumentState>& history() const { return mHistory; }
bool undo();
bool redo();
bool jumpToHistory(size_t index);
private:
Selection mSelection;
std::shared_ptr<const Selection> mStrokeSelection;
std::optional<Layer::State> mMoveSource;
std::optional<Layer::State> mTransformSource;
std::optional<Layer::State> mAdjustSource;
bool mAdjustChanged = false;
int4 mTransformBounds[4] = { 0, 0, 0, 0 };
bool mTransformChanged = false;
bool mMoveDuplicate = false;
bool mMoveSelectionOnly = false;
Selection mMoveSelection;
int4 mMoveX = 0;
int4 mMoveY = 0;
[[nodiscard]] Layer* layerWithId(ualni id) const;
void writeSmudgePatch(const SmudgePatch& patch, int4 originX, int4 originY, bool keepAlpha, bool linear);
std::vector<std::unique_ptr<Layer>> mLayers;
Layer* mActive = nullptr;
CanvasBackground mBackground;
CanvasFrame mFrame;
ualni mNextLayerId = 1;
mutable StrokePainter mPainter;
mutable Engine mEngine;
Layer* mStrokeLayer = nullptr;
Map<ualni, TileCoord> mStrokeTiles;
Map<ualni, std::shared_ptr<StrokeMask>> mStrokeMasks;
mutable Map<ualni, std::shared_ptr<StrokeMask>> mPreviewMasks;
mutable Map<ualni, std::shared_ptr<void>> mPendingSlots;
mutable Map<ualni, std::shared_ptr<void>> mStrokeCoverage;
struct RangeStart {
std::optional<StrokePath::Walk> walk = StrokePath::Walk{};
std::shared_ptr<StrokeTrace> after;
};
mutable std::shared_ptr<StrokeTrace> mOpenTrace;
StrokeStyle mPathStyle;
StrokePath mStrokePath;
std::optional<StrokePath::Walk> mDenseWalk = StrokePath::Walk{};
StrokePath::Walk mPreviewWalk;
mutable RangeStart mRangeStart;
mutable bool mPathRangeOpen = false;
mutable halnf mPathRangeTo = 0;
bool mPathActive = false;
mutable std::shared_ptr<std::atomic<bool>> mStrokeCancelled;
StrokeMask* strokeMask(const TileCoord& coord);
std::vector<std::shared_ptr<ualni>> mPendingStrokeEntries;
int4 mPreviewLevel = -1;
std::shared_ptr<const DabWork> mLastPreviewDab;
void stagePreviewDab(const TracedDab& dab);
void paintPreviewDab(const TracedDab& dab);
[[nodiscard]] int4 deferredLevel(bool marker) const;
void usePreviewLevel(int4 level);
[[nodiscard]] DabTarget strokeTarget();
[[nodiscard]] StrokeTrace& openTrace();
void stageEagerDab(const TracedDab& dab);
void advanceDensePath(halnf end);
void advancePreviewPath(int4 level, halnf end);
void resetPathStroke();
void stageBasePreview(const DabWork& work, const TileCoord& coord, int4 minX, int4 minY, int4 maxX, int4 maxY);
[[nodiscard]] bool previewNeedsDab(const DabWork& work, int4 level);
void flushDeferredDabs() const;
void closePathRange() const;
void releaseFinishedPreviews() const;
void dropOpenStroke();
[[nodiscard]] bool isLayerEmpty(const Layer& layer) const;
SmudgePatch mSmudgeCarry;
History<DocumentState> mHistory;
Vec2F mPan = { 0, 0 };
halnf mZoom = 1.0f;
halnf mRotation = 0;
};
}

View file

@ -0,0 +1,19 @@
#pragma once
#include "Document.hpp"
#include "LayerLevels.hpp"
#include <vector>
namespace tp::sketch2d {
class LayerThumbnail {
public:
static constexpr int4 kSize = 40;
[[nodiscard]] static RectF documentBounds(const Document& document);
[[nodiscard]] static std::vector<uint1> render(const Layer& layer, const RectF& worldBounds, int4 size = kSize);
[[nodiscard]] static std::vector<uint1>
renderLevel(const Layer& layer, const LayerLevels& levels, int4 level, const RectF& worldBounds, int4 size);
};
}

View file

@ -0,0 +1,86 @@
#pragma once
#include "Brush.hpp"
#include "StrokePainter.hpp"
#include "StrokePath.hpp"
#include "Tile.hpp"
#include "Map.hpp"
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <vector>
namespace tp::sketch2d {
using StrokeMirror = std::function<Vec2F(Vec2F point)>;
struct TracedDab {
std::shared_ptr<const DabWork> work;
int4 minX = 0;
int4 minY = 0;
int4 maxX = 0;
int4 maxY = 0;
[[nodiscard]] bool masked() const { return !work->erase && !work->marker && !work->keepAlpha; }
};
struct DabTarget {
bool keepAlpha = false;
std::shared_ptr<const Selection> selection;
};
[[nodiscard]] std::optional<TracedDab>
traceDab(const Brush& brush, const DabShape& requested, const DabTarget& target, Vec2F center);
struct StrokeStyle {
Brush brush;
DabTarget target;
StrokeMirror mirror;
void forEachDab(Vec2F center, halnf pressure, const std::function<void(TracedDab dab)>& emit) const;
};
class StrokeTrace {
public:
struct PathRange {
StrokeStyle style;
StrokePath path;
halnf fromParameter = 0;
halnf toParameter = 0;
std::optional<StrokePath::Walk> from;
std::shared_ptr<StrokeTrace> previous;
};
StrokeTrace() = default;
StrokeTrace(const StrokeTrace&) = delete;
StrokeTrace& operator=(const StrokeTrace&) = delete;
void record(TracedDab dab);
void setRange(PathRange range);
void expand();
[[nodiscard]] StrokePath::Walk endWalk();
[[nodiscard]] const std::vector<ualni>* dabsFor(const TileCoord& coord) const;
[[nodiscard]] const TracedDab& dab(ualni index) const { return mDabs[index]; }
[[nodiscard]] std::vector<TileCoord> tiles() const;
[[nodiscard]] bool keepsAlpha() const { return mKeepsAlpha; }
[[nodiscard]] bool isEmpty() const { return mDabs.empty() && !mRange; }
[[nodiscard]] size_t recordedDabs() const { return mRecorded; }
private:
void index(TracedDab dab);
void collectRangeTiles(Map<ualni, TileCoord>& tiles) const;
std::optional<PathRange> mRange;
std::once_flag mExpanded;
StrokePath::Walk mEndWalk;
std::vector<TracedDab> mDabs;
Map<ualni, std::vector<ualni>> mTileDabs;
Map<ualni, TileCoord> mRecordedTiles;
size_t mRecorded = 0;
bool mKeepsAlpha = false;
};
}

View file

@ -0,0 +1,103 @@
#pragma once
#include "BrushCursor.hpp"
#include "ChannelView.hpp"
#include "Document.hpp"
namespace tp::sketch2d {
enum class PickerStyle { Cursor, Ring, Balloon, Magnifier };
enum class CanvasTool {
Paint,
Picker,
Hand,
Zoom,
Rotate,
Bucket,
Gradient,
Move,
Transform,
Crop,
Marquee,
Lasso,
Wand
};
enum class MarqueeShape { Rectangle, Ellipse };
enum class LassoShape { Freehand, Polygon };
struct SelectionSettings {
MarqueeShape marquee = MarqueeShape::Rectangle;
LassoShape lasso = LassoShape::Freehand;
bool clickToDeselect = true;
bool autoFill = false;
int4 adjustPixels = 4;
BucketSettings wand = { 0.1f, false, true, 0 };
};
struct TransformSettings {
bool proportional = false;
};
struct PickerSettings {
bool sampleAllLayers = true;
bool sampleOpacity = false;
PickerStyle style = PickerStyle::Ring;
};
enum class LineMode { Normal, Ruler, Ellipse, Perspective };
enum class PerspectiveType { OnePoint, TwoPoint, ThreePoint, Isometric, Radial };
struct PerspectiveSettings {
PerspectiveType type = PerspectiveType::TwoPoint;
Vec2F points[3] = { { 0, 0 }, { 0, 0 }, { 0, 0 } };
halnf isometricDegrees = 30.0f;
bool locked = false;
bool placed = false;
[[nodiscard]] int4 pointCount() const;
[[nodiscard]] std::vector<Vec2F> directions(Vec2F from) const;
[[nodiscard]] Vec2F snap(Vec2F from, Vec2F to) const;
};
struct SymmetrySettings {
bool enabled = false;
bool visible = true;
bool locked = false;
Vec2F center = { 0, 0 };
halnf angle = 1.5707963f;
[[nodiscard]] Vec2F mirror(Vec2F point) const;
};
struct LineSettings {
bool holdForLine = false;
halnf holdSeconds = 0.5f;
LineMode mode = LineMode::Normal;
};
struct ViewSettings {
HueColor boardColor = { 0.0f, 0.0f, 0.6f };
bool canvasShadow = true;
halnf zoomInLimit = -6.0f;
halnf zoomOutLimit = 1.0f;
bool showChannel = false;
bool useBrushStabilizer = true;
halnf globalStabilizer = Brush::kDefaultStabilizer;
halnf viewStiffness = 0.5f;
bool lockView = false;
ViewChannel channel = ViewChannel::Red;
BrushCursorSettings cursor;
PickerSettings picker;
BucketSettings bucket;
LineSettings line;
SymmetrySettings symmetry;
PerspectiveSettings perspective;
GradientSettings gradient;
MoveSettings move;
TransformSettings transform;
SelectionSettings selection;
};
}

View file

@ -0,0 +1,240 @@
#include "Engine.hpp"
#include "ThreadPlacement.hpp"
#include <algorithm>
#include <chrono>
#include <limits>
#include <sstream>
namespace tp::sketch2d {
void Operation::finish(Layer& layer, const TileWork& work, const std::shared_ptr<Tile>& result) {
result->markDirty({ 0, 0, (halnf) kTileSize, (halnf) kTileSize });
layer.noteChanged(work.coord);
}
Engine::Engine(size_t threads) { setThreads(threads); }
Engine::~Engine() {
drain();
stopThreads();
}
ualni Engine::keyOf(const Layer* layer, const TileCoord& coord) {
ualni hash = (ualni) (uintptr_t) layer * 0x9E3779B97F4A7C15ull;
return hash ^ (coord.key() + 0x7F4A7C159E3779B9ull + (hash << 6) + (hash >> 2));
}
void Engine::stopThreads() {
{
std::lock_guard lock(mQueueMutex);
mStopping = true;
}
mWake.notify_all();
for (std::thread& thread : mThreads)
thread.join();
mThreads.clear();
std::lock_guard lock(mQueueMutex);
mStopping = false;
}
void Engine::setThreads(size_t threads) {
drain();
stopThreads();
for (size_t index = 0; index < threads; index++)
mThreads.emplace_back([this]() {
ThreadPlacement::placeCallingThreadAsWorker();
worker();
});
}
void Engine::submit(Job job) {
const int4 level = std::clamp(job.work.level, 0, LayerLevels::kLevels - 1);
{
std::lock_guard lock(mQueueMutex);
const ualni key = keyOf(job.layer, job.work.coord);
auto found = mQueued.presents(key);
if (found.isValid()) mQueued.getSlotVal(found)++;
else mQueued.put(key, 1);
mQueues[level].push_back(std::move(job));
}
mWake.notify_one();
}
bool Engine::takeOne(Job& job) {
for (int4 level = LayerLevels::kLevels - 1; level >= 0; level--) {
std::deque<Job>& queue = mQueues[level];
for (auto it = queue.begin(); it != queue.end(); ++it) {
const ualni key = keyOf(it->layer, it->work.coord);
if (mBusy.presents(key).isValid()) continue;
job = std::move(*it);
queue.erase(it);
mBusy.put(key, true);
mActive++;
mRunning.push_back({ job.layer, job.work });
return true;
}
}
return false;
}
void Engine::runJob(Job& job) {
if (!job.tile) job.tile = std::make_shared<Tile>(Tile::Uninitialized{});
job.operation->run(job.work, job.tile);
const ualni key = keyOf(job.layer, job.work.coord);
{
std::lock_guard lock(mDoneMutex);
mDone.push_back(std::move(job));
}
mCompleted.fetch_add(1, std::memory_order_acq_rel);
{
std::lock_guard lock(mQueueMutex);
mBusy.remove(key);
for (size_t index = 0; index < mRunning.size(); index++) {
if (keyOf(mRunning[index].first, mRunning[index].second.coord) != key) continue;
mRunning[index] = mRunning.back();
mRunning.pop_back();
break;
}
mActive--;
}
mIdle.notify_all();
mWake.notify_all();
}
void Engine::worker() {
while (true) {
Job job;
{
std::unique_lock lock(mQueueMutex);
mWake.wait(lock, [&]() { return mStopping || takeOne(job); });
if (!job.operation) return;
}
runJob(job);
}
}
size_t Engine::runFor(double milliseconds) {
if (isPausedHere()) return 0;
const auto start = std::chrono::steady_clock::now();
size_t ran = 0;
while (true) {
Job job;
{
std::lock_guard lock(mQueueMutex);
if (!takeOne(job)) break;
}
runJob(job);
ran++;
const double elapsed =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
if (elapsed >= milliseconds) break;
}
applyFor(milliseconds);
return ran;
}
void Engine::drain() {
if (isPausedHere()) return;
if (mThreads.empty()) {
while (true) {
Job job;
{
std::lock_guard lock(mQueueMutex);
if (!takeOne(job)) break;
}
runJob(job);
}
} else {
std::unique_lock lock(mQueueMutex);
mIdle.wait(lock, [this]() {
if (mActive > 0) return false;
for (const std::deque<Job>& queue : mQueues)
if (!queue.empty()) return false;
return true;
});
}
apply();
}
void Engine::apply() { applyFor(std::numeric_limits<double>::infinity()); }
void Engine::applyFor(double milliseconds) {
const auto start = std::chrono::steady_clock::now();
while (true) {
Job job;
{
std::lock_guard lock(mDoneMutex);
if (mDone.empty()) return;
job = std::move(mDone.front());
mDone.pop_front();
}
job.operation->finish(*job.layer, job.work, job.tile);
{
std::lock_guard lock(mQueueMutex);
const ualni key = keyOf(job.layer, job.work.coord);
auto found = mQueued.presents(key);
if (found.isValid() && --mQueued.getSlotVal(found) <= 0) mQueued.remove(key);
}
const double spent = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
if (spent >= milliseconds) return;
}
}
size_t Engine::pending() const {
size_t total = 0;
{
std::lock_guard lock(mDoneMutex);
total += mDone.size();
}
std::lock_guard lock(mQueueMutex);
total += mActive;
for (const std::deque<Job>& queue : mQueues)
total += queue.size();
return total;
}
size_t Engine::pending(int4 level) const {
std::lock_guard lock(mQueueMutex);
return mQueues[std::clamp(level, 0, LayerLevels::kLevels - 1)].size();
}
bool Engine::hasWork(const Layer& layer, const TileCoord& coord) const {
std::lock_guard lock(mQueueMutex);
return mQueued.presents(keyOf(&layer, coord)).isValid();
}
bool Engine::hasWorkFor(const Layer& layer) const {
std::lock_guard lock(mQueueMutex);
for (const std::deque<Job>& queue : mQueues)
for (const Job& job : queue)
if (job.layer == &layer) return true;
for (const auto& running : mRunning)
if (running.first == &layer) return true;
return false;
}
std::string Engine::describeState() const {
std::ostringstream text;
text << "threads " << mThreads.size() << "\n";
text << "completed " << completed() << "\n";
text << "paused by a thread " << (mPausedBy.load() != std::thread::id() ? "yes" : "no") << "\n";
std::unique_lock queue(mQueueMutex, std::try_to_lock);
if (!queue.owns_lock()) text << "queue lock HELD by another thread\n";
else {
text << "running " << mActive << "\n";
for (size_t level = 0; level < (size_t) LayerLevels::kLevels; level++)
if (!mQueues[level].empty()) text << "queued at level " << level << ": " << mQueues[level].size() << "\n";
for (const auto& running : mRunning)
text << "running tile " << running.second.coord.x << "," << running.second.coord.y << " level "
<< running.second.level << "\n";
queue.unlock();
}
std::unique_lock done(mDoneMutex, std::try_to_lock);
if (!done.owns_lock()) text << "done lock HELD by another thread\n";
else text << "finished, waiting to apply " << mDone.size() << "\n";
return text.str();
}
}

View file

@ -0,0 +1,118 @@
#include "Layer.hpp"
#include "LayerLevels.hpp"
#include <atomic>
namespace tp::sketch2d {
namespace {
ualni nextSerial() {
static std::atomic<ualni> serial{ 1 };
return serial.fetch_add(1);
}
}
const char* blendModeName(BlendMode mode) {
switch (mode) {
case BlendMode::Normal: return "Normal";
case BlendMode::Multiply: return "Multiply";
case BlendMode::Screen: return "Screen";
case BlendMode::Overlay: return "Overlay";
case BlendMode::Lighten: return "Lighten";
case BlendMode::Darken: return "Darken";
case BlendMode::ColorDodge: return "Color dodge";
case BlendMode::ColorBurn: return "Color burn";
case BlendMode::SoftDodge: return "Soft dodge";
case BlendMode::SoftBurn: return "Soft burn";
case BlendMode::Color: return "Color";
}
return "Normal";
}
BlendMode nextBlendMode(BlendMode mode) { return (BlendMode) (((int4) mode + 1) % kBlendModeCount); }
BlendMode previousBlendMode(BlendMode mode) {
return (BlendMode) (((int4) mode + kBlendModeCount - 1) % kBlendModeCount);
}
Layer::Layer(ualni id, const std::string& name) :
mId(id),
mSerial(nextSerial()) {
mProperties.name = name;
}
Layer::~Layer() = default;
LayerLevels& Layer::levels() const {
if (!mLevels) mLevels = std::make_unique<LayerLevels>();
return *mLevels;
}
void Layer::clear() {
for (auto it : mTiles)
noteChanged(it->val.coord);
mTiles.removeAll();
}
void Layer::removeTile(const TileCoord& coord) {
if (!mTiles.presents(coord.key()).isValid()) return;
mTiles.remove(coord.key());
noteChanged(coord);
}
Tile* Layer::tileIfExists(const TileCoord& coord) const {
auto index = mTiles.presents(coord.key());
if (!index.isValid()) return nullptr;
return mTiles.getSlotVal(index).tile.get();
}
std::shared_ptr<Tile> Layer::sharedTile(const TileCoord& coord) const {
auto index = mTiles.presents(coord.key());
return index.isValid() ? mTiles.getSlotVal(index).tile : nullptr;
}
Tile* Layer::tileAt(const TileCoord& coord) {
noteChanged(coord);
auto index = mTiles.presents(coord.key());
if (!index.isValid()) {
mTiles.put(coord.key(), { coord, std::make_shared<Tile>() });
return mTiles.get(coord.key()).tile.get();
}
TileSlot& slot = mTiles.get(coord.key());
if (slot.tile.use_count() > 1) slot.tile = slot.tile->takeForWriting();
return slot.tile.get();
}
Layer::State Layer::state() const { return { mId, mProperties, mTiles }; }
std::shared_ptr<Tile> Layer::replaceTile(const TileCoord& coord, std::shared_ptr<Tile> tile) {
noteChanged(coord);
std::shared_ptr<Tile> previous;
auto index = mTiles.presents(coord.key());
if (index.isValid()) {
TileSlot& slot = mTiles.getSlotVal(index);
previous = slot.tile;
if (previous.use_count() <= 2) {
tile->setGpuId(previous->gpuId());
previous->setGpuId(0);
}
slot.tile = std::move(tile);
} else {
mTiles.put(coord.key(), { coord, std::move(tile) });
}
return previous;
}
void Layer::restore(const State& state) {
for (auto it : mTiles) {
auto found = state.tiles.presents(it->key);
if (!found.isValid() || state.tiles.getSlotVal(found).tile != it->val.tile) noteChanged(it->val.coord);
}
for (auto it : state.tiles) {
if (!mTiles.presents(it->key).isValid()) noteChanged(it->val.coord);
}
mProperties = state.properties;
mTiles = state.tiles;
}
}

View file

@ -0,0 +1,318 @@
#include "LayerLevels.hpp"
#include <cstring>
#include <algorithm>
#include <cmath>
namespace tp::sketch2d {
namespace {
constexpr int4 kHalfTile = kTileSize / 2;
const std::vector<TileCoord> kNoChanges;
}
int4 LayerLevels::levelForZoom(halnf zoom) {
if (zoom > 0.5f) return 0;
const int4 level = (int4) std::floor(std::log2(1.0f / zoom) + 1.0e-4f);
return std::clamp(level, 0, kLevels - 1);
}
void LayerLevels::downsample(const Tile* const quadrants[4], Tile& target, int4 x0, int4 y0, int4 x1, int4 y1) {
uint1* out = target.pixels();
for (int4 y = y0; y < y1; y++) {
for (int4 x = x0; x < x1; x++) {
const int4 quadrant = (y >= kHalfTile ? 2 : 0) + (x >= kHalfTile ? 1 : 0);
uint1* destination = out + ((ualni) y * kTileSize + (ualni) x) * 4;
const Tile* source = quadrants[quadrant];
if (!source) {
destination[0] = destination[1] = destination[2] = destination[3] = 0;
continue;
}
const int4 sx = (x % kHalfTile) * 2;
const int4 sy = (y % kHalfTile) * 2;
const uint1* row0 = source->pixels() + ((ualni) sy * kTileSize + (ualni) sx) * 4;
const uint1* row1 = row0 + (ualni) kTileSize * 4;
for (int4 channel = 0; channel < 4; channel++) {
const int4 sum = row0[channel] + row0[channel + 4] + row1[channel] + row1[channel + 4];
destination[channel] = (uint1) ((sum + 2) / 4);
}
}
}
target.markDirty({ (halnf) x0, (halnf) y0, (halnf) (x1 - x0), (halnf) (y1 - y0) });
}
template <typename Lookup>
void LayerLevels::updateLevel(
int4 level,
const Lookup& children,
const std::vector<TileCoord>& changedChildren,
std::vector<TileCoord>& changedParents,
ParallelFor* parallel
) {
Level& target = mLevels[(size_t) level - 1];
Map<ualni, TileCoord> parents;
Map<ualni, int4> quadrantMask;
for (const TileCoord& child : changedChildren) {
const TileCoord parent = parentOf(child);
parents.put(parent.key(), parent);
const int4 bit = 1 << ((child.y - parent.y * 2) * 2 + (child.x - parent.x * 2));
auto found = quadrantMask.presents(parent.key());
if (found.isValid()) quadrantMask.getSlotVal(found) |= bit;
else quadrantMask.put(parent.key(), bit);
}
struct Job {
const Tile* quadrants[4];
Tile* target;
int4 mask;
};
std::vector<Job> jobs;
for (auto it : parents) {
const TileCoord parent = it->val;
const Tile* quadrants[4] = {
children({ parent.x * 2, parent.y * 2 }),
children({ parent.x * 2 + 1, parent.y * 2 }),
children({ parent.x * 2, parent.y * 2 + 1 }),
children({ parent.x * 2 + 1, parent.y * 2 + 1 }),
};
changedParents.push_back(parent);
auto found = target.presents(parent.key());
if (mPreview[level].presents(parent.key()).isValid()) continue;
if (!quadrants[0] && !quadrants[1] && !quadrants[2] && !quadrants[3]) {
if (found.isValid()) {
mRemoved.push_back(target.getSlotVal(found).tile);
target.remove(parent.key());
}
continue;
}
int4 mask = quadrantMask.get(parent.key());
if (!found.isValid()) {
target.put(parent.key(), { parent, std::make_shared<Tile>() });
mask = 15;
}
jobs.push_back(
{ { quadrants[0], quadrants[1], quadrants[2], quadrants[3] }, target.get(parent.key()).tile.get(), mask }
);
mLastDownsampled++;
mRebuilds++;
for (int4 quadrant = 0; quadrant < 4; quadrant++)
if (mask & (1 << quadrant)) mLastPixels += (ualni) kHalfTile * kHalfTile;
}
const auto run = [&jobs](size_t index) {
const Job& job = jobs[index];
for (int4 quadrant = 0; quadrant < 4; quadrant++) {
if (!(job.mask & (1 << quadrant))) continue;
const int4 x0 = (quadrant & 1) * kHalfTile;
const int4 y0 = (quadrant >> 1) * kHalfTile;
downsample(job.quadrants, *job.target, x0, y0, x0 + kHalfTile, y0 + kHalfTile);
}
};
if (parallel) parallel->run(jobs.size(), run);
else
for (size_t index = 0; index < jobs.size(); index++)
run(index);
}
std::vector<TileCoord> LayerLevels::holdBackPending(std::vector<TileCoord> changed) {
mPending.clear();
for (int4 level = 1; level < kLevels; level++)
mStale[level].removeAll();
if (!mIsPending) return changed;
Map<ualni, bool> heldParents;
for (const TileCoord& coord : changed) {
const TileCoord parent = parentOf(coord);
if (heldParents.presents(parent.key()).isValid()) continue;
if (anyChildPending(parent)) heldParents.put(parent.key(), true);
}
if (heldParents.size() == 0) return changed;
std::vector<TileCoord> ready;
for (const TileCoord& coord : changed) {
if (heldParents.presents(parentOf(coord).key()).isValid()) mPending.push_back(coord);
else ready.push_back(coord);
}
for (const TileCoord& coord : mPending)
markStale(coord);
return ready;
}
bool LayerLevels::anyChildPending(const TileCoord& parent) const {
for (int4 child = 0; child < 4; child++)
if (mIsPending({ parent.x * 2 + (child & 1), parent.y * 2 + (child >> 1) })) return true;
return false;
}
void LayerLevels::markStale(const TileCoord& child) {
TileCoord coord = child;
for (int4 level = 1; level < kLevels; level++) {
coord = parentOf(coord);
mStale[level].put(coord.key(), coord);
}
}
bool LayerLevels::isStale(int4 level, const TileCoord& coord) const {
return level >= 1 && level < kLevels && mStale[level].presents(coord.key()).isValid();
}
ualni LayerLevels::staleCount() const {
ualni count = 0;
for (const Map<ualni, TileCoord>& stale : mStale)
count += stale.size();
return count;
}
void LayerLevels::update(const Layer& layer, int4 upToLevel, ParallelFor* parallel) {
mLastDownsampled = 0;
mLastPixels = 0;
mRemoved.clear();
for (std::vector<TileCoord>& changed : mChanged)
changed.clear();
upToLevel = std::clamp(upToLevel, 0, kLevels - 1);
layer.forEachLevelChanged([this](const TileCoord& coord) { mPending.push_back(coord); });
layer.clearLevelChanges();
if (upToLevel <= 0 && mBuilt == 0) return;
std::vector<TileCoord> changed;
if (mBuilt == 0) {
layer.forEachTile([&changed](const TileCoord& coord, Tile*) { changed.push_back(coord); });
mPending.clear();
} else {
changed = holdBackPending(std::move(mPending));
}
mChanged[0] = changed;
const int4 target = std::max(upToLevel, mBuilt);
for (int4 level = 1; level <= target; level++) {
if (level > mBuilt) {
changed.clear();
if (level == 1) layer.forEachTile([&changed](const TileCoord& coord, Tile*) { changed.push_back(coord); });
else
for (auto it : mLevels[(size_t) level - 2])
changed.push_back(it->val.coord);
}
if (level <= mBuilt) {
for (const TileCoord& released : mReleased[level])
for (int4 child = 0; child < 4; child++)
changed.push_back({ released.x * 2 + (child & 1), released.y * 2 + (child >> 1) });
}
mReleased[level].clear();
std::vector<TileCoord> parents;
if (level == 1) {
updateLevel(
level, [&layer](const TileCoord& coord) { return layer.tileIfExists(coord); }, changed, parents, parallel
);
} else {
const Level& below = mLevels[(size_t) level - 2];
updateLevel(
level,
[&below](const TileCoord& coord) -> Tile* {
auto found = below.presents(coord.key());
return found.isValid() ? below.getSlotVal(found).tile.get() : nullptr;
},
changed,
parents,
parallel
);
}
mChanged[level] = parents;
for (const TileCoord& parent : parents)
mAccumulated[level].put(parent.key(), parent);
changed = std::move(parents);
}
mBuilt = target;
}
Tile* LayerLevels::previewTile(int4 level, const TileCoord& coord) {
if (level < 1 || level >= kLevels || level > mBuilt) return nullptr;
Level& target = mLevels[(size_t) level - 1];
auto found = target.presents(coord.key());
if (!mPreview[level].presents(coord.key()).isValid()) {
std::shared_ptr<Tile> original = found.isValid() ? target.getSlotVal(found).tile : nullptr;
mOriginals[level].put(coord.key(), { coord, original });
auto copy = std::make_shared<Tile>(Tile::Uninitialized{});
if (original) std::memcpy(copy->pixels(), original->pixels(), (size_t) kTileSize * kTileSize * 4);
else std::memset(copy->pixels(), 0, (size_t) kTileSize * kTileSize * 4);
target.put(coord.key(), { coord, copy });
}
mPreview[level].put(coord.key(), coord);
mAccumulated[level].put(coord.key(), coord);
return target.get(coord.key()).tile.get();
}
Tile* LayerLevels::basePreviewTile(const Layer& layer, const TileCoord& coord) {
auto found = mBase.presents(coord.key());
if (found.isValid()) return mBase.getSlotVal(found).tile.get();
auto tile = std::make_shared<Tile>(Tile::Uninitialized{});
if (const Tile* source = layer.tileIfExists(coord))
std::memcpy(tile->pixels(), source->pixels(), (size_t) kTileSize * kTileSize * 4);
else std::memset(tile->pixels(), 0, (size_t) kTileSize * kTileSize * 4);
mBase.put(coord.key(), { coord, tile });
mPreview[0].put(coord.key(), coord);
return tile.get();
}
Tile* LayerLevels::basePreview(const TileCoord& coord) const {
auto found = mBase.presents(coord.key());
return found.isValid() ? mBase.getSlotVal(found).tile.get() : nullptr;
}
bool LayerLevels::isPreview(int4 level, const TileCoord& coord) const {
return level >= 0 && level < kLevels && mPreview[level].presents(coord.key()).isValid();
}
ualni LayerLevels::previewCount() const {
ualni count = 0;
for (const Map<ualni, TileCoord>& preview : mPreview)
count += preview.size();
return count;
}
void LayerLevels::discardPreview() {
mBase.removeAll();
mPreview[0].removeAll();
for (int4 level = 1; level < kLevels; level++) {
Level& target = mLevels[(size_t) level - 1];
for (auto it : mOriginals[level]) {
const LevelTile& original = it->val;
if (original.tile) target.put(it->key, original);
else if (target.presents(it->key).isValid()) target.remove(it->key);
mAccumulated[level].put(it->key, original.coord);
}
mOriginals[level].removeAll();
mPreview[level].removeAll();
}
}
void LayerLevels::endPreview() {
mBase.removeAll();
mPreview[0].removeAll();
for (int4 level = 1; level < kLevels; level++)
mOriginals[level].removeAll();
for (int4 level = 1; level < kLevels; level++) {
for (auto it : mPreview[level])
mReleased[level].push_back(it->val);
mPreview[level].removeAll();
}
}
Tile* LayerLevels::tileAt(int4 level, const TileCoord& coord) const {
if (level < 1 || level > mBuilt) return nullptr;
auto found = mLevels[(size_t) level - 1].presents(coord.key());
return found.isValid() ? mLevels[(size_t) level - 1].getSlotVal(found).tile.get() : nullptr;
}
ualni LayerLevels::tileCount(int4 level) const {
if (level < 1 || level > mBuilt) return 0;
return mLevels[(size_t) level - 1].size();
}
const std::vector<TileCoord>& LayerLevels::lastChanged(int4 level) const {
if (level < 0 || level >= kLevels) return kNoChanges;
return mChanged[level];
}
}

View file

@ -0,0 +1,351 @@
#include "Selection.hpp"
#include <algorithm>
#include <cmath>
namespace tp::sketch2d {
namespace {
constexpr int4 kSamples = 4;
}
const Selection::MaskTile* Selection::tile(int4 tx, int4 ty) const {
const auto found = mTiles.find(key(tx, ty));
return found == mTiles.end() ? nullptr : found->second.get();
}
Selection::MaskTile* Selection::writableTile(int4 tx, int4 ty) {
std::shared_ptr<MaskTile>& slot = mTiles[key(tx, ty)];
if (!slot) slot = std::make_shared<MaskTile>();
else if (slot.use_count() > 1) slot = std::make_shared<MaskTile>(*slot);
return slot.get();
}
uint1 Selection::coverage(int4 x, int4 y) const {
if (!mActive) return 255;
const MaskTile* mask = tile(floordiv(x, kTileSize), floordiv(y, kTileSize));
const uint1 value =
mask
? mask
->coverage[(x - floordiv(x, kTileSize) * kTileSize) + (y - floordiv(y, kTileSize) * kTileSize) * kTileSize]
: 0;
return mInverted ? (uint1) (255 - value) : value;
}
halnf Selection::coverageAt(Vec2F world) const {
return coverage((int4) std::floor(world.x), (int4) std::floor(world.y)) / 255.0f;
}
void Selection::clear() {
mTiles.clear();
mActive = false;
mInverted = false;
}
void Selection::combine(int4 x, int4 y, uint1 value, SelectionMode mode) {
if (mode != SelectionMode::Replace && value == 0) return;
const int4 tx = floordiv(x, kTileSize);
const int4 ty = floordiv(y, kTileSize);
if (mode == SelectionMode::Subtract && !tile(tx, ty)) return;
MaskTile* mask = writableTile(tx, ty);
uint1& cell = mask->coverage[(x - tx * kTileSize) + (y - ty * kTileSize) * kTileSize];
switch (mode) {
case SelectionMode::Replace: cell = value; break;
case SelectionMode::Add: cell = std::max(cell, value); break;
case SelectionMode::Subtract: cell = (uint1) std::max(0, (int4) cell - (int4) value); break;
}
}
void Selection::addMask(int4 left, int4 top, int4 width, const std::vector<uint1>& mask, SelectionMode mode) {
if (width <= 0) return;
if (mode == SelectionMode::Replace) {
mTiles.clear();
mInverted = false;
mode = SelectionMode::Add;
} else if (!mActive) {
if (mode == SelectionMode::Subtract) return;
mTiles.clear();
mInverted = false;
}
const SelectionMode stored =
mInverted ? (mode == SelectionMode::Add ? SelectionMode::Subtract : SelectionMode::Add) : mode;
for (size_t index = 0; index < mask.size(); index++)
combine(left + (int4) (index % (size_t) width), top + (int4) (index / (size_t) width), mask[index], stored);
mActive = true;
compact();
}
void Selection::compact() {
for (auto it = mTiles.begin(); it != mTiles.end();) {
const uint1* cells = it->second->coverage;
if (std::all_of(cells, cells + kTileSize * kTileSize, [](uint1 value) { return value == 0; }))
it = mTiles.erase(it);
else ++it;
}
if (mTiles.empty() && !mInverted) mActive = false;
}
void Selection::selectAll(int4 left, int4 top, int4 right, int4 bottom) {
clear();
addRectangle(left, top, right, bottom, SelectionMode::Replace);
}
void Selection::invert() {
if (!mActive) return;
mInverted = !mInverted;
if (mTiles.empty() && !mInverted) mActive = false;
}
void Selection::addRectangle(int4 left, int4 top, int4 right, int4 bottom, SelectionMode mode) {
if (right <= left || bottom <= top) {
if (mode == SelectionMode::Replace) clear();
return;
}
addMask(left, top, right - left, std::vector<uint1>((size_t) (right - left) * (bottom - top), 255), mode);
}
void Selection::addEllipse(Vec2F center, Vec2F radius, SelectionMode mode) {
if (radius.x <= 0 || radius.y <= 0) {
if (mode == SelectionMode::Replace) clear();
return;
}
const int4 left = (int4) std::floor(center.x - radius.x);
const int4 top = (int4) std::floor(center.y - radius.y);
const int4 right = (int4) std::ceil(center.x + radius.x);
const int4 bottom = (int4) std::ceil(center.y + radius.y);
const int4 width = right - left;
std::vector<uint1> mask((size_t) width * (bottom - top), 0);
for (int4 y = top; y < bottom; y++)
for (int4 x = left; x < right; x++) {
int4 inside = 0;
for (int4 sy = 0; sy < kSamples; sy++)
for (int4 sx = 0; sx < kSamples; sx++) {
const halnf dx = ((halnf) x + (sx + 0.5f) / kSamples - center.x) / radius.x;
const halnf dy = ((halnf) y + (sy + 0.5f) / kSamples - center.y) / radius.y;
if (dx * dx + dy * dy <= 1) inside++;
}
mask[(size_t) (y - top) * width + (x - left)] = (uint1) (inside * 255 / (kSamples * kSamples));
}
addMask(left, top, width, mask, mode);
}
void Selection::addPolygon(const std::vector<Vec2F>& points, SelectionMode mode) {
if (points.size() < 3) {
if (mode == SelectionMode::Replace) clear();
return;
}
halnf minX = points[0].x, minY = points[0].y, maxX = minX, maxY = minY;
for (const Vec2F& point : points) {
minX = std::min(minX, point.x);
minY = std::min(minY, point.y);
maxX = std::max(maxX, point.x);
maxY = std::max(maxY, point.y);
}
const int4 left = (int4) std::floor(minX);
const int4 top = (int4) std::floor(minY);
const int4 right = (int4) std::ceil(maxX);
const int4 bottom = (int4) std::ceil(maxY);
const int4 width = std::max(1, right - left);
const int4 height = std::max(1, bottom - top);
std::vector<int4> hits((size_t) width * height, 0);
std::vector<halnf> crossings;
for (int4 row = 0; row < height * kSamples; row++) {
const halnf y = (halnf) top + (row + 0.5f) / kSamples;
crossings.clear();
for (size_t index = 0; index < points.size(); index++) {
const Vec2F a = points[index];
const Vec2F b = points[(index + 1) % points.size()];
if ((a.y <= y) == (b.y <= y)) continue;
crossings.push_back(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
}
std::sort(crossings.begin(), crossings.end());
for (size_t pair = 0; pair + 1 < crossings.size(); pair += 2) {
for (int4 column = 0; column < width * kSamples; column++) {
const halnf x = (halnf) left + (column + 0.5f) / kSamples;
if (x >= crossings[pair] && x < crossings[pair + 1])
hits[(size_t) (row / kSamples) * width + column / kSamples]++;
}
}
}
std::vector<uint1> mask(hits.size());
for (size_t index = 0; index < hits.size(); index++)
mask[index] = (uint1) std::min(255, hits[index] * 255 / (kSamples * kSamples));
addMask(left, top, width, mask, mode);
}
void Selection::spread(int4 pixels) {
if (!mActive || pixels == 0) return;
const bool grow = (pixels > 0) != mInverted;
const int4 steps = std::abs(pixels);
int4 left = 0, top = 0, right = 0, bottom = 0;
const bool inverted = mInverted;
mInverted = false;
const bool any = bounds(left, top, right, bottom);
mInverted = inverted;
if (!any) return;
left -= steps;
top -= steps;
right += steps;
bottom += steps;
const int4 width = right - left;
const int4 height = bottom - top;
std::vector<uint1> current((size_t) width * height, 0);
for (int4 y = top; y < bottom; y++)
for (int4 x = left; x < right; x++) {
const MaskTile* mask = tile(floordiv(x, kTileSize), floordiv(y, kTileSize));
current[(size_t) (y - top) * width + (x - left)] =
mask ? mask->coverage
[(x - floordiv(x, kTileSize) * kTileSize) + (y - floordiv(y, kTileSize) * kTileSize) * kTileSize]
: 0;
}
for (int4 step = 0; step < steps; step++) {
std::vector<uint1> next = current;
for (int4 y = 0; y < height; y++)
for (int4 x = 0; x < width; x++) {
uint1 value = current[(size_t) y * width + x];
for (const auto [dx, dy] : { std::pair{ -1, 0 }, std::pair{ 1, 0 }, std::pair{ 0, -1 }, std::pair{ 0, 1 } }) {
const int4 nx = x + dx;
const int4 ny = y + dy;
const uint1 neighbour =
(nx < 0 || ny < 0 || nx >= width || ny >= height) ? 0 : current[(size_t) ny * width + nx];
value = grow ? std::max(value, neighbour) : std::min(value, neighbour);
}
next[(size_t) y * width + x] = value;
}
current = std::move(next);
}
mTiles.clear();
for (size_t index = 0; index < current.size(); index++)
if (current[index])
combine(
left + (int4) (index % (size_t) width),
top + (int4) (index / (size_t) width),
current[index],
SelectionMode::Add
);
compact();
if (!mInverted && mTiles.empty()) mActive = false;
}
void Selection::blur(int4 radius) {
if (!mActive || radius <= 0) return;
int4 left = 0, top = 0, right = 0, bottom = 0;
const bool inverted = mInverted;
mInverted = false;
const bool any = bounds(left, top, right, bottom);
mInverted = inverted;
if (!any) return;
left -= radius;
top -= radius;
right += radius;
bottom += radius;
const int4 width = right - left;
const int4 height = bottom - top;
std::vector<halnf> values((size_t) width * height, 0);
for (int4 y = top; y < bottom; y++)
for (int4 x = left; x < right; x++) {
const MaskTile* mask = tile(floordiv(x, kTileSize), floordiv(y, kTileSize));
values[(size_t) (y - top) * width + (x - left)] =
mask ? mask->coverage
[(x - floordiv(x, kTileSize) * kTileSize) + (y - floordiv(y, kTileSize) * kTileSize) * kTileSize]
: 0;
}
const halnf outside = 0;
std::vector<halnf> pass(values.size());
const halnf window = (halnf) (2 * radius + 1);
for (int4 y = 0; y < height; y++)
for (int4 x = 0; x < width; x++) {
halnf sum = 0;
for (int4 k = -radius; k <= radius; k++) {
const int4 nx = x + k;
sum += (nx < 0 || nx >= width) ? outside : values[(size_t) y * width + nx];
}
pass[(size_t) y * width + x] = sum / window;
}
for (int4 y = 0; y < height; y++)
for (int4 x = 0; x < width; x++) {
halnf sum = 0;
for (int4 k = -radius; k <= radius; k++) {
const int4 ny = y + k;
sum += (ny < 0 || ny >= height) ? outside : pass[(size_t) ny * width + x];
}
values[(size_t) y * width + x] = sum / window;
}
mTiles.clear();
for (size_t index = 0; index < values.size(); index++) {
const uint1 value = (uint1) std::clamp((int4) std::lround(values[index]), 0, 255);
if (value)
combine(
left + (int4) (index % (size_t) width), top + (int4) (index / (size_t) width), value, SelectionMode::Add
);
}
compact();
}
bool Selection::bounds(int4& left, int4& top, int4& right, int4& bottom) const {
if (!mActive || mInverted) return false;
bool any = false;
for (const auto& [packed, mask] : mTiles) {
const int4 tx = (int4) (uint4) (packed >> 32);
const int4 ty = (int4) (uint4) (packed & 0xffffffffu);
for (int4 y = 0; y < kTileSize; y++)
for (int4 x = 0; x < kTileSize; x++) {
if (mask->coverage[x + y * kTileSize] == 0) continue;
const int4 px = tx * kTileSize + x;
const int4 py = ty * kTileSize + y;
if (!any) {
left = right = px;
top = bottom = py;
any = true;
}
left = std::min(left, px);
top = std::min(top, py);
right = std::max(right, px + 1);
bottom = std::max(bottom, py + 1);
}
}
return any;
}
std::vector<std::pair<Vec2F, Vec2F>> Selection::outline() const {
std::vector<std::pair<Vec2F, Vec2F>> edges;
if (!mActive) return edges;
const auto inside = [this](int4 x, int4 y) {
const MaskTile* mask = tile(floordiv(x, kTileSize), floordiv(y, kTileSize));
if (!mask) return false;
return mask->coverage
[(x - floordiv(x, kTileSize) * kTileSize) + (y - floordiv(y, kTileSize) * kTileSize) * kTileSize] >= 128;
};
for (const auto& [packed, mask] : mTiles) {
const int4 tx = (int4) (uint4) (packed >> 32);
const int4 ty = (int4) (uint4) (packed & 0xffffffffu);
for (int4 y = 0; y < kTileSize; y++)
for (int4 x = 0; x < kTileSize; x++) {
if (mask->coverage[x + y * kTileSize] < 128) continue;
const int4 px = tx * kTileSize + x;
const int4 py = ty * kTileSize + y;
const halnf fx = (halnf) px;
const halnf fy = (halnf) py;
if (!inside(px, py - 1)) edges.push_back({ { fx, fy }, { fx + 1, fy } });
if (!inside(px, py + 1)) edges.push_back({ { fx, fy + 1 }, { fx + 1, fy + 1 } });
if (!inside(px - 1, py)) edges.push_back({ { fx, fy }, { fx, fy + 1 } });
if (!inside(px + 1, py)) edges.push_back({ { fx + 1, fy }, { fx + 1, fy + 1 } });
}
}
return edges;
}
bool Selection::operator==(const Selection& other) const {
if (mActive != other.mActive || mInverted != other.mInverted || mTiles.size() != other.mTiles.size()) return false;
for (const auto& [packed, mask] : mTiles) {
const auto found = other.mTiles.find(packed);
if (found == other.mTiles.end()) return false;
if (
found->second != mask &&
!std::equal(mask->coverage, mask->coverage + kTileSize * kTileSize, found->second->coverage)
)
return false;
}
return true;
}
}

View file

@ -0,0 +1,268 @@
#include "StrokePainter.hpp"
#include "Layer.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
namespace tp::sketch2d {
namespace {
constexpr size_t kSubmitBatch = 256;
uint1 toByte(halnf value) {
if (value <= 0.0f) return 0;
if (value >= 255.0f) return 255;
return (uint1) (int4) (value + 0.5f);
}
void blendMarker(uint1* premultiplied, const halnf source[3], halnf coverage) {
const halnf alpha = premultiplied[3] / 255.0f;
const halnf overAlpha = alpha + (1.0f - alpha) * coverage;
const halnf keptAlpha = std::max(alpha, coverage);
for (int4 channel = 0; channel < 3; channel++) {
const halnf mixed = premultiplied[channel] + (source[channel] - premultiplied[channel]) * coverage;
const halnf straight = overAlpha > 0.0f ? mixed / overAlpha : 0.0f;
premultiplied[channel] = toByte(straight * keptAlpha);
}
premultiplied[3] = toByte(255.0f * keptAlpha);
}
int64_t nowNs() {
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch())
.count();
}
}
StrokePainter::~StrokePainter() { flush(); }
void StrokePainter::setWorkers(size_t count) {
flush();
mLanes.reset();
mStaged.clear();
mNotes.clear();
if (count == 0) return;
mStaged.resize(count);
mNotes.resize(count);
mLanes = std::make_unique<WorkerLanes<DabJob>>(count, [this](size_t lane, DabJob& job) { run(lane, job); });
}
size_t StrokePainter::laneOf(TileCoord coord, int4 bandY) const {
const ualni key = coord.key() * 0x9e3779b97f4a7c15ull + (ualni) (bandY / kBandRows);
return (size_t) ((key ^ (key >> 29)) % mStaged.size());
}
void StrokePainter::stage(
const std::shared_ptr<const DabWork>& work,
Tile& tile,
Layer& layer,
TileCoord coord,
int4 x0,
int4 y0,
int4 x1,
int4 y1,
StrokeMask* mask
) {
if (!mLanes) {
if (mask) paintMasked(*work, *mask, tile, coord, x0, y0, x1, y1);
else paint(*work, tile, coord, x0, y0, x1, y1);
tile.markDirty({ (halnf) x0, (halnf) y0, (halnf) (x1 - x0), (halnf) (y1 - y0) });
return;
}
for (int4 band = y0; band < y1;) {
const int4 bandEnd = std::min(y1, (band / kBandRows + 1) * kBandRows);
mStaged[laneOf(coord, band)].push_back({ work, &tile, mask, &layer, coord, x0, band, x1, bandEnd });
mStagedCount++;
band = bandEnd;
}
if (mStagedCount >= kSubmitBatch) submit();
}
void StrokePainter::submit() {
if (!mLanes || mStagedCount == 0) return;
for (size_t lane = 0; lane < mStaged.size(); lane++)
mLanes->pushAll(lane, mStaged[lane]);
mStagedCount = 0;
}
void StrokePainter::flush() {
if (!mLanes) return;
if (isPausedHere()) {
for (size_t lane = 0; lane < mStaged.size(); lane++) {
for (DabJob& job : mStaged[lane])
run(lane, job);
mStaged[lane].clear();
}
mStagedCount = 0;
mLanes->runQueuedHere();
applyNotes();
return;
}
submit();
mLanes->wait();
applyNotes();
}
void StrokePainter::applyNotes() {
for (std::vector<DirtyNote>& notes : mNotes) {
for (const DirtyNote& note : notes) {
note.tile->markDirty(
{ (halnf) note.x0, (halnf) note.y0, (halnf) (note.x1 - note.x0), (halnf) (note.y1 - note.y0) }
);
note.layer->noteChanged(note.coord);
}
notes.clear();
}
}
double StrokePainter::takeWorkerMs() { return (double) mWorkerNs.exchange(0) / 1.0e6; }
void StrokePainter::run(size_t lane, DabJob& job) {
const int64_t start = nowNs();
if (job.mask) paintMasked(*job.work, *job.mask, *job.tile, job.coord, job.x0, job.y0, job.x1, job.y1);
else paint(*job.work, *job.tile, job.coord, job.x0, job.y0, job.x1, job.y1);
std::vector<DirtyNote>& notes = mNotes[lane];
if (!notes.empty() && notes.back().tile == job.tile && notes.back().y0 / kBandRows == job.y0 / kBandRows) {
DirtyNote& last = notes.back();
last.x0 = std::min(last.x0, job.x0);
last.y0 = std::min(last.y0, job.y0);
last.x1 = std::max(last.x1, job.x1);
last.y1 = std::max(last.y1, job.y1);
} else {
notes.push_back({ job.tile, job.layer, job.coord, job.x0, job.y0, job.x1, job.y1 });
}
mWorkerNs.fetch_add(nowNs() - start, std::memory_order_relaxed);
}
namespace {
struct DabPixel {
int4 x;
int4 y;
halnf coverage;
};
struct Blend {
const DabWork& work;
uint1* pixels;
StrokeMask* mask;
const uint1* base;
void apply(int4 x, int4 y, halnf a) const {
const size_t index = (size_t) y * kTileSize + (size_t) x;
uint1* dst = pixels + index * 4;
const halnf* source = work.source;
if (mask) {
uint16_t* coverage = mask->coverage();
coverage[index] = StrokeMask::raise(coverage[index], a);
const halnf stroke = (halnf) coverage[index] / (halnf) StrokeMask::kOne * work.opacity;
const halnf below = 1.0f - stroke;
const uint1* under = base ? base + index * 4 : nullptr;
for (int4 channel = 0; channel < 3; channel++)
dst[channel] = toByte(source[channel] * stroke + (under ? under[channel] : 0.0f) * below);
dst[3] = toByte(255.0f * stroke + (under ? under[3] : 0.0f) * below);
return;
}
const halnf keep = 1.0f - a;
if (work.erase) {
for (int4 channel = 0; channel < 4; channel++)
dst[channel] = toByte(dst[channel] * keep);
} else if (work.marker && !work.keepAlpha) {
blendMarker(dst, source, a);
} else if (work.keepAlpha) {
const halnf alpha = dst[3] / 255.0f;
for (int4 channel = 0; channel < 3; channel++)
dst[channel] = toByte(source[channel] * alpha * a + dst[channel] * keep);
} else {
for (int4 channel = 0; channel < 3; channel++)
dst[channel] = toByte(source[channel] * a + dst[channel] * keep);
dst[3] = toByte(255.0f * a + dst[3] * keep);
}
}
};
halnf finishCoverage(const DabWork& work, halnf a, halnf distance, Vec2F world, bool masked) {
if (!work.dab.plain()) {
a = work.dab.shape().textured(a, world, distance);
if (a <= 0.0f) return 0;
}
a *= masked ? work.strength : work.strength * work.opacity;
if (work.selection) a *= work.selection->coverage((int4) world.x, (int4) world.y) / 255.0f;
return a;
}
}
void StrokePainter::paintAtLevel(
const DabWork& work, int4 level, Tile& tile, TileCoord coord, int4 x0, int4 y0, int4 x1, int4 y1, StrokeMask* mask
) {
const halnf scale = (halnf) (1 << std::max(0, level));
const DabPlacement placement = DabPlacement::snap(work.center / scale, work.dab.shape().size / scale);
const Blend blend{ work, tile.pixels(), mask, mask && mask->base() ? mask->base()->pixels() : nullptr };
const int4 originX = coord.x * kTileSize;
const int4 originY = coord.y * kTileSize;
const int4 anchorX = (int4) std::floor(placement.center.x) - originX;
const int4 anchorY = (int4) std::floor(placement.center.y) - originY;
if (std::shared_ptr<const DabMask> cached = DabMaskCache::shared().mask(work.dab, placement, scale)) {
const DabMask& dab = *cached;
const int4 fromY = std::max(y0, anchorY + dab.top);
const int4 toY = std::min(y1, anchorY + dab.top + dab.height);
const int4 fromX = std::max(x0, anchorX + dab.left);
const int4 toX = std::min(x1, anchorX + dab.left + dab.width);
for (int4 y = fromY; y < toY; y++) {
for (int4 x = fromX; x < toX; x++) {
const size_t index = dab.index(x - anchorX, y - anchorY);
const uint16_t value = dab.coverage[index];
if (value == 0) continue;
const Vec2F world = { ((halnf) (originX + x) + 0.5f) * scale, ((halnf) (originY + y) + 0.5f) * scale };
const halnf a = finishCoverage(
work,
(halnf) value / (halnf) DabMask::kOne,
(halnf) dab.distance[index] / (halnf) DabMask::kOne,
world,
mask != nullptr
);
if (a > 0.0f) blend.apply(x, y, a);
}
}
return;
}
const PreparedDab& dab = work.dab;
const halnf inverse = 1.0f / scale;
const halnf radius = dab.shape().radius() * inverse + 1.0f;
const halnf centerX = placement.center.x - (halnf) originX;
const halnf centerY = placement.center.y - (halnf) originY;
const int4 fromY = std::max(y0, (int4) std::floor(centerY - radius));
const int4 toY = std::min(y1, (int4) std::ceil(centerY + radius) + 1);
for (int4 y = fromY; y < toY; y++) {
const halnf wy = ((halnf) y + 0.5f - centerY) * scale;
halnf spanFrom = 0;
halnf spanTo = 0;
if (!dab.rowSpan(wy, spanFrom, spanTo)) continue;
const int4 fromX = std::max(x0, (int4) std::floor(centerX - 0.5f + spanFrom * inverse) - 1);
const int4 toX = std::min(x1, (int4) std::ceil(centerX - 0.5f + spanTo * inverse) + 2);
for (int4 x = fromX; x < toX; x++) {
const halnf wx = ((halnf) x + 0.5f - centerX) * scale;
halnf distance = 0;
const halnf value = dab.coverage(wx, wy, distance);
if (value <= 0.0f) continue;
const Vec2F world = { ((halnf) (originX + x) + 0.5f) * scale, ((halnf) (originY + y) + 0.5f) * scale };
const halnf a = finishCoverage(work, value, distance, world, mask != nullptr);
if (a > 0.0f) blend.apply(x, y, a);
}
}
}
void StrokePainter::paint(const DabWork& work, Tile& tile, TileCoord coord, int4 x0, int4 y0, int4 x1, int4 y1) {
paintAtLevel(work, 0, tile, coord, x0, y0, x1, y1, nullptr);
}
void StrokePainter::paintMasked(
const DabWork& work, StrokeMask& mask, Tile& tile, TileCoord coord, int4 x0, int4 y0, int4 x1, int4 y1
) {
paintAtLevel(work, 0, tile, coord, x0, y0, x1, y1, &mask);
}
}

View file

@ -0,0 +1,139 @@
#include "Tile.hpp"
#include <cmath>
#include <cstdlib>
#include <cstring>
namespace tp::sketch2d {
namespace {
int4 clampi(int4 v, int4 lo, int4 hi) { return v < lo ? lo : (v > hi ? hi : v); }
}
Tile::Tile() {
const ualni bytes = (ualni) kTileSize * kTileSize * 4;
mPixels = (uint1*) std::malloc(bytes);
std::memset(mPixels, 0, bytes);
}
Tile::Tile(Uninitialized) { mPixels = (uint1*) std::malloc((ualni) kTileSize * kTileSize * 4); }
Tile::~Tile() { std::free(mPixels); }
std::shared_ptr<Tile> Tile::takeForWriting() {
auto copy = std::make_shared<Tile>();
std::memcpy(copy->mPixels, mPixels, (ualni) kTileSize * kTileSize * 4);
copy->mDirty = mDirty;
copy->mMipChanges = mMipChanges;
copy->mGpuId = mGpuId;
mGpuId = 0;
copy->mCompositeBands = mCompositeBands;
mDirty = { 0, 0, (halnf) kTileSize, (halnf) kTileSize };
mMipChanges = mDirty;
mCompositeBands.include(0, 0, kTileSize, kTileSize);
return copy;
}
void Tile::clear(RGBA color) {
const uint1 r = (uint1) std::lround(color.r * color.a * 255.0f);
const uint1 g = (uint1) std::lround(color.g * color.a * 255.0f);
const uint1 b = (uint1) std::lround(color.b * color.a * 255.0f);
const uint1 a = (uint1) std::lround(color.a * 255.0f);
for (int4 i = 0; i < kTileSize * kTileSize; i++) {
mPixels[i * 4 + 0] = r;
mPixels[i * 4 + 1] = g;
mPixels[i * 4 + 2] = b;
mPixels[i * 4 + 3] = a;
}
mDirty = { 0, 0, (halnf) kTileSize, (halnf) kTileSize };
mMipChanges = mDirty;
mCompositeBands.include(0, 0, kTileSize, kTileSize);
}
bool Tile::isTransparent() const {
for (ualni index = 3; index < (ualni) kTileSize * kTileSize * 4; index += 4) {
if (mPixels[index] != 0) return false;
}
return true;
}
namespace {
void includeRect(RectF& accumulated, int4 x0, int4 y0, int4 x1, int4 y1) {
if (accumulated.z <= 0 || accumulated.w <= 0) {
accumulated = { (halnf) x0, (halnf) y0, (halnf) (x1 - x0), (halnf) (y1 - y0) };
return;
}
const int4 ex0 = (int4) accumulated.pos.x;
const int4 ey0 = (int4) accumulated.pos.y;
const int4 ex1 = ex0 + (int4) accumulated.size.x;
const int4 ey1 = ey0 + (int4) accumulated.size.y;
const int4 nx0 = x0 < ex0 ? x0 : ex0;
const int4 ny0 = y0 < ey0 ? y0 : ey0;
const int4 nx1 = x1 > ex1 ? x1 : ex1;
const int4 ny1 = y1 > ey1 ? y1 : ey1;
accumulated = { (halnf) nx0, (halnf) ny0, (halnf) (nx1 - nx0), (halnf) (ny1 - ny0) };
}
}
void Tile::markDirty(const RectF& rectInTile) {
const int4 x0 = clampi((int4) rectInTile.pos.x, 0, kTileSize);
const int4 y0 = clampi((int4) rectInTile.pos.y, 0, kTileSize);
const int4 x1 = clampi((int4) (rectInTile.pos.x + rectInTile.size.x + 0.999f), 0, kTileSize);
const int4 y1 = clampi((int4) (rectInTile.pos.y + rectInTile.size.y + 0.999f), 0, kTileSize);
if (x1 <= x0 || y1 <= y0) return;
includeRect(mDirty, x0, y0, x1, y1);
includeRect(mMipChanges, x0, y0, x1, y1);
mCompositeBands.include(x0, y0, x1, y1);
}
void DirtySpan::include(int4 left, int4 top, int4 right, int4 bottom) {
if (right <= left || bottom <= top) return;
if (isEmpty()) {
*this = { left, top, right, bottom };
return;
}
x0 = left < x0 ? left : x0;
y0 = top < y0 ? top : y0;
x1 = right > x1 ? right : x1;
y1 = bottom > y1 ? bottom : y1;
}
void DirtyBands::include(int4 x0, int4 y0, int4 x1, int4 y1) {
if (x1 <= x0 || y1 <= y0) return;
for (int4 band = y0 / kBandRows; band <= (y1 - 1) / kBandRows; band++) {
const int4 top = band * kBandRows;
mBands[band].include(x0, y0 > top ? y0 : top, x1, y1 < top + kBandRows ? y1 : top + kBandRows);
}
}
void DirtyBands::include(const DirtyBands& other) {
for (int4 band = 0; band < kBands; band++) {
const DirtySpan& span = other.mBands[band];
mBands[band].include(span.x0, span.y0, span.x1, span.y1);
}
}
void DirtyBands::clear() {
for (DirtySpan& span : mBands)
span = {};
}
bool DirtyBands::isEmpty() const {
for (const DirtySpan& span : mBands) {
if (!span.isEmpty()) return false;
}
return true;
}
ualni DirtyBands::area() const {
ualni total = 0;
for (const DirtySpan& span : mBands) {
if (!span.isEmpty()) total += (ualni) (span.x1 - span.x0) * (ualni) (span.y1 - span.y0);
}
return total;
}
}

View file

@ -0,0 +1,111 @@
#pragma once
#include "Layer.hpp"
#include "LayerLevels.hpp"
#include <atomic>
#include <condition_variable>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace tp::sketch2d {
struct TileWork {
TileCoord coord;
int4 level = 0;
};
class Operation {
public:
virtual ~Operation() = default;
[[nodiscard]] virtual const char* name() const = 0;
virtual void run(const TileWork& work, Tile& target) = 0;
virtual void run(const TileWork& work, const std::shared_ptr<Tile>& target) { run(work, *target); }
virtual void finish(Layer& layer, const TileWork& work, const std::shared_ptr<Tile>& result);
};
class Engine {
public:
struct Job {
std::shared_ptr<Operation> operation;
std::shared_ptr<Tile> tile;
Layer* layer = nullptr;
TileWork work;
};
static constexpr double kFrameApplyMs = 2.0;
explicit Engine(size_t threads = 0);
~Engine();
Engine(const Engine&) = delete;
Engine& operator=(const Engine&) = delete;
void setThreads(size_t threads);
[[nodiscard]] size_t threads() const { return mThreads.size(); }
void submit(Job job);
void drain();
size_t runFor(double milliseconds);
void apply();
void applyFor(double milliseconds);
[[nodiscard]] size_t pending() const;
[[nodiscard]] size_t pending(int4 level) const;
[[nodiscard]] bool hasWork(const Layer& layer, const TileCoord& coord) const;
[[nodiscard]] bool hasWorkFor(const Layer& layer) const;
template <typename Fn>
void forEachPending(Fn&& fn) const {
std::lock_guard lock(mQueueMutex);
for (const std::deque<Job>& queue : mQueues)
for (const Job& job : queue)
fn(*job.layer, job.work, false);
for (const auto& running : mRunning)
fn(*running.first, running.second, true);
}
[[nodiscard]] ualni completed() const { return mCompleted.load(std::memory_order_acquire); }
[[nodiscard]] std::string describeState() const;
[[nodiscard]] bool isPausedHere() const { return mPausedBy.load() == std::this_thread::get_id(); }
template <typename Fn>
void whilePaused(Fn&& fn) {
if (isPausedHere()) {
fn();
return;
}
mPausedBy.store(std::this_thread::get_id());
applyFor(kFrameApplyMs);
fn();
mPausedBy.store(std::thread::id());
}
private:
[[nodiscard]] static ualni keyOf(const Layer* layer, const TileCoord& coord);
bool takeOne(Job& job);
void runJob(Job& job);
void worker();
void stopThreads();
std::deque<Job> mQueues[LayerLevels::kLevels];
Map<ualni, int4> mQueued;
Map<ualni, bool> mBusy;
mutable std::mutex mQueueMutex;
std::condition_variable mWake;
std::condition_variable mIdle;
size_t mActive = 0;
std::vector<std::pair<const Layer*, TileWork>> mRunning;
bool mStopping = false;
mutable std::mutex mDoneMutex;
std::deque<Job> mDone;
std::vector<std::thread> mThreads;
std::atomic<ualni> mCompleted{ 0 };
std::atomic<std::thread::id> mPausedBy{};
};
}

View file

@ -0,0 +1,135 @@
#pragma once
#include "Map.hpp"
#include "Tile.hpp"
#include <memory>
#include <string>
namespace tp::sketch2d {
enum class BlendMode : int4 {
Normal = 0,
Multiply = 1,
Screen = 2,
Overlay = 3,
Lighten = 4,
Darken = 5,
ColorDodge = 6,
ColorBurn = 7,
SoftDodge = 8,
SoftBurn = 9,
Color = 10,
};
constexpr int4 kBlendModeCount = 11;
[[nodiscard]] const char* blendModeName(BlendMode mode);
[[nodiscard]] BlendMode nextBlendMode(BlendMode mode);
[[nodiscard]] BlendMode previousBlendMode(BlendMode mode);
class LayerLevels;
class Layer {
public:
using TilePointer = std::shared_ptr<Tile>;
struct TileSlot {
TileCoord coord;
TilePointer tile;
};
struct Properties {
std::string name;
bool visible = true;
halnf opacity = 1.0f;
BlendMode blend = BlendMode::Normal;
bool clipped = false;
bool locked = false;
bool alphaLocked = false;
bool selected = false;
};
struct State {
ualni id = 0;
Properties properties;
Map<ualni, TileSlot> tiles;
};
Layer(ualni id, const std::string& name);
~Layer();
[[nodiscard]] LayerLevels& levels() const;
Layer(const Layer&) = delete;
Layer& operator=(const Layer&) = delete;
[[nodiscard]] ualni id() const { return mId; }
[[nodiscard]] ualni serial() const { return mSerial; }
Tile* tileAt(const TileCoord& coord);
[[nodiscard]] Tile* tileIfExists(const TileCoord& coord) const;
[[nodiscard]] std::shared_ptr<Tile> sharedTile(const TileCoord& coord) const;
std::shared_ptr<Tile> replaceTile(const TileCoord& coord, std::shared_ptr<Tile> tile);
[[nodiscard]] ualni tileCount() const { return mTiles.size(); }
template <typename Fn>
void forEachTile(Fn&& fn) const {
for (auto it : mTiles)
fn(it->val.coord, it->val.tile.get());
}
void clear();
void removeTile(const TileCoord& coord);
void noteDisplayChanged(const TileCoord& coord) { mChanged.put(coord.key(), coord); }
void noteChanged(const TileCoord& coord) {
mChanged.put(coord.key(), coord);
mLevelChanged.put(coord.key(), coord);
}
template <typename Fn>
void forEachLevelChanged(Fn&& fn) const {
for (auto it : mLevelChanged)
fn(it->val);
}
void clearLevelChanges() const { mLevelChanged.removeAll(); }
template <typename Fn>
void forEachChanged(Fn&& fn) const {
for (auto it : mChanged)
fn(it->val);
}
[[nodiscard]] ualni changedCount() const { return mChanged.size(); }
void clearChanges() const { mChanged.removeAll(); }
[[nodiscard]] State state() const;
void restore(const State& state);
[[nodiscard]] const Properties& properties() const { return mProperties; }
[[nodiscard]] const std::string& name() const { return mProperties.name; }
void setName(const std::string& name) { mProperties.name = name; }
[[nodiscard]] bool visible() const { return mProperties.visible; }
void setVisible(bool visible) { mProperties.visible = visible; }
[[nodiscard]] halnf opacity() const { return mProperties.opacity; }
void setOpacity(halnf opacity) { mProperties.opacity = opacity; }
[[nodiscard]] BlendMode blend() const { return mProperties.blend; }
void setBlend(BlendMode blend) { mProperties.blend = blend; }
[[nodiscard]] bool clipped() const { return mProperties.clipped; }
void setClipped(bool clipped) { mProperties.clipped = clipped; }
[[nodiscard]] bool locked() const { return mProperties.locked; }
void setLocked(bool locked) { mProperties.locked = locked; }
[[nodiscard]] bool alphaLocked() const { return mProperties.alphaLocked; }
void setAlphaLocked(bool alphaLocked) { mProperties.alphaLocked = alphaLocked; }
[[nodiscard]] bool selected() const { return mProperties.selected; }
void setSelected(bool selected) { mProperties.selected = selected; }
private:
ualni mId;
ualni mSerial;
Properties mProperties;
Map<ualni, TileSlot> mTiles;
mutable Map<ualni, TileCoord> mChanged;
mutable Map<ualni, TileCoord> mLevelChanged;
mutable std::unique_ptr<LayerLevels> mLevels;
};
}

View file

@ -0,0 +1,102 @@
#pragma once
#include "Layer.hpp"
#include "ParallelFor.hpp"
#include <functional>
#include <memory>
#include <vector>
namespace tp::sketch2d {
class LayerLevels {
public:
static constexpr int4 kLevels = 7;
[[nodiscard]] static int4 levelForZoom(halnf zoom);
[[nodiscard]] static TileCoord parentOf(const TileCoord& coord) {
return { floordiv(coord.x, 2), floordiv(coord.y, 2) };
}
using PendingCheck = std::function<bool(const TileCoord& coord)>;
void update(const Layer& layer, int4 upToLevel, ParallelFor* parallel = nullptr);
void setPendingCheck(PendingCheck isPending) { mIsPending = std::move(isPending); }
[[nodiscard]] bool isStale(int4 level, const TileCoord& coord) const;
[[nodiscard]] ualni staleCount() const;
[[nodiscard]] ualni rebuildCount() const { return mRebuilds; }
[[nodiscard]] Tile* tileAt(int4 level, const TileCoord& coord) const;
[[nodiscard]] ualni tileCount(int4 level) const;
[[nodiscard]] int4 builtLevels() const { return mBuilt; }
[[nodiscard]] ualni lastDownsampledTiles() const { return mLastDownsampled; }
[[nodiscard]] ualni lastDownsampledPixels() const { return mLastPixels; }
[[nodiscard]] const std::vector<TileCoord>& lastChanged(int4 level) const;
template <typename Fn>
void takeChanges(int4 level, Fn&& fn) {
if (level < 1 || level >= kLevels) return;
for (auto it : mAccumulated[level])
fn(it->val);
mAccumulated[level].removeAll();
}
[[nodiscard]] const std::vector<std::shared_ptr<Tile>>& lastRemoved() const { return mRemoved; }
template <typename Fn>
void forEachTile(int4 level, Fn&& fn) const {
if (level < 1 || level > mBuilt) return;
for (auto it : mLevels[(size_t) level - 1])
fn(it->val.coord, it->val.tile.get());
}
static void downsample(const Tile* const quadrants[4], Tile& target, int4 x0, int4 y0, int4 x1, int4 y1);
Tile* previewTile(int4 level, const TileCoord& coord);
Tile* basePreviewTile(const Layer& layer, const TileCoord& coord);
[[nodiscard]] Tile* basePreview(const TileCoord& coord) const;
template <typename Fn>
void forEachBasePreview(Fn&& fn) const {
for (auto it : mBase)
fn(it->val.coord, it->val.tile.get());
}
[[nodiscard]] bool isPreview(int4 level, const TileCoord& coord) const;
[[nodiscard]] ualni previewCount() const;
void endPreview();
void discardPreview();
private:
[[nodiscard]] std::vector<TileCoord> holdBackPending(std::vector<TileCoord> changed);
void markStale(const TileCoord& child);
[[nodiscard]] bool anyChildPending(const TileCoord& parent) const;
struct LevelTile {
TileCoord coord;
std::shared_ptr<Tile> tile;
};
using Level = Map<ualni, LevelTile>;
template <typename Lookup>
void updateLevel(
int4 level,
const Lookup& children,
const std::vector<TileCoord>& changedChildren,
std::vector<TileCoord>& changedParents,
ParallelFor* parallel
);
Level mLevels[kLevels - 1];
std::vector<TileCoord> mChanged[kLevels];
int4 mBuilt = 0;
ualni mLastDownsampled = 0;
ualni mLastPixels = 0;
std::vector<std::shared_ptr<Tile>> mRemoved;
std::vector<TileCoord> mPending;
Map<ualni, TileCoord> mAccumulated[kLevels];
Map<ualni, TileCoord> mPreview[kLevels];
Map<ualni, LevelTile> mOriginals[kLevels];
Level mBase;
std::vector<TileCoord> mReleased[kLevels];
Map<ualni, TileCoord> mStale[kLevels];
PendingCheck mIsPending;
ualni mRebuilds = 0;
};
}

View file

@ -0,0 +1,51 @@
#pragma once
#include "Tile.hpp"
#include <memory>
#include <unordered_map>
#include <vector>
namespace tp::sketch2d {
enum class SelectionMode { Replace, Add, Subtract };
class Selection {
public:
struct MaskTile {
uint1 coverage[kTileSize * kTileSize] = {};
};
[[nodiscard]] bool isActive() const { return mActive; }
[[nodiscard]] bool isInverted() const { return mInverted; }
[[nodiscard]] uint1 coverage(int4 x, int4 y) const;
[[nodiscard]] halnf coverageAt(Vec2F world) const;
[[nodiscard]] bool bounds(int4& left, int4& top, int4& right, int4& bottom) const;
[[nodiscard]] size_t tileCount() const { return mTiles.size(); }
void clear();
void selectAll(int4 left, int4 top, int4 right, int4 bottom);
void invert();
void addRectangle(int4 left, int4 top, int4 right, int4 bottom, SelectionMode mode);
void addEllipse(Vec2F center, Vec2F radius, SelectionMode mode);
void addPolygon(const std::vector<Vec2F>& points, SelectionMode mode);
void addMask(int4 left, int4 top, int4 width, const std::vector<uint1>& mask, SelectionMode mode);
void spread(int4 pixels);
void blur(int4 radius);
[[nodiscard]] std::vector<std::pair<Vec2F, Vec2F>> outline() const;
[[nodiscard]] bool operator==(const Selection& other) const;
private:
[[nodiscard]] const MaskTile* tile(int4 tx, int4 ty) const;
MaskTile* writableTile(int4 tx, int4 ty);
void combine(int4 x, int4 y, uint1 value, SelectionMode mode);
void compact();
static ualni key(int4 tx, int4 ty) { return ((ualni) (uint4) tx << 32) | (ualni) (uint4) ty; }
std::unordered_map<ualni, std::shared_ptr<MaskTile>> mTiles;
bool mActive = false;
bool mInverted = false;
};
}

View file

@ -0,0 +1,33 @@
#pragma once
#include "Tile.hpp"
#include <cstdint>
#include <memory>
#include <vector>
namespace tp::sketch2d {
class StrokeMask {
public:
static constexpr int4 kOne = 1 << 14;
StrokeMask() :
mCoverage((size_t) kTileSize * kTileSize, 0) {}
[[nodiscard]] uint16_t* coverage() { return mCoverage.data(); }
[[nodiscard]] const uint16_t* coverage() const { return mCoverage.data(); }
[[nodiscard]] const std::shared_ptr<const Tile>& base() const { return mBase; }
void setBase(std::shared_ptr<const Tile> base) { mBase = std::move(base); }
static uint16_t raise(uint16_t current, halnf amount) {
const int4 strength = (int4) (amount * (halnf) kOne + 0.5f);
return (uint16_t) (current + (((kOne - (int4) current) * strength) >> 14));
}
private:
std::vector<uint16_t> mCoverage;
std::shared_ptr<const Tile> mBase;
};
}

View file

@ -0,0 +1,125 @@
#pragma once
#include "Selection.hpp"
#include "Brush.hpp"
#include "DabMask.hpp"
#include "StrokeMask.hpp"
#include "Tile.hpp"
#include "WorkerLanes.hpp"
#include <atomic>
#include <thread>
#include <memory>
#include <vector>
namespace tp::sketch2d {
class Layer;
struct DabWork {
PreparedDab dab;
Vec2F center = { 0, 0 };
halnf strength = 0;
halnf opacity = 1;
halnf source[3] = { 0, 0, 0 };
bool erase = false;
bool marker = false;
bool keepAlpha = false;
std::shared_ptr<const Selection> selection;
};
struct DabJob {
std::shared_ptr<const DabWork> work;
Tile* tile = nullptr;
StrokeMask* mask = nullptr;
Layer* layer = nullptr;
TileCoord coord;
int4 x0 = 0;
int4 y0 = 0;
int4 x1 = 0;
int4 y1 = 0;
};
class StrokePainter {
public:
static constexpr int4 kBandRows = DirtyBands::kBandRows;
StrokePainter() = default;
~StrokePainter();
StrokePainter(const StrokePainter&) = delete;
StrokePainter& operator=(const StrokePainter&) = delete;
void setWorkers(size_t count);
[[nodiscard]] size_t workers() const { return mLanes ? mLanes->laneCount() : 0; }
void stage(
const std::shared_ptr<const DabWork>& work,
Tile& tile,
Layer& layer,
TileCoord coord,
int4 x0,
int4 y0,
int4 x1,
int4 y1,
StrokeMask* mask = nullptr
);
void submit();
void flush();
[[nodiscard]] size_t backlog() const { return mStagedCount + (mLanes ? mLanes->pending() : 0); }
template <typename Fn>
void whilePaused(Fn&& fn) {
if (!mLanes || isPausedHere()) {
fn();
return;
}
auto pause = mLanes->pause();
mPausedBy.store(std::this_thread::get_id());
applyNotes();
fn();
mPausedBy.store(std::thread::id());
}
[[nodiscard]] bool isPausedHere() const { return mPausedBy.load() == std::this_thread::get_id(); }
[[nodiscard]] double takeWorkerMs();
static void paint(const DabWork& work, Tile& tile, TileCoord coord, int4 x0, int4 y0, int4 x1, int4 y1);
static void
paintMasked(const DabWork& work, StrokeMask& mask, Tile& tile, TileCoord coord, int4 x0, int4 y0, int4 x1, int4 y1);
static void paintAtLevel(
const DabWork& work,
int4 level,
Tile& tile,
TileCoord coord,
int4 x0,
int4 y0,
int4 x1,
int4 y1,
StrokeMask* mask = nullptr
);
private:
struct DirtyNote {
Tile* tile;
Layer* layer;
TileCoord coord;
int4 x0;
int4 y0;
int4 x1;
int4 y1;
};
void run(size_t lane, DabJob& job);
void applyNotes();
[[nodiscard]] size_t laneOf(TileCoord coord, int4 bandY) const;
std::unique_ptr<WorkerLanes<DabJob>> mLanes;
std::vector<std::vector<DabJob>> mStaged;
std::vector<std::vector<DirtyNote>> mNotes;
size_t mStagedCount = 0;
std::atomic<int64_t> mWorkerNs{ 0 };
std::atomic<std::thread::id> mPausedBy{};
};
}

View file

@ -0,0 +1,106 @@
#pragma once
#include "Environment.hpp"
#include "Vec.hpp"
#include "Rect.hpp"
#include "Color.hpp"
#include <memory>
namespace tp::sketch2d {
#ifndef SKETCH2D_TILE_SIZE
#define SKETCH2D_TILE_SIZE 128
#endif
constexpr int4 kTileSize = SKETCH2D_TILE_SIZE;
static_assert(
kTileSize >= 64 && (kTileSize & (kTileSize - 1)) == 0, "tile size must be a power of two of at least 64"
);
[[nodiscard]] constexpr int4 floordiv(int4 value, int4 divisor) {
const int4 quotient = value / divisor;
return (value % divisor != 0 && (value < 0) != (divisor < 0)) ? quotient - 1 : quotient;
}
struct TileCoord {
int4 x = 0;
int4 y = 0;
bool operator==(const TileCoord& o) const { return x == o.x && y == o.y; }
bool operator!=(const TileCoord& o) const { return !(*this == o); }
[[nodiscard]] ualni key() const {
const uint4 ux = (uint4) (x + (int4) 0x40000000);
const uint4 uy = (uint4) (y + (int4) 0x40000000);
return ((ualni) ux << 32) | (ualni) uy;
}
};
struct DirtySpan {
int4 x0 = 0;
int4 y0 = 0;
int4 x1 = 0;
int4 y1 = 0;
[[nodiscard]] bool isEmpty() const { return x1 <= x0 || y1 <= y0; }
void include(int4 left, int4 top, int4 right, int4 bottom);
};
class DirtyBands {
public:
static constexpr int4 kBandRows = 32;
static constexpr int4 kBands = kTileSize / kBandRows;
void include(int4 x0, int4 y0, int4 x1, int4 y1);
void include(const DirtyBands& other);
void clear();
[[nodiscard]] bool isEmpty() const;
[[nodiscard]] const DirtySpan& band(int4 index) const { return mBands[(size_t) index]; }
[[nodiscard]] ualni area() const;
private:
DirtySpan mBands[kBands];
};
class Tile {
public:
Tile();
struct Uninitialized {};
explicit Tile(Uninitialized);
~Tile();
Tile(const Tile&) = delete;
Tile& operator=(const Tile&) = delete;
[[nodiscard]] std::shared_ptr<Tile> takeForWriting();
[[nodiscard]] uint1* pixels() { return mPixels; }
[[nodiscard]] const uint1* pixels() const { return mPixels; }
void clear(RGBA color);
[[nodiscard]] bool isTransparent() const;
void markDirty(const RectF& rectInTile);
[[nodiscard]] bool hasDirty() const { return mDirty.z > 0 && mDirty.w > 0; }
[[nodiscard]] const RectF& dirtyRect() const { return mDirty; }
[[nodiscard]] const DirtyBands& compositeBands() const { return mCompositeBands; }
void clearDirty() { mDirty = { 0, 0, 0, 0 }; }
void clearCompositeBands() { mCompositeBands.clear(); }
[[nodiscard]] bool hasMipChanges() const { return mMipChanges.z > 0 && mMipChanges.w > 0; }
[[nodiscard]] const RectF& mipChanges() const { return mMipChanges; }
void clearMipChanges() { mMipChanges = { 0, 0, 0, 0 }; }
[[nodiscard]] ualni gpuId() const { return mGpuId; }
void setGpuId(ualni id) { mGpuId = id; }
private:
uint1* mPixels = nullptr;
RectF mDirty = { 0, 0, 0, 0 };
DirtyBands mCompositeBands;
RectF mMipChanges = { 0, 0, 0, 0 };
ualni mGpuId = 0;
};
}

View file

@ -0,0 +1,673 @@
#include "Session.hpp"
#include <cmath>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
namespace tp::sketch2d {
namespace {
constexpr const char* kLegacyTileMagic = "sketch2d-tiles 1\n";
constexpr const char* kTileMagic = "sketch2d-tiles 2 ";
constexpr const char* kOldTileMagic = "leo-tiles 1\n";
constexpr const char* kOldSizedTileMagic = "leo-tiles 2 ";
constexpr int4 kLegacyTileSize = 256;
size_t tileBytes(int4 size) { return (size_t) size * (size_t) size * 4; }
void placeStoredTile(Layer::State& layer, TileCoord stored, int4 storedSize, const uint1* pixels) {
const int4 originX = stored.x * storedSize;
const int4 originY = stored.y * storedSize;
const int4 tx0 = floordiv(originX, kTileSize);
const int4 ty0 = floordiv(originY, kTileSize);
const int4 tx1 = floordiv(originX + storedSize - 1, kTileSize);
const int4 ty1 = floordiv(originY + storedSize - 1, kTileSize);
for (int4 ty = ty0; ty <= ty1; ty++)
for (int4 tx = tx0; tx <= tx1; tx++) {
const TileCoord coord = { tx, ty };
auto found = layer.tiles.presents(coord.key());
Layer::TilePointer tile = found.isValid() ? layer.tiles.getSlotVal(found).tile : std::make_shared<Tile>();
if (!found.isValid()) layer.tiles.put(coord.key(), { coord, tile });
const int4 x0 = std::max(originX, tx * kTileSize);
const int4 y0 = std::max(originY, ty * kTileSize);
const int4 x1 = std::min(originX + storedSize, (tx + 1) * kTileSize);
const int4 y1 = std::min(originY + storedSize, (ty + 1) * kTileSize);
for (int4 y = y0; y < y1; y++) {
const uint1* source = pixels + ((size_t) (y - originY) * storedSize + (size_t) (x0 - originX)) * 4;
uint1* target =
tile->pixels() + ((size_t) (y - ty * kTileSize) * kTileSize + (size_t) (x0 - tx * kTileSize)) * 4;
std::memcpy(target, source, (size_t) (x1 - x0) * 4);
}
tile->clearDirty();
}
}
void dropTransparentTiles(Layer::State& layer) {
Map<ualni, Layer::TileSlot> kept;
for (auto entry : layer.tiles)
if (!entry->val.tile->isTransparent()) kept.put(entry->key, entry->val);
layer.tiles = kept;
}
std::string quoted(const std::string& text) {
std::string escaped = "\"";
for (char character : text) {
if (character == '"' || character == '\\') escaped += '\\';
escaped += character;
}
return escaped + "\"";
}
std::string readQuoted(std::istringstream& input) {
input >> std::ws;
if (input.peek() != '"') return {};
input.get();
std::string text;
for (char character; input.get(character);) {
if (character == '\\') {
input.get(character);
text += character;
continue;
}
if (character == '"') break;
text += character;
}
return text;
}
std::string number(double value) {
char text[48];
std::snprintf(text, sizeof(text), "%.9g", value);
return text;
}
std::string line(const std::string& key, const std::vector<std::string>& values) {
std::string text = key;
for (const std::string& value : values)
text += " " + value;
return text + "\n";
}
void appendBrush(std::string& text, const Brush& brush) {
text += line("brush.type", { std::to_string((int) brush.type) });
text += line("brush.preset", { quoted(brush.presetName()) });
text += line(
"brush.color", { number(brush.color.r), number(brush.color.g), number(brush.color.b), number(brush.color.a) }
);
text += line("brush.size_limits", { number(brush.minimumSize()), number(brush.maximumSize()) });
text += line("brush.stabilizer", { number(brush.stabilizer()) });
text += line("brush.marker_blend", { std::to_string((int) brush.markerBlend()) });
text += line("brush.linear_mixing", { std::to_string((int) brush.linearMixing()) });
text += line("brush.eraser_mode", { std::to_string((int) brush.eraserMode()) });
for (size_t index = 0; index < kBrushParameterCount; index++) {
const BrushParameter parameter = (BrushParameter) index;
text += line("brush.parameter", { brushParameterInfo(parameter).name, number(brush.get(parameter)) });
}
for (size_t index = 0; index < kPressureTargetCount; index++) {
const PressureTarget target = (PressureTarget) index;
text +=
line("brush.pressure", { pressureTargetName(target), std::to_string((int) brush.usesPressure(target)) });
}
}
void appendSettings(std::string& text, const ViewSettings& settings) {
text += line(
"view.board",
{ number(settings.boardColor.hueDegrees),
number(settings.boardColor.saturation),
number(settings.boardColor.value) }
);
text += line("view.canvas_shadow", { std::to_string((int) settings.canvasShadow) });
text += line("view.stiffness", { number(settings.viewStiffness) });
text += line("view.zoom_limits", { number(settings.zoomInLimit), number(settings.zoomOutLimit) });
text +=
line("view.channel", { std::to_string((int) settings.showChannel), std::to_string((int) settings.channel) });
text += line(
"view.stabilizer", { std::to_string((int) settings.useBrushStabilizer), number(settings.globalStabilizer) }
);
const BrushCursorSettings& cursor = settings.cursor;
text += line(
"view.cursor",
{ std::to_string((int) cursor.style),
std::to_string((int) cursor.contourForEraserAndSmudge),
std::to_string((int) cursor.crosshairForSmallContour),
std::to_string((int) cursor.spotlight),
number(cursor.spotlightColor.hueDegrees),
number(cursor.spotlightColor.saturation),
number(cursor.spotlightColor.value),
number(cursor.spotlightAlpha),
std::to_string((int) cursor.showForPen) }
);
text += line(
"view.line",
{ std::to_string((int) settings.line.holdForLine),
number(settings.line.holdSeconds),
std::to_string((int) settings.line.mode) }
);
const PerspectiveSettings& perspective = settings.perspective;
text += line(
"view.perspective",
{ std::to_string((int) perspective.type),
std::to_string((int) perspective.locked),
std::to_string((int) perspective.placed),
number(perspective.isometricDegrees),
number(perspective.points[0].x),
number(perspective.points[0].y),
number(perspective.points[1].x),
number(perspective.points[1].y),
number(perspective.points[2].x),
number(perspective.points[2].y) }
);
const SymmetrySettings& symmetry = settings.symmetry;
text += line(
"view.symmetry",
{ std::to_string((int) symmetry.enabled),
std::to_string((int) symmetry.visible),
std::to_string((int) symmetry.locked),
number(symmetry.center.x),
number(symmetry.center.y),
number(symmetry.angle) }
);
const GradientSettings& gradient = settings.gradient;
text += line(
"view.gradient",
{ std::to_string((int) gradient.shape),
std::to_string((int) gradient.toTransparent),
std::to_string((int) gradient.eraser) }
);
const BucketSettings& bucket = settings.bucket;
text += line(
"view.bucket",
{ number(bucket.threshold),
std::to_string((int) bucket.onlyAlpha),
std::to_string((int) bucket.allLayers),
std::to_string(bucket.spread) }
);
const PickerSettings& picker = settings.picker;
text += line(
"view.picker",
{ std::to_string((int) picker.sampleAllLayers),
std::to_string((int) picker.sampleOpacity),
std::to_string((int) picker.style) }
);
text += line("view.lock", { std::to_string((int) settings.lockView) });
text += line("view.move", { std::to_string((int) settings.move.duplicate) });
text += line("view.transform", { std::to_string((int) settings.transform.proportional) });
const SelectionSettings& selection = settings.selection;
const BucketSettings& wand = selection.wand;
text += line(
"view.selection",
{ std::to_string((int) selection.marquee),
std::to_string((int) selection.lasso),
std::to_string((int) selection.clickToDeselect),
std::to_string((int) selection.autoFill),
std::to_string(selection.adjustPixels),
number(wand.threshold),
std::to_string((int) wand.onlyAlpha),
std::to_string((int) wand.allLayers),
std::to_string(wand.spread) }
);
}
void appendDocument(std::string& text, const DocumentState& document) {
const CanvasBackground& background = document.background;
text += line(
"document.background",
{ number(background.color.hueDegrees),
number(background.color.saturation),
number(background.color.value),
std::to_string((int) background.transparent) }
);
const CanvasFrame& frame = document.frame;
text += line(
"document.frame",
{ std::to_string(frame.left),
std::to_string(frame.top),
std::to_string(frame.right),
std::to_string(frame.bottom),
std::to_string((int) frame.infinite) }
);
text += line("document.active", { std::to_string(document.active) });
for (const Layer::State& layer : document.layers) {
const Layer::Properties& properties = layer.properties;
text += line(
"layer",
{ std::to_string(layer.id),
quoted(properties.name),
std::to_string((int) properties.visible),
number(properties.opacity),
std::to_string((int) properties.blend),
std::to_string((int) properties.clipped),
std::to_string((int) properties.locked),
std::to_string((int) properties.alphaLocked),
std::to_string((int) properties.selected),
std::to_string(layer.tiles.size()) }
);
}
}
bool readBrushParameter(std::istringstream& fields, Brush& brush) {
std::string name;
halnf value = 0;
fields >> name >> value;
for (size_t index = 0; index < kBrushParameterCount; index++) {
const BrushParameter parameter = (BrushParameter) index;
if (name != brushParameterInfo(parameter).name) continue;
brush.set(parameter, value);
return true;
}
return false;
}
bool readBrushPressure(std::istringstream& fields, Brush& brush) {
std::string name;
int uses = 0;
fields >> name >> uses;
for (size_t index = 0; index < kPressureTargetCount; index++) {
const PressureTarget target = (PressureTarget) index;
if (name != pressureTargetName(target)) continue;
brush.setUsesPressure(target, uses != 0);
return true;
}
return false;
}
std::string layerFile(ualni id) { return "layers/layer-" + std::to_string(id) + ".tiles"; }
std::string readBytes(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary);
std::ostringstream bytes;
bytes << file.rdbuf();
return bytes.str();
}
bool writeBytes(const std::filesystem::path& path, const std::string& bytes) {
std::ofstream file(path, std::ios::binary);
file.write(bytes.data(), (std::streamsize) bytes.size());
return (bool) file;
}
}
SessionSnapshot SessionSnapshot::capture(
const Document& document, const Brush& brush, const ViewSettings& settings, SessionEnvironment environment
) {
SessionSnapshot snapshot;
snapshot.environment = std::move(environment);
snapshot.view = { document.panWorld(), document.zoom(), document.rotation() };
snapshot.brush = brush;
snapshot.settings = settings;
snapshot.document = document.state();
return snapshot;
}
void SessionSnapshot::applyTo(Document& document, Brush& target, ViewSettings& targetSettings) const {
document.load(this->document, "Recorded session");
document.setZoom(view.zoom);
document.setRotation(view.rotation);
document.setPanWorld(view.pan);
target = brush;
targetSettings = settings;
}
std::string SessionPreferences::serialize() const {
std::string text = std::string(SessionSnapshot::kHeader) + "\n";
appendBrush(text, brush);
appendSettings(text, settings);
text += line(
"document.background",
{ number(background.color.hueDegrees),
number(background.color.saturation),
number(background.color.value),
std::to_string((int) background.transparent) }
);
return text;
}
std::optional<SessionPreferences> SessionPreferences::parse(const std::string& text) {
const std::optional<SessionSnapshot> snapshot = SessionSnapshot::parse(text);
if (!snapshot || !snapshot->document.layers.empty()) return std::nullopt;
return SessionPreferences{ snapshot->brush, snapshot->settings, snapshot->document.background };
}
bool saveSessionPreferences(const std::string& path, const SessionPreferences& preferences) {
const std::filesystem::path target(path);
std::error_code error;
std::filesystem::create_directories(target.parent_path(), error);
if (error) return false;
const std::filesystem::path temporary = target.string() + ".tmp";
if (!writeBytes(temporary, preferences.serialize())) return false;
std::filesystem::rename(temporary, target, error);
if (!error) return true;
std::filesystem::remove(temporary, error);
return false;
}
std::string SessionSnapshot::serialize() const {
std::string text = std::string(kHeader) + "\n";
text += line("environment.revision", { quoted(environment.revision) });
text += line("environment.device", { quoted(environment.device) });
text += line("environment.date", { quoted(environment.date) });
text += line("environment.window", { number(environment.windowSize.x), number(environment.windowSize.y) });
text += line("environment.scale", { number(environment.contentScale) });
text += line("view.pan", { number(view.pan.x), number(view.pan.y) });
text += line("view.zoom", { number(view.zoom) });
text += line("view.rotation", { number(view.rotation) });
appendBrush(text, brush);
appendSettings(text, settings);
text += serializeDocument(document);
return text;
}
std::string serializeDocument(const DocumentState& document) {
std::string text;
appendDocument(text, document);
return text;
}
bool parseDocumentLine(const std::string& key, std::istringstream& fields, DocumentState& document) {
int flag = 0;
if (key == "document.background") {
CanvasBackground& background = document.background;
fields >> background.color.hueDegrees >> background.color.saturation >> background.color.value >> flag;
background.transparent = flag != 0;
} else if (key == "document.frame") {
CanvasFrame& frame = document.frame;
fields >> frame.left >> frame.top >> frame.right >> frame.bottom >> flag;
frame.infinite = flag != 0;
} else if (key == "document.active") fields >> document.active;
else if (key == "layer") {
Layer::State layer;
Layer::Properties& properties = layer.properties;
int visible = 1, blend = 0, clipped = 0, locked = 0, alphaLocked = 0, selected = 0;
size_t tiles = 0;
fields >> layer.id;
properties.name = readQuoted(fields);
fields >> visible >> properties.opacity >> blend >> clipped >> locked >> alphaLocked >> selected >> tiles;
properties.visible = visible != 0;
properties.blend = (BlendMode) blend;
properties.clipped = clipped != 0;
properties.locked = locked != 0;
properties.alphaLocked = alphaLocked != 0;
properties.selected = selected != 0;
document.layers.push_back(std::move(layer));
} else return false;
return true;
}
std::string readFileBytes(const std::string& path) { return readBytes(path); }
bool writeFileBytes(const std::string& path, const std::string& bytes) { return writeBytes(path, bytes); }
bool saveLayerTiles(const std::string& directory, const DocumentState& document) {
const std::filesystem::path root(directory);
std::error_code error;
std::filesystem::create_directories(root / "layers", error);
if (error) return false;
bool written = true;
for (const Layer::State& layer : document.layers)
written = written && writeBytes(root / layerFile(layer.id), serializeLayerTiles(layer));
return written;
}
bool loadLayerTiles(const std::string& directory, DocumentState& document) {
const std::filesystem::path root(directory);
for (Layer::State& layer : document.layers) {
if (!parseLayerTiles(readBytes(root / layerFile(layer.id)), layer)) return false;
}
return true;
}
std::optional<SessionSnapshot> SessionSnapshot::parse(const std::string& text) {
std::istringstream input(text);
std::string row;
if (!std::getline(input, row) || (row != kHeader && row != kLegacyHeader)) return std::nullopt;
SessionSnapshot snapshot;
snapshot.document.layers.clear();
Brush& brush = snapshot.brush;
while (std::getline(input, row)) {
if (row.empty()) continue;
std::istringstream fields(row);
std::string key;
fields >> key;
int flag = 0;
int other = 0;
if (key == "environment.revision") snapshot.environment.revision = readQuoted(fields);
else if (key == "environment.device") snapshot.environment.device = readQuoted(fields);
else if (key == "environment.date") snapshot.environment.date = readQuoted(fields);
else if (key == "environment.window")
fields >> snapshot.environment.windowSize.x >> snapshot.environment.windowSize.y;
else if (key == "environment.scale") fields >> snapshot.environment.contentScale;
else if (key == "view.pan") fields >> snapshot.view.pan.x >> snapshot.view.pan.y;
else if (key == "view.zoom") fields >> snapshot.view.zoom;
else if (key == "view.rotation") fields >> snapshot.view.rotation;
else if (key == "brush.type") {
fields >> other;
brush.type = (BrushType) other;
} else if (key == "brush.preset") brush.setPresetName(readQuoted(fields));
else if (key == "brush.color") fields >> brush.color.r >> brush.color.g >> brush.color.b >> brush.color.a;
else if (key == "brush.size_limits") {
halnf minimum = 1;
halnf maximum = Brush::kMaximumSize;
fields >> minimum >> maximum;
brush.setSizeLimits(minimum, maximum);
} else if (key == "brush.stabilizer") {
halnf amount = 0;
fields >> amount;
brush.setStabilizer(amount);
} else if (key == "brush.marker_blend") {
fields >> flag;
brush.setMarkerBlend(flag != 0);
} else if (key == "brush.linear_mixing") {
fields >> flag;
brush.setLinearMixing(flag != 0);
} else if (key == "brush.eraser_mode") {
fields >> flag;
brush.setEraserMode(flag != 0);
} else if (key == "brush.parameter") {
if (!readBrushParameter(fields, brush)) return std::nullopt;
} else if (key == "brush.pressure") {
if (!readBrushPressure(fields, brush)) return std::nullopt;
} else if (key == "view.board") {
HueColor& board = snapshot.settings.boardColor;
fields >> board.hueDegrees >> board.saturation >> board.value;
} else if (key == "view.canvas_shadow") {
fields >> flag;
snapshot.settings.canvasShadow = flag != 0;
} else if (key == "view.stiffness") fields >> snapshot.settings.viewStiffness;
else if (key == "view.zoom_limits") fields >> snapshot.settings.zoomInLimit >> snapshot.settings.zoomOutLimit;
else if (key == "view.channel") {
fields >> flag >> other;
snapshot.settings.showChannel = flag != 0;
snapshot.settings.channel = (ViewChannel) other;
} else if (key == "view.cursor") {
BrushCursorSettings& cursor = snapshot.settings.cursor;
int4 style = 0;
int4 eraser = 1;
int4 small = 1;
int4 spotlight = 0;
int4 pen = 1;
fields >> style >> eraser >> small >> spotlight >> cursor.spotlightColor.hueDegrees >>
cursor.spotlightColor.saturation >> cursor.spotlightColor.value >> cursor.spotlightAlpha >> pen;
if (style < 0 || style >= kBrushCursorStyleCount) return std::nullopt;
cursor.style = (BrushCursorStyle) style;
cursor.contourForEraserAndSmudge = eraser != 0;
cursor.crosshairForSmallContour = small != 0;
cursor.spotlight = spotlight != 0;
cursor.showForPen = pen != 0;
} else if (key == "view.line") {
int4 hold = 1;
halnf seconds = 0.5f;
int4 mode = 0;
fields >> hold >> seconds;
if (!(fields >> mode)) mode = 0;
if (seconds < 0.05f || seconds > 5 || mode < 0 || mode > (int4) LineMode::Perspective) return std::nullopt;
snapshot.settings.line = { hold != 0, seconds, (LineMode) mode };
} else if (key == "view.perspective") {
PerspectiveSettings& perspective = snapshot.settings.perspective;
int4 type = 0, locked = 0, placed = 0;
fields >> type >> locked >> placed >> perspective.isometricDegrees;
for (Vec2F& point : perspective.points)
fields >> point.x >> point.y;
if (type < 0 || type > (int4) PerspectiveType::Radial || !std::isfinite(perspective.isometricDegrees))
return std::nullopt;
perspective.type = (PerspectiveType) type;
perspective.locked = locked != 0;
perspective.placed = placed != 0;
} else if (key == "view.symmetry") {
SymmetrySettings& symmetry = snapshot.settings.symmetry;
int4 enabled = 0, visible = 1, locked = 0;
fields >> enabled >> visible >> locked >> symmetry.center.x >> symmetry.center.y >> symmetry.angle;
if (!std::isfinite(symmetry.center.x) || !std::isfinite(symmetry.center.y) || !std::isfinite(symmetry.angle))
return std::nullopt;
symmetry.enabled = enabled != 0;
symmetry.visible = visible != 0;
symmetry.locked = locked != 0;
} else if (key == "view.gradient") {
int4 shape = 0;
int4 transparent = 0;
int4 eraser = 0;
fields >> shape >> transparent >> eraser;
if (shape < 0 || shape > (int4) GradientShape::Radial) return std::nullopt;
snapshot.settings.gradient = { (GradientShape) shape, transparent != 0, eraser != 0 };
} else if (key == "view.bucket") {
BucketSettings& bucket = snapshot.settings.bucket;
int4 alpha = 0;
int4 all = 1;
fields >> bucket.threshold >> alpha >> all >> bucket.spread;
if (bucket.threshold < 0 || bucket.threshold > 1 || bucket.spread < 0 || bucket.spread > 64)
return std::nullopt;
bucket.onlyAlpha = alpha != 0;
bucket.allLayers = all != 0;
} else if (key == "view.picker") {
int4 all = 1;
int4 opacity = 0;
int4 style = 1;
fields >> all >> opacity >> style;
if (style < 0 || style > (int4) PickerStyle::Magnifier) return std::nullopt;
snapshot.settings.picker = { all != 0, opacity != 0, (PickerStyle) style };
} else if (key == "view.lock") {
fields >> flag;
snapshot.settings.lockView = flag != 0;
} else if (key == "view.move") {
fields >> flag;
snapshot.settings.move.duplicate = flag != 0;
} else if (key == "view.transform") {
fields >> flag;
snapshot.settings.transform.proportional = flag != 0;
} else if (key == "view.selection") {
SelectionSettings& selection = snapshot.settings.selection;
BucketSettings& wand = selection.wand;
int4 marquee = 0;
int4 lasso = 0;
int4 click = 1;
int4 fill = 0;
int4 alpha = 0;
int4 all = 1;
fields >> marquee >> lasso >> click >> fill >> selection.adjustPixels >> wand.threshold >> alpha >> all >>
wand.spread;
if (
marquee < 0 || marquee > (int4) MarqueeShape::Ellipse || lasso < 0 || lasso > (int4) LassoShape::Polygon ||
selection.adjustPixels < 0 || selection.adjustPixels > 64 || wand.threshold < 0 || wand.threshold > 1 ||
wand.spread < 0 || wand.spread > 64
)
return std::nullopt;
selection.marquee = (MarqueeShape) marquee;
selection.lasso = (LassoShape) lasso;
selection.clickToDeselect = click != 0;
selection.autoFill = fill != 0;
wand.onlyAlpha = alpha != 0;
wand.allLayers = all != 0;
} else if (key == "view.stabilizer") {
fields >> flag >> snapshot.settings.globalStabilizer;
snapshot.settings.useBrushStabilizer = flag != 0;
} else if (!parseDocumentLine(key, fields, snapshot.document)) return std::nullopt;
if (fields.fail()) return std::nullopt;
}
return snapshot;
}
std::string serializeLayerTiles(const Layer::State& layer) {
std::string bytes = std::string(kTileMagic) + std::to_string(kTileSize) + "\n";
for (auto entry : layer.tiles) {
const Layer::TileSlot& slot = entry->val;
if (!slot.tile) continue;
const int32_t coordinates[2] = { slot.coord.x, slot.coord.y };
bytes.append(reinterpret_cast<const char*>(coordinates), sizeof(coordinates));
bytes.append(reinterpret_cast<const char*>(slot.tile->pixels()), tileBytes(kTileSize));
}
return bytes;
}
bool parseLayerTiles(const std::string& bytes, Layer::State& layer) {
size_t header = 0;
int4 storedSize = 0;
const auto startsWith = [&bytes](const char* magic) { return bytes.compare(0, std::strlen(magic), magic) == 0; };
const char* sized = startsWith(kTileMagic) ? kTileMagic
: startsWith(kOldSizedTileMagic) ? kOldSizedTileMagic
: nullptr;
if (startsWith(kLegacyTileMagic) || startsWith(kOldTileMagic)) {
header = std::strlen(startsWith(kOldTileMagic) ? kOldTileMagic : kLegacyTileMagic);
storedSize = kLegacyTileSize;
} else if (sized) {
const size_t end = bytes.find('\n');
if (end == std::string::npos) return false;
storedSize = std::atoi(bytes.substr(std::strlen(sized), end - std::strlen(sized)).c_str());
header = end + 1;
} else {
return false;
}
if (storedSize <= 0) return false;
const size_t record = sizeof(int32_t) * 2 + tileBytes(storedSize);
if ((bytes.size() - header) % record != 0) return false;
for (size_t offset = header; offset < bytes.size(); offset += record) {
int32_t coordinates[2] = {};
std::memcpy(coordinates, bytes.data() + offset, sizeof(coordinates));
const uint1* pixels = reinterpret_cast<const uint1*>(bytes.data() + offset + sizeof(coordinates));
placeStoredTile(layer, { coordinates[0], coordinates[1] }, storedSize, pixels);
}
if (storedSize != kTileSize) dropTransparentTiles(layer);
return true;
}
bool saveSessionSnapshot(const std::string& directory, const SessionSnapshot& snapshot) {
if (!saveLayerTiles(directory, snapshot.document)) return false;
return writeBytes(std::filesystem::path(directory) / "session.txt", snapshot.serialize());
}
bool RecordedSession::save(const std::string& directory) const {
const std::filesystem::path root(directory);
bool written = saveSessionSnapshot(directory, snapshot);
written = written && input.save((root / "input.txt").string());
if (!recordedFrames.empty()) written = written && writeBytes(root / "recorded-frames.tsv", recordedFrames);
return written;
}
std::optional<RecordedSession> RecordedSession::load(const std::string& directory) {
const std::filesystem::path root(directory);
std::optional<SessionSnapshot> snapshot = SessionSnapshot::parse(readBytes(root / "session.txt"));
std::optional<InputRecording> input = InputRecording::load((root / "input.txt").string());
if (!snapshot || !input) return std::nullopt;
if (!loadLayerTiles(directory, snapshot->document)) return std::nullopt;
RecordedSession session;
session.snapshot = std::move(*snapshot);
session.input = std::move(*input);
if (std::filesystem::exists(root / "recorded-frames.tsv"))
session.recordedFrames = readBytes(root / "recorded-frames.tsv");
return session;
}
}

View file

@ -0,0 +1,350 @@
#include "SketchLibrary.hpp"
#include "LayerThumbnail.hpp"
#include "Session.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <sstream>
namespace tp::sketch2d {
namespace {
constexpr const char* kDocumentFile = "sketch.txt";
constexpr const char* kPreviewFile = "preview.rgba";
constexpr const char* kPreviewHeader = "sketch2d-preview 1\n";
constexpr const char* kLegacyPreviewHeader = "leo-preview 1\n";
constexpr const char* kOrderFile = "order.txt";
std::filesystem::path pathOf(const std::string& directory, const char* file) {
return std::filesystem::path(directory) / file;
}
void blendOver(uint1* target, const uint1* source, halnf opacity) {
const halnf alpha = (halnf) source[3] / 255.0f * opacity;
if (alpha <= 0) return;
const halnf targetAlpha = (halnf) target[3] / 255.0f;
const halnf outAlpha = alpha + targetAlpha * (1 - alpha);
for (int4 channel = 0; channel < 3; channel++) {
const halnf mixed =
((halnf) source[channel] * alpha + (halnf) target[channel] * targetAlpha * (1 - alpha)) / outAlpha;
target[channel] = (uint1) std::lround(std::clamp(mixed, 0.0f, 255.0f));
}
target[3] = (uint1) std::lround(outAlpha * 255.0f);
}
void fillBackground(std::vector<uint1>& pixels, const CanvasBackground& background) {
const RGBA color = background.rgba();
for (size_t offset = 0; offset < pixels.size(); offset += 4) {
pixels[offset + 0] = (uint1) std::lround(color.r * 255.0f);
pixels[offset + 1] = (uint1) std::lround(color.g * 255.0f);
pixels[offset + 2] = (uint1) std::lround(color.b * 255.0f);
pixels[offset + 3] = (uint1) std::lround(color.a * 255.0f);
}
}
bool isValidNameCharacter(char character) {
return std::isalnum((unsigned char) character) || character == ' ' || character == '-' || character == '_';
}
}
SketchPreview SketchPreview::render(const Document& document) {
document.settlePaint();
SketchPreview preview;
preview.rgba.assign((size_t) kSize * kSize * 4, 0);
fillBackground(preview.rgba, document.background());
const RectF bounds = LayerThumbnail::documentBounds(document);
for (const auto& layer : document.layers()) {
if (!layer->properties().visible) continue;
const std::vector<uint1> pixels = LayerThumbnail::render(*layer, bounds, kSize);
for (size_t offset = 0; offset < pixels.size(); offset += 4)
blendOver(preview.rgba.data() + offset, pixels.data() + offset, layer->properties().opacity);
}
return preview;
}
SketchFile SketchFile::capture(const Document& document) {
document.settlePaint();
return { document.state(),
SketchPreview::render(document),
SessionView{ document.panWorld(), document.zoom(), document.rotation() } };
}
bool SketchFile::save(const std::string& directory) const {
if (!saveLayerTiles(directory, document)) return false;
std::string text = std::string(kHeader) + "\n" + serializeDocument(document);
if (view) {
std::ostringstream line;
line << "view.transform " << view->pan.x << " " << view->pan.y << " " << view->zoom << " " << view->rotation
<< "\n";
text += line.str();
}
bool written = writeFileBytes(pathOf(directory, kDocumentFile).string(), text);
if (!preview.isEmpty()) {
const std::string bytes(reinterpret_cast<const char*>(preview.rgba.data()), preview.rgba.size());
written = written && writeFileBytes(pathOf(directory, kPreviewFile).string(), kPreviewHeader + bytes);
}
return written;
}
std::optional<SketchFile> SketchFile::load(const std::string& directory) {
std::istringstream input(readFileBytes(pathOf(directory, kDocumentFile).string()));
std::string row;
if (!std::getline(input, row) || (row != kHeader && row != kLegacyHeader)) return std::nullopt;
SketchFile sketch;
while (std::getline(input, row)) {
if (row.empty()) continue;
std::istringstream fields(row);
std::string key;
fields >> key;
if (key == "view.transform") {
SessionView view;
fields >> view.pan.x >> view.pan.y >> view.zoom >> view.rotation;
if (!fields.fail()) sketch.view = view;
continue;
}
if (!parseDocumentLine(key, fields, sketch.document) || fields.fail()) return std::nullopt;
}
if (sketch.document.layers.empty()) return std::nullopt;
if (!loadLayerTiles(directory, sketch.document)) return std::nullopt;
if (std::optional<SketchPreview> preview = loadPreview(directory)) sketch.preview = std::move(*preview);
return sketch;
}
std::optional<SketchPreview> SketchFile::loadPreview(const std::string& directory) {
const std::string bytes = readFileBytes(pathOf(directory, kPreviewFile).string());
const bool legacy = bytes.compare(0, std::string(kLegacyPreviewHeader).size(), kLegacyPreviewHeader) == 0;
const size_t header = std::string(legacy ? kLegacyPreviewHeader : kPreviewHeader).size();
if (bytes.size() <= header || (!legacy && bytes.compare(0, header, kPreviewHeader) != 0)) return std::nullopt;
const size_t pixels = (bytes.size() - header) / 4;
const int4 size = (int4) std::lround(std::sqrt((double) pixels));
if (size <= 0 || (size_t) size * size * 4 != bytes.size() - header) return std::nullopt;
SketchPreview preview;
preview.size = size;
preview.rgba.assign(bytes.begin() + (std::ptrdiff_t) header, bytes.end());
return preview;
}
SketchLibrary::SketchLibrary(std::string root) :
mRoot(std::move(root)) {}
AppStorage SketchLibrary::applicationStorage() { return AppStorage::inHome("sketch2d"); }
std::string SketchLibrary::defaultRoot() { return rootIn(applicationStorage()); }
std::string SketchLibrary::rootIn(const AppStorage& storage) { return storage.path(kSketchesFolder); }
std::string SketchLibrary::sanitizedName(const std::string& name) {
std::string result;
for (char character : name)
result += isValidNameCharacter(character) ? character : '_';
const size_t first = result.find_first_not_of(' ');
const size_t last = result.find_last_not_of(' ');
if (first == std::string::npos) return "Sketch";
return result.substr(first, last - first + 1);
}
std::string SketchLibrary::sanitizedPath(const std::string& path) {
std::string result;
std::istringstream parts(path);
std::string part;
while (std::getline(parts, part, '/')) {
if (part.empty()) continue;
result = joined(result, sanitizedName(part));
}
return result;
}
std::string SketchLibrary::parentOf(const std::string& path) {
const size_t slash = path.rfind('/');
return slash == std::string::npos ? std::string() : path.substr(0, slash);
}
std::string SketchLibrary::nameOf(const std::string& path) {
const size_t slash = path.rfind('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
std::string SketchLibrary::joined(const std::string& folder, const std::string& name) {
return folder.empty() ? name : folder + "/" + name;
}
std::string SketchLibrary::directoryOf(const std::string& path) const {
return (std::filesystem::path(mRoot) / sanitizedPath(path)).string();
}
bool SketchLibrary::contains(const std::string& path) const {
return std::filesystem::exists(pathOf(directoryOf(path), kDocumentFile));
}
bool SketchLibrary::isFolder(const std::string& path) const {
std::error_code error;
return std::filesystem::is_directory(directoryOf(path), error) && !contains(path);
}
std::string SketchLibrary::unusedPath(const std::string& folder, const std::string& base) const {
for (int4 index = 1;; index++) {
const std::string candidate = joined(folder, base + " " + std::to_string(index));
std::error_code error;
if (!std::filesystem::exists(directoryOf(candidate), error)) return candidate;
}
}
std::string SketchLibrary::unusedName(const std::string& base) const { return unusedPath(kScratchFolder, base); }
void SketchLibrary::ensureScratch() const {
std::error_code error;
const std::filesystem::path scratch = directoryOf(kScratchFolder);
std::filesystem::create_directories(scratch, error);
for (const auto& item : std::filesystem::directory_iterator(mRoot, error)) {
if (!item.is_directory() || !std::filesystem::exists(item.path() / kDocumentFile)) continue;
const std::string name = item.path().filename().string();
if (name.ends_with(".saving")) continue;
const bool taken = std::filesystem::exists(scratch / name);
const std::string target = taken ? nameOf(unusedPath(kScratchFolder, name)) : name;
std::filesystem::rename(item.path(), scratch / target, error);
}
}
std::vector<std::string> SketchLibrary::storedOrder(const std::string& folder) const {
std::istringstream input(readFileBytes(pathOf(directoryOf(folder), kOrderFile).string()));
std::vector<std::string> names;
std::string name;
while (std::getline(input, name))
if (!name.empty()) names.push_back(name);
return names;
}
std::vector<LibraryItem> SketchLibrary::children(const std::string& folder) const {
std::vector<LibraryItem> items;
std::error_code error;
for (const auto& entry : std::filesystem::directory_iterator(directoryOf(folder), error)) {
const std::string name = entry.path().filename().string();
if (!entry.is_directory() || name.ends_with(".saving")) continue;
const std::string path = joined(folder, name);
if (!std::filesystem::exists(entry.path() / kDocumentFile)) {
items.push_back({ LibraryItem::Kind::Folder, name, path, entry.path().string(), 0 });
continue;
}
const auto written = std::filesystem::last_write_time(entry.path() / kDocumentFile, error);
const auto seconds = std::chrono::duration_cast<std::chrono::seconds>(written.time_since_epoch()).count();
items.push_back({ LibraryItem::Kind::Sketch, name, path, entry.path().string(), (int8) seconds });
}
const std::vector<std::string> order = storedOrder(folder);
const auto rank = [&order](const LibraryItem& item) {
const auto found = std::find(order.begin(), order.end(), item.name);
return found == order.end() ? order.size() : (size_t) (found - order.begin());
};
std::sort(items.begin(), items.end(), [&](const LibraryItem& left, const LibraryItem& right) {
if (rank(left) != rank(right)) return rank(left) < rank(right);
if (left.kind != right.kind) return left.kind == LibraryItem::Kind::Folder;
if (left.modifiedSeconds != right.modifiedSeconds) return left.modifiedSeconds > right.modifiedSeconds;
return left.name < right.name;
});
return items;
}
std::vector<SketchEntry> SketchLibrary::list() const {
std::vector<SketchEntry> entries;
std::vector<std::string> folders = { "" };
while (!folders.empty()) {
const std::string folder = folders.back();
folders.pop_back();
for (const LibraryItem& item : children(folder)) {
if (item.kind == LibraryItem::Kind::Folder) folders.push_back(item.path);
else entries.push_back({ item.name, item.directory, item.modifiedSeconds, item.path });
}
}
std::sort(entries.begin(), entries.end(), [](const SketchEntry& left, const SketchEntry& right) {
if (left.modifiedSeconds != right.modifiedSeconds) return left.modifiedSeconds > right.modifiedSeconds;
return left.path < right.path;
});
return entries;
}
bool SketchLibrary::save(const std::string& path, const Document& document) const {
return save(path, SketchFile::capture(document));
}
bool SketchLibrary::save(const std::string& path, const SketchFile& file) const {
const std::filesystem::path target = directoryOf(path);
const std::filesystem::path staging = target.string() + ".saving";
std::error_code error;
std::filesystem::create_directories(target.parent_path(), error);
std::filesystem::remove_all(staging, error);
if (!file.save(staging.string())) {
std::filesystem::remove_all(staging, error);
return false;
}
std::filesystem::remove_all(target, error);
std::filesystem::rename(staging, target, error);
return !error;
}
bool SketchLibrary::open(const std::string& path, Document& document) const {
std::optional<SketchFile> sketch = SketchFile::load(directoryOf(path));
if (!sketch) return false;
document.load(sketch->document, "Open " + nameOf(sanitizedPath(path)));
document.resetView();
if (sketch->view) {
document.setZoom(sketch->view->zoom);
document.setRotation(sketch->view->rotation);
document.setPanWorld(sketch->view->pan);
}
return true;
}
bool SketchLibrary::remove(const std::string& path) const {
if (sanitizedPath(path).empty() || sanitizedPath(path) == kScratchFolder) return false;
std::error_code error;
return std::filesystem::remove_all(directoryOf(path), error) > 0 && !error;
}
bool SketchLibrary::createFolder(const std::string& path) const {
std::error_code error;
if (std::filesystem::exists(directoryOf(path), error)) return false;
return std::filesystem::create_directories(directoryOf(path), error) && !error;
}
bool SketchLibrary::move(const std::string& path, const std::string& folder) const {
const std::string source = sanitizedPath(path);
const std::string destination = sanitizedPath(folder);
if (source.empty() || source == kScratchFolder || parentOf(source) == destination) return false;
if (destination == source || destination.starts_with(source + "/")) return false;
if (!destination.empty() && !isFolder(destination)) return false;
const std::string target = joined(destination, nameOf(source));
std::error_code error;
if (std::filesystem::exists(directoryOf(target), error)) return false;
std::filesystem::rename(directoryOf(source), directoryOf(target), error);
return !error;
}
bool SketchLibrary::rename(const std::string& path, const std::string& name) const {
const std::string source = sanitizedPath(path);
if (source.empty() || source == kScratchFolder) return false;
const std::string target = joined(parentOf(source), sanitizedName(name));
if (target == source) return true;
std::error_code error;
if (std::filesystem::exists(directoryOf(target), error)) return false;
std::filesystem::rename(directoryOf(source), directoryOf(target), error);
return !error;
}
bool SketchLibrary::reorder(const std::string& folder, const std::vector<std::string>& names) const {
std::string text;
for (const std::string& name : names)
text += name + "\n";
return writeFileBytes(pathOf(directoryOf(folder), kOrderFile).string(), text);
}
}

View file

@ -0,0 +1,69 @@
#pragma once
#include "ViewSettings.hpp"
#include "Document.hpp"
#include "InputRecording.hpp"
#include "SessionRecording.hpp"
#include <optional>
#include <sstream>
#include <string>
namespace tp::sketch2d {
struct SessionView {
Vec2F pan = { 0, 0 };
halnf zoom = 1;
halnf rotation = 0;
};
struct SessionSnapshot {
static constexpr const char* kHeader = "sketch2d-session 1";
static constexpr const char* kLegacyHeader = "leo-session 1";
SessionEnvironment environment;
SessionView view;
Brush brush;
ViewSettings settings;
DocumentState document;
[[nodiscard]] static SessionSnapshot
capture(const Document& document, const Brush& brush, const ViewSettings& settings, SessionEnvironment environment);
void applyTo(Document& document, Brush& brush, ViewSettings& settings) const;
[[nodiscard]] std::string serialize() const;
[[nodiscard]] static std::optional<SessionSnapshot> parse(const std::string& text);
};
struct SessionPreferences {
Brush brush;
ViewSettings settings;
CanvasBackground background;
[[nodiscard]] std::string serialize() const;
[[nodiscard]] static std::optional<SessionPreferences> parse(const std::string& text);
};
bool saveSessionPreferences(const std::string& path, const SessionPreferences& preferences);
[[nodiscard]] std::string serializeLayerTiles(const Layer::State& layer);
[[nodiscard]] bool parseLayerTiles(const std::string& bytes, Layer::State& layer);
[[nodiscard]] std::string serializeDocument(const DocumentState& document);
[[nodiscard]] bool parseDocumentLine(const std::string& key, std::istringstream& fields, DocumentState& document);
bool saveLayerTiles(const std::string& directory, const DocumentState& document);
bool loadLayerTiles(const std::string& directory, DocumentState& document);
[[nodiscard]] std::string readFileBytes(const std::string& path);
bool writeFileBytes(const std::string& path, const std::string& bytes);
struct RecordedSession {
SessionSnapshot snapshot;
InputRecording input;
std::string recordedFrames;
bool save(const std::string& directory) const;
[[nodiscard]] static std::optional<RecordedSession> load(const std::string& directory);
};
bool saveSessionSnapshot(const std::string& directory, const SessionSnapshot& snapshot);
}

View file

@ -0,0 +1,95 @@
#pragma once
#include "AppStorage.hpp"
#include "Document.hpp"
#include "Session.hpp"
#include <optional>
#include <string>
#include <vector>
namespace tp::sketch2d {
struct SketchPreview {
static constexpr int4 kSize = 256;
int4 size = kSize;
std::vector<uint1> rgba;
[[nodiscard]] bool isEmpty() const { return rgba.empty(); }
[[nodiscard]] static SketchPreview render(const Document& document);
};
struct SketchFile {
static constexpr const char* kHeader = "sketch2d-sketch 1";
static constexpr const char* kLegacyHeader = "leo-sketch 1";
DocumentState document;
SketchPreview preview;
std::optional<SessionView> view;
[[nodiscard]] static SketchFile capture(const Document& document);
bool save(const std::string& directory) const;
[[nodiscard]] static std::optional<SketchFile> load(const std::string& directory);
[[nodiscard]] static std::optional<SketchPreview> loadPreview(const std::string& directory);
};
struct SketchEntry {
std::string name;
std::string directory;
int8 modifiedSeconds = 0;
std::string path;
};
struct LibraryItem {
enum class Kind { Folder, Sketch };
Kind kind = Kind::Sketch;
std::string name;
std::string path;
std::string directory;
int8 modifiedSeconds = 0;
};
class SketchLibrary {
public:
static constexpr const char* kScratchFolder = "Scratch";
static constexpr const char* kSketchesFolder = "sketches";
explicit SketchLibrary(std::string root);
[[nodiscard]] const std::string& root() const { return mRoot; }
[[nodiscard]] std::vector<SketchEntry> list() const;
[[nodiscard]] std::vector<LibraryItem> children(const std::string& folder) const;
[[nodiscard]] std::string directoryOf(const std::string& path) const;
[[nodiscard]] bool contains(const std::string& path) const;
[[nodiscard]] bool isFolder(const std::string& path) const;
[[nodiscard]] std::string unusedName(const std::string& base = "Sketch") const;
[[nodiscard]] std::string unusedPath(const std::string& folder, const std::string& base) const;
bool save(const std::string& path, const Document& document) const;
bool save(const std::string& path, const SketchFile& file) const;
bool open(const std::string& path, Document& document) const;
bool remove(const std::string& path) const;
bool createFolder(const std::string& path) const;
bool move(const std::string& path, const std::string& folder) const;
bool rename(const std::string& path, const std::string& name) const;
bool reorder(const std::string& folder, const std::vector<std::string>& names) const;
void ensureScratch() const;
[[nodiscard]] static AppStorage applicationStorage();
[[nodiscard]] static std::string defaultRoot();
[[nodiscard]] static std::string rootIn(const AppStorage& storage);
[[nodiscard]] static std::string sanitizedName(const std::string& name);
[[nodiscard]] static std::string sanitizedPath(const std::string& path);
[[nodiscard]] static std::string parentOf(const std::string& path);
[[nodiscard]] static std::string nameOf(const std::string& path);
[[nodiscard]] static std::string joined(const std::string& folder, const std::string& name);
private:
[[nodiscard]] std::vector<std::string> storedOrder(const std::string& folder) const;
std::string mRoot;
};
}

Some files were not shown because too many files have changed in this diff Show more