Sketch2D: tablet painting application #17
|
|
@ -38,3 +38,4 @@ add_subdirectory(3DScene)
|
|||
add_subdirectory(RayTracer)
|
||||
add_subdirectory(Sketch3D)
|
||||
add_subdirectory(3DEditor)
|
||||
add_subdirectory(Sketch2D)
|
||||
|
|
|
|||
25
Modules/private/AppStorage.cpp
Normal 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();
|
||||
}
|
||||
}
|
||||
23
Modules/public/AppStorage.hpp
Normal 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
|
|
@ -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 ()
|
||||
|
||||
132
Sketch2D/bench/BenchInput.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
22
Sketch2D/bench/BenchInput.hpp
Normal 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);
|
||||
}
|
||||
312
Sketch2D/bench/BenchMain.cpp
Normal 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;
|
||||
}
|
||||
587
Sketch2D/bench/BenchReport.cpp
Normal 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, §ionMetrics, &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;
|
||||
}
|
||||
}
|
||||
137
Sketch2D/bench/BenchReport.hpp
Normal 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
|
||||
);
|
||||
}
|
||||
262
Sketch2D/bench/BenchRunner.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
41
Sketch2D/bench/BenchRunner.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
149
Sketch2D/bench/BenchScenario.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
36
Sketch2D/bench/BenchScenario.hpp
Normal 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
|
|
@ -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).
|
||||
43
Sketch2D/rsc/icons/LICENSE-lucide.txt
Normal 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.
|
||||
BIN
Sketch2D/rsc/icons/brush.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
Sketch2D/rsc/icons/color.png
Normal file
|
After Width: | Height: | Size: 755 B |
BIN
Sketch2D/rsc/icons/crop.png
Normal file
|
After Width: | Height: | Size: 917 B |
BIN
Sketch2D/rsc/icons/edit.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
Sketch2D/rsc/icons/eraser.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
Sketch2D/rsc/icons/export.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Sketch2D/rsc/icons/eyedropper.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Sketch2D/rsc/icons/fill.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Sketch2D/rsc/icons/gradient.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Sketch2D/rsc/icons/hand.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
Sketch2D/rsc/icons/history.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
Sketch2D/rsc/icons/lasso.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
Sketch2D/rsc/icons/layers.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
Sketch2D/rsc/icons/library.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
Sketch2D/rsc/icons/line.png
Normal file
|
After Width: | Height: | Size: 781 B |
BIN
Sketch2D/rsc/icons/menu.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
Sketch2D/rsc/icons/move.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Sketch2D/rsc/icons/rectselect.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Sketch2D/rsc/icons/redo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
Sketch2D/rsc/icons/rotate.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
Sketch2D/rsc/icons/select.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Sketch2D/rsc/icons/settings.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
Sketch2D/rsc/icons/size.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
Sketch2D/rsc/icons/smudge.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Sketch2D/rsc/icons/tool.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
Sketch2D/rsc/icons/transform.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Sketch2D/rsc/icons/undo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
Sketch2D/rsc/icons/view.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Sketch2D/rsc/icons/wand.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Sketch2D/rsc/icons/zoom.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
23
Sketch2D/src/apps/AndroidEntry.cpp
Normal 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);
|
||||
}
|
||||
162
Sketch2D/src/apps/AndroidUpdater.cpp
Normal 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); }
|
||||
}
|
||||
26
Sketch2D/src/apps/AndroidUpdater.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
11
Sketch2D/src/apps/Entry.cpp
Normal 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;
|
||||
}
|
||||
61
Sketch2D/src/libs/CMakeLists.txt
Normal 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 ()
|
||||
120
Sketch2D/src/libs/app/private/Sketch2DApplication.cpp
Normal 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();
|
||||
}
|
||||
}
|
||||
67
Sketch2D/src/libs/app/public/Sketch2DApplication.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
510
Sketch2D/src/libs/brush/private/Brush.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
185
Sketch2D/src/libs/brush/private/DabMask.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
262
Sketch2D/src/libs/brush/public/Brush.hpp
Normal 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);
|
||||
}
|
||||
74
Sketch2D/src/libs/brush/public/DabMask.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
53
Sketch2D/src/libs/common/private/ChannelView.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
152
Sketch2D/src/libs/common/private/Stabilizer.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
128
Sketch2D/src/libs/common/private/StrokePath.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
100
Sketch2D/src/libs/common/private/ThreadPlacement.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
22
Sketch2D/src/libs/common/public/ChannelView.hpp
Normal 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);
|
||||
}
|
||||
129
Sketch2D/src/libs/common/public/History.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
28
Sketch2D/src/libs/common/public/Profiling.hpp
Normal 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)
|
||||
57
Sketch2D/src/libs/common/public/Stabilizer.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
53
Sketch2D/src/libs/common/public/StrokePath.hpp
Normal 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 };
|
||||
};
|
||||
}
|
||||
27
Sketch2D/src/libs/common/public/ThreadPlacement.hpp
Normal 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();
|
||||
};
|
||||
}
|
||||
57
Sketch2D/src/libs/document/private/BrushCursor.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
48
Sketch2D/src/libs/document/private/CanvasFrameGeometry.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
21
Sketch2D/src/libs/document/private/Checkerboard.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
202
Sketch2D/src/libs/document/private/ColorModel.cpp
Normal 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());
|
||||
}
|
||||
}
|
||||
508
Sketch2D/src/libs/document/private/Compositor.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
2728
Sketch2D/src/libs/document/private/Document.cpp
Normal file
119
Sketch2D/src/libs/document/private/LayerThumbnail.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
154
Sketch2D/src/libs/document/private/StrokeTrace.cpp
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
49
Sketch2D/src/libs/document/public/BrushCursor.hpp
Normal 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
|
||||
);
|
||||
};
|
||||
}
|
||||
22
Sketch2D/src/libs/document/public/CanvasFrameGeometry.hpp
Normal 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);
|
||||
}
|
||||
20
Sketch2D/src/libs/document/public/Checkerboard.hpp
Normal 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);
|
||||
}
|
||||
89
Sketch2D/src/libs/document/public/ColorModel.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
104
Sketch2D/src/libs/document/public/Compositor.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
417
Sketch2D/src/libs/document/public/Document.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
19
Sketch2D/src/libs/document/public/LayerThumbnail.hpp
Normal 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);
|
||||
};
|
||||
}
|
||||
86
Sketch2D/src/libs/document/public/StrokeTrace.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
103
Sketch2D/src/libs/document/public/ViewSettings.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
240
Sketch2D/src/libs/engine/private/Engine.cpp
Normal 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();
|
||||
}
|
||||
}
|
||||
118
Sketch2D/src/libs/engine/private/Layer.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
318
Sketch2D/src/libs/engine/private/LayerLevels.cpp
Normal 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];
|
||||
}
|
||||
}
|
||||
351
Sketch2D/src/libs/engine/private/Selection.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
268
Sketch2D/src/libs/engine/private/StrokePainter.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
139
Sketch2D/src/libs/engine/private/Tile.cpp
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
111
Sketch2D/src/libs/engine/public/Engine.hpp
Normal 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{};
|
||||
};
|
||||
}
|
||||
135
Sketch2D/src/libs/engine/public/Layer.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
102
Sketch2D/src/libs/engine/public/LayerLevels.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
51
Sketch2D/src/libs/engine/public/Selection.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
33
Sketch2D/src/libs/engine/public/StrokeMask.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
125
Sketch2D/src/libs/engine/public/StrokePainter.hpp
Normal 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{};
|
||||
};
|
||||
}
|
||||
106
Sketch2D/src/libs/engine/public/Tile.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
673
Sketch2D/src/libs/library/private/Session.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
350
Sketch2D/src/libs/library/private/SketchLibrary.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
69
Sketch2D/src/libs/library/public/Session.hpp
Normal 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);
|
||||
}
|
||||
95
Sketch2D/src/libs/library/public/SketchLibrary.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||