diff --git a/CMakeLists.txt b/CMakeLists.txt index ef9a5db6..7006b18d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,3 +38,4 @@ add_subdirectory(3DScene) add_subdirectory(RayTracer) add_subdirectory(Sketch3D) add_subdirectory(3DEditor) +add_subdirectory(Sketch2D) diff --git a/Modules/private/AppStorage.cpp b/Modules/private/AppStorage.cpp new file mode 100644 index 00000000..3564ecbf --- /dev/null +++ b/Modules/private/AppStorage.cpp @@ -0,0 +1,25 @@ +#include "AppStorage.hpp" + +#include +#include +#include + +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(); + } +} diff --git a/Modules/public/AppStorage.hpp b/Modules/public/AppStorage.hpp new file mode 100644 index 00000000..39b24db0 --- /dev/null +++ b/Modules/public/AppStorage.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +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; + }; +} diff --git a/Sketch2D/CMakeLists.txt b/Sketch2D/CMakeLists.txt new file mode 100644 index 00000000..4a030c6f --- /dev/null +++ b/Sketch2D/CMakeLists.txt @@ -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 $ --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 $ 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 () + diff --git a/Sketch2D/bench/BenchInput.cpp b/Sketch2D/bench/BenchInput.cpp new file mode 100644 index 00000000..5d5c1fc3 --- /dev/null +++ b/Sketch2D/bench/BenchInput.cpp @@ -0,0 +1,132 @@ +#include "BenchInput.hpp" + +#include +#include + +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; + } +} diff --git a/Sketch2D/bench/BenchInput.hpp b/Sketch2D/bench/BenchInput.hpp new file mode 100644 index 00000000..afc9063a --- /dev/null +++ b/Sketch2D/bench/BenchInput.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "InputRecording.hpp" +#include "Rect.hpp" + +#include + +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); +} diff --git a/Sketch2D/bench/BenchMain.cpp b/Sketch2D/bench/BenchMain.cpp new file mode 100644 index 00000000..2dc4a081 --- /dev/null +++ b/Sketch2D/bench/BenchMain.cpp @@ -0,0 +1,312 @@ +#include "ThreadPlacement.hpp" +#include "BenchInput.hpp" +#include "BenchReport.hpp" +#include "BenchRunner.hpp" +#include "Sketch2DApplication.hpp" +#include "VulkanContext.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 sessions; + std::string revision = "unknown"; + std::string buildType = "unknown"; + std::string filter; + std::vector 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 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 loadInputs(const Options& options, RectF canvasArea) { + std::vector 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 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& 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 before = Baseline::parse(readFile(options.baselinePath)); + const std::optional 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 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 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 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 inputs = loadInputs(options, application.root().canvas.windowRect()); + std::map recordedFrames; + if (inputs.empty() && options.sessions.empty()) { + std::fprintf(stderr, "sketch2dBench: no inputs\n"); + return 2; + } + + std::vector inputNames; + for (const BenchInputs& input : inputs) + inputNames.push_back(input.name); + + std::vector scenarios; + std::vector sessionInputs; + for (const std::string& path : options.sessions) { + std::optional 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(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 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; + 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 changes = baseline ? compare(*baseline, results, policy) : std::vector{}; + 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; +} diff --git a/Sketch2D/bench/BenchReport.cpp b/Sketch2D/bench/BenchReport.cpp new file mode 100644 index 00000000..3cb5fcc5 --- /dev/null +++ b/Sketch2D/bench/BenchReport.cpp @@ -0,0 +1,587 @@ +#include "BenchReport.hpp" + +#include +#include +#include +#include +#include + +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 split(const std::string& line, char separator) { + std::vector 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& values) { + std::string text; + for (double value : values) + text += (text.empty() ? "" : ",") + format("%.6f", value); + return text; + } + + std::vector numbers(const std::string& text) { + std::vector 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> previous(second + 1, std::vector(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> current(second + 1, std::vector(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& 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 groupsInOrder(const std::vector& results) { + std::vector 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& 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 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* list : { &frameMetrics, §ionMetrics, &gpuMetrics }) { + for (const Metric& entry : *list) { + if (entry.name == name) return &entry; + } + } + return nullptr; + } + + const std::vector& keyMetrics() { + static const std::vector 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::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 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 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 metricsFromRecordedFrames(const std::string& tsv) { + std::istringstream input(tsv); + std::string line; + if (!std::getline(input, line)) return {}; + const std::vector columns = split(line, '\t'); + std::vector> values(columns.size()); + while (std::getline(input, line)) { + const std::vector 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 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 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 resultsFromBaseline(const Baseline& stored) { + std::vector 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 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& 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& before, const std::vector& after) { + const size_t first = before.size(); + const size_t second = after.size(); + if (first < 2 || second < 2) return 1; + + std::vector> 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 falseDiscoveryAdjusted(const std::vector& pValues) { + const size_t count = pValues.size(); + std::vector 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 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& 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* 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 + compare(const Baseline& baseline, const std::vector& results, const ComparisonPolicy& policy) { + std::vector 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 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 pValues; + for (const Change& change : changes) + pValues.push_back(change.pValue); + const std::vector 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 summarizeChanges(const std::vector& changes) { + std::vector 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(group.scenarios, 1)); + return groups; + } + + size_t countVerdicts(const std::vector& 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& results, + const std::optional& 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 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
Every key metric against the baseline\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
\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; + } +} diff --git a/Sketch2D/bench/BenchReport.hpp b/Sketch2D/bench/BenchReport.hpp new file mode 100644 index 00000000..b8710809 --- /dev/null +++ b/Sketch2D/bench/BenchReport.hpp @@ -0,0 +1,137 @@ +#pragma once + +#include "Profiler.hpp" + +#include +#include +#include +#include + +namespace tp::sketch2d::bench { + + struct Metric { + std::string name; + std::string unit; + ProfileStatistics statistics; + std::map> repeats; + bool lowerIsBetter = true; + + [[nodiscard]] std::vector 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 frameMetrics; + std::vector sectionMetrics; + std::vector gpuMetrics; + std::vector liveMetrics; + std::map 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 repeats; + }; + + struct Baseline { + std::string revision; + std::vector values; + std::map groups; + + [[nodiscard]] const BaselineValue* + find(const std::string& scenario, const std::string& metric, const std::string& statistic) const; + [[nodiscard]] static std::optional parse(const std::string& tsv); + }; + + struct SampleSummary { + size_t count = 0; + double median = 0; + double variation = 0; + }; + + [[nodiscard]] SampleSummary describeSamples(const std::vector& samples); + [[nodiscard]] double mannWhitneyPValue(const std::vector& before, const std::vector& after); + [[nodiscard]] std::vector falseDiscoveryAdjusted(const std::vector& 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& keyMetrics(); + [[nodiscard]] double statisticValue(const ProfileStatistics& statistics, const std::string& statistic); + + [[nodiscard]] std::vector metricsFromRecordedFrames(const std::string& tsv); + [[nodiscard]] std::vector resultsFromBaseline(const Baseline& stored); + [[nodiscard]] std::string resultsTsv(const RunInfo& info, const std::vector& results); + [[nodiscard]] std::vector + compare(const Baseline& baseline, const std::vector& results, const ComparisonPolicy& policy); + [[nodiscard]] std::vector summarizeChanges(const std::vector& changes); + [[nodiscard]] size_t countVerdicts(const std::vector& changes, Verdict verdict); + [[nodiscard]] std::string markdownReport( + const RunInfo& info, + const std::vector& results, + const std::optional& baseline, + const ComparisonPolicy& policy + ); +} diff --git a/Sketch2D/bench/BenchRunner.cpp b/Sketch2D/bench/BenchRunner.cpp new file mode 100644 index 00000000..7a1191d1 --- /dev/null +++ b/Sketch2D/bench/BenchRunner.cpp @@ -0,0 +1,262 @@ +#include "BenchRunner.hpp" + +#include "Sketch2DApplication.hpp" + +#include + +#include "Window.hpp" +#include + +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(now).count(); + } + + class MetricSeries { + public: + void add(const std::string& name, const std::vector& 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 metrics() const { + std::vector 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 pooled; + std::map> 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 mEntries; + }; + + std::vector frameValues(const std::vector& frames, double FrameProfile::* field) { + std::vector 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& 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 latencies; + replay(input, latencies, frameCount); + drain(frameCount); + + result.wallMs += (nowMs() - wallStart) / repeats; + result.processCpuMs += (Profiler::processCpuMs() - cpuStart) / repeats; + profiler.setEnabled(false); + + const std::vector& frames = profiler.frames(); + result.frames += frames.size(); + result.tiles = std::max(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 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; + } +} diff --git a/Sketch2D/bench/BenchRunner.hpp b/Sketch2D/bench/BenchRunner.hpp new file mode 100644 index 00000000..89075900 --- /dev/null +++ b/Sketch2D/bench/BenchRunner.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "BenchReport.hpp" +#include "BenchScenario.hpp" + +#include +#include +#include + +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& latencies, size_t& frames); + void drain(size_t& frames); + + Sketch2DApplication& mApplication; + RunOptions mOptions; + }; +} diff --git a/Sketch2D/bench/BenchScenario.cpp b/Sketch2D/bench/BenchScenario.cpp new file mode 100644 index 00000000..dd77dd0b --- /dev/null +++ b/Sketch2D/bench/BenchScenario.cpp @@ -0,0 +1,149 @@ +#include "BenchScenario.hpp" + +#include +#include + +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& 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& 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& 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& 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& 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& 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 brushAndEraserScenarios(const std::vector& inputs) { + std::vector 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; + } +} diff --git a/Sketch2D/bench/BenchScenario.hpp b/Sketch2D/bench/BenchScenario.hpp new file mode 100644 index 00000000..0da22f5e --- /dev/null +++ b/Sketch2D/bench/BenchScenario.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "Brush.hpp" +#include "Session.hpp" +#include "InputRecording.hpp" + +#include +#include +#include +#include + +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 tune; + std::shared_ptr session; + + [[nodiscard]] std::string describe() const; + }; + + struct BenchInputs { + std::string name; + InputRecording recording; + }; + + [[nodiscard]] std::vector brushAndEraserScenarios(const std::vector& inputs); +} diff --git a/Sketch2D/bench/README.md b/Sketch2D/bench/README.md new file mode 100644 index 00000000..d6d97fc2 --- /dev/null +++ b/Sketch2D/bench/README.md @@ -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//` (`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 `-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//` (or `$MODULES_SESSION_DIR//`) 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). diff --git a/Sketch2D/rsc/icons/LICENSE-lucide.txt b/Sketch2D/rsc/icons/LICENSE-lucide.txt new file mode 100644 index 00000000..718bb3f0 --- /dev/null +++ b/Sketch2D/rsc/icons/LICENSE-lucide.txt @@ -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. diff --git a/Sketch2D/rsc/icons/brush.png b/Sketch2D/rsc/icons/brush.png new file mode 100644 index 00000000..016a4856 Binary files /dev/null and b/Sketch2D/rsc/icons/brush.png differ diff --git a/Sketch2D/rsc/icons/color.png b/Sketch2D/rsc/icons/color.png new file mode 100644 index 00000000..bb5f74aa Binary files /dev/null and b/Sketch2D/rsc/icons/color.png differ diff --git a/Sketch2D/rsc/icons/crop.png b/Sketch2D/rsc/icons/crop.png new file mode 100644 index 00000000..f77a962b Binary files /dev/null and b/Sketch2D/rsc/icons/crop.png differ diff --git a/Sketch2D/rsc/icons/edit.png b/Sketch2D/rsc/icons/edit.png new file mode 100644 index 00000000..691dc87f Binary files /dev/null and b/Sketch2D/rsc/icons/edit.png differ diff --git a/Sketch2D/rsc/icons/eraser.png b/Sketch2D/rsc/icons/eraser.png new file mode 100644 index 00000000..6fb5f6a1 Binary files /dev/null and b/Sketch2D/rsc/icons/eraser.png differ diff --git a/Sketch2D/rsc/icons/export.png b/Sketch2D/rsc/icons/export.png new file mode 100644 index 00000000..3db14b38 Binary files /dev/null and b/Sketch2D/rsc/icons/export.png differ diff --git a/Sketch2D/rsc/icons/eyedropper.png b/Sketch2D/rsc/icons/eyedropper.png new file mode 100644 index 00000000..01eee998 Binary files /dev/null and b/Sketch2D/rsc/icons/eyedropper.png differ diff --git a/Sketch2D/rsc/icons/fill.png b/Sketch2D/rsc/icons/fill.png new file mode 100644 index 00000000..152ad330 Binary files /dev/null and b/Sketch2D/rsc/icons/fill.png differ diff --git a/Sketch2D/rsc/icons/gradient.png b/Sketch2D/rsc/icons/gradient.png new file mode 100644 index 00000000..8ea1c303 Binary files /dev/null and b/Sketch2D/rsc/icons/gradient.png differ diff --git a/Sketch2D/rsc/icons/hand.png b/Sketch2D/rsc/icons/hand.png new file mode 100644 index 00000000..10f13fea Binary files /dev/null and b/Sketch2D/rsc/icons/hand.png differ diff --git a/Sketch2D/rsc/icons/history.png b/Sketch2D/rsc/icons/history.png new file mode 100644 index 00000000..d44724a1 Binary files /dev/null and b/Sketch2D/rsc/icons/history.png differ diff --git a/Sketch2D/rsc/icons/lasso.png b/Sketch2D/rsc/icons/lasso.png new file mode 100644 index 00000000..41ff66dd Binary files /dev/null and b/Sketch2D/rsc/icons/lasso.png differ diff --git a/Sketch2D/rsc/icons/layers.png b/Sketch2D/rsc/icons/layers.png new file mode 100644 index 00000000..0d254a13 Binary files /dev/null and b/Sketch2D/rsc/icons/layers.png differ diff --git a/Sketch2D/rsc/icons/library.png b/Sketch2D/rsc/icons/library.png new file mode 100644 index 00000000..98d1b1b5 Binary files /dev/null and b/Sketch2D/rsc/icons/library.png differ diff --git a/Sketch2D/rsc/icons/line.png b/Sketch2D/rsc/icons/line.png new file mode 100644 index 00000000..79a3a584 Binary files /dev/null and b/Sketch2D/rsc/icons/line.png differ diff --git a/Sketch2D/rsc/icons/menu.png b/Sketch2D/rsc/icons/menu.png new file mode 100644 index 00000000..b6c5d589 Binary files /dev/null and b/Sketch2D/rsc/icons/menu.png differ diff --git a/Sketch2D/rsc/icons/move.png b/Sketch2D/rsc/icons/move.png new file mode 100644 index 00000000..ad8b023c Binary files /dev/null and b/Sketch2D/rsc/icons/move.png differ diff --git a/Sketch2D/rsc/icons/rectselect.png b/Sketch2D/rsc/icons/rectselect.png new file mode 100644 index 00000000..ff3cc1f9 Binary files /dev/null and b/Sketch2D/rsc/icons/rectselect.png differ diff --git a/Sketch2D/rsc/icons/redo.png b/Sketch2D/rsc/icons/redo.png new file mode 100644 index 00000000..5150ed91 Binary files /dev/null and b/Sketch2D/rsc/icons/redo.png differ diff --git a/Sketch2D/rsc/icons/rotate.png b/Sketch2D/rsc/icons/rotate.png new file mode 100644 index 00000000..c772e8d1 Binary files /dev/null and b/Sketch2D/rsc/icons/rotate.png differ diff --git a/Sketch2D/rsc/icons/select.png b/Sketch2D/rsc/icons/select.png new file mode 100644 index 00000000..ff3cc1f9 Binary files /dev/null and b/Sketch2D/rsc/icons/select.png differ diff --git a/Sketch2D/rsc/icons/settings.png b/Sketch2D/rsc/icons/settings.png new file mode 100644 index 00000000..11a8deb6 Binary files /dev/null and b/Sketch2D/rsc/icons/settings.png differ diff --git a/Sketch2D/rsc/icons/size.png b/Sketch2D/rsc/icons/size.png new file mode 100644 index 00000000..dc49b7d4 Binary files /dev/null and b/Sketch2D/rsc/icons/size.png differ diff --git a/Sketch2D/rsc/icons/smudge.png b/Sketch2D/rsc/icons/smudge.png new file mode 100644 index 00000000..dc1eaa80 Binary files /dev/null and b/Sketch2D/rsc/icons/smudge.png differ diff --git a/Sketch2D/rsc/icons/tool.png b/Sketch2D/rsc/icons/tool.png new file mode 100644 index 00000000..0792df3e Binary files /dev/null and b/Sketch2D/rsc/icons/tool.png differ diff --git a/Sketch2D/rsc/icons/transform.png b/Sketch2D/rsc/icons/transform.png new file mode 100644 index 00000000..6cfeb253 Binary files /dev/null and b/Sketch2D/rsc/icons/transform.png differ diff --git a/Sketch2D/rsc/icons/undo.png b/Sketch2D/rsc/icons/undo.png new file mode 100644 index 00000000..d93f6733 Binary files /dev/null and b/Sketch2D/rsc/icons/undo.png differ diff --git a/Sketch2D/rsc/icons/view.png b/Sketch2D/rsc/icons/view.png new file mode 100644 index 00000000..30befc86 Binary files /dev/null and b/Sketch2D/rsc/icons/view.png differ diff --git a/Sketch2D/rsc/icons/wand.png b/Sketch2D/rsc/icons/wand.png new file mode 100644 index 00000000..2e9cec06 Binary files /dev/null and b/Sketch2D/rsc/icons/wand.png differ diff --git a/Sketch2D/rsc/icons/zoom.png b/Sketch2D/rsc/icons/zoom.png new file mode 100644 index 00000000..a7107eea Binary files /dev/null and b/Sketch2D/rsc/icons/zoom.png differ diff --git a/Sketch2D/src/apps/AndroidEntry.cpp b/Sketch2D/src/apps/AndroidEntry.cpp new file mode 100644 index 00000000..1887d67b --- /dev/null +++ b/Sketch2D/src/apps/AndroidEntry.cpp @@ -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); +} diff --git a/Sketch2D/src/apps/AndroidUpdater.cpp b/Sketch2D/src/apps/AndroidUpdater.cpp new file mode 100644 index 00000000..9895d53e --- /dev/null +++ b/Sketch2D/src/apps/AndroidUpdater.cpp @@ -0,0 +1,162 @@ +#include "AndroidUpdater.hpp" + +#include +#include +#include + +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 reportProgress(JNIEnv*, jclass, jlong handle, jlong received, jlong total) { + const auto* callback = reinterpret_cast(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 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 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 + AndroidUpdateBackend::download(const Release& release, const std::function& 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(&callback) + ); + env->DeleteLocalRef(address); + env->DeleteLocalRef(sha256); + return errorOrNothing(env, result); + } + + std::optional 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); } +} diff --git a/Sketch2D/src/apps/AndroidUpdater.hpp b/Sketch2D/src/apps/AndroidUpdater.hpp new file mode 100644 index 00000000..81462595 --- /dev/null +++ b/Sketch2D/src/apps/AndroidUpdater.hpp @@ -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 fetchText(const std::string& url) override; + [[nodiscard]] std::optional + download(const Release& release, const std::function& onProgress) override; + [[nodiscard]] std::optional install() override; + void wakeMainThread() override; + + private: + android_app& mApp; + void* mUpdater = nullptr; + }; +} diff --git a/Sketch2D/src/apps/Entry.cpp b/Sketch2D/src/apps/Entry.cpp new file mode 100644 index 00000000..48c7a73b --- /dev/null +++ b/Sketch2D/src/apps/Entry.cpp @@ -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; +} diff --git a/Sketch2D/src/libs/CMakeLists.txt b/Sketch2D/src/libs/CMakeLists.txt new file mode 100644 index 00000000..77ec2f7e --- /dev/null +++ b/Sketch2D/src/libs/CMakeLists.txt @@ -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 () diff --git a/Sketch2D/src/libs/app/private/Sketch2DApplication.cpp b/Sketch2D/src/libs/app/private/Sketch2DApplication.cpp new file mode 100644 index 00000000..3d0285a1 --- /dev/null +++ b/Sketch2D/src/libs/app/private/Sketch2DApplication.cpp @@ -0,0 +1,120 @@ +#include "Sketch2DApplication.hpp" +#include "ThreadPlacement.hpp" + +#include + +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 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(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 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(backend); + mUpdates->checkInBackground(); + } +} diff --git a/Sketch2D/src/libs/app/public/Sketch2DApplication.hpp b/Sketch2D/src/libs/app/public/Sketch2DApplication.hpp new file mode 100644 index 00000000..1bc9f79d --- /dev/null +++ b/Sketch2D/src/libs/app/public/Sketch2DApplication.hpp @@ -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 + +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 mStudioRoot; + std::unique_ptr mUpdates; + }; +} diff --git a/Sketch2D/src/libs/brush/private/Brush.cpp b/Sketch2D/src/libs/brush/private/Brush.cpp new file mode 100644 index 00000000..6e5a66e0 --- /dev/null +++ b/Sketch2D/src/libs/brush/private/Brush.cpp @@ -0,0 +1,510 @@ +#include "Brush.hpp" + +#include +#include + +#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 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 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& brushPresets() { + static const std::vector 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); + } +} diff --git a/Sketch2D/src/libs/brush/private/DabMask.cpp b/Sketch2D/src/libs/brush/private/DabMask.cpp new file mode 100644 index 00000000..bcc6583f --- /dev/null +++ b/Sketch2D/src/libs/brush/private/DabMask.cpp @@ -0,0 +1,185 @@ +#include "DabMask.hpp" + +#include +#include +#include + +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 values{}; + + SizeTable() { + const std::pair 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 DabMaskCache::rasterize(const PreparedDab& dab, const DabPlacement& placement, halnf scale) { + auto mask = std::make_shared(); + 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(1, toFixed(total * weight)); + mask->distance[index] = toFixed(nearest); + } + } + return mask; + } + + std::shared_ptr + 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 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; + } +} diff --git a/Sketch2D/src/libs/brush/public/Brush.hpp b/Sketch2D/src/libs/brush/public/Brush.hpp new file mode 100644 index 00000000..50b9c7c8 --- /dev/null +++ b/Sketch2D/src/libs/brush/public/Brush.hpp @@ -0,0 +1,262 @@ +#pragma once + +#include "Color.hpp" +#include "Environment.hpp" +#include "Vec.hpp" + +#include +#include +#include +#include +#include + +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 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 mValues{}; + std::array 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 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& brushPresets(); + [[nodiscard]] const BrushPreset* findBrushPreset(BrushType type); +} diff --git a/Sketch2D/src/libs/brush/public/DabMask.hpp b/Sketch2D/src/libs/brush/public/DabMask.hpp new file mode 100644 index 00000000..960d5cc2 --- /dev/null +++ b/Sketch2D/src/libs/brush/public/DabMask.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include "Brush.hpp" + +#include +#include +#include +#include +#include + +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 coverage; + std::vector 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 + 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 + 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> mMasks; + uint64_t mHits = 0; + uint64_t mMisses = 0; + }; +} diff --git a/Sketch2D/src/libs/common/private/ChannelView.cpp b/Sketch2D/src/libs/common/private/ChannelView.cpp new file mode 100644 index 00000000..233a6317 --- /dev/null +++ b/Sketch2D/src/libs/common/private/ChannelView.cpp @@ -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); + } +} diff --git a/Sketch2D/src/libs/common/private/Stabilizer.cpp b/Sketch2D/src/libs/common/private/Stabilizer.cpp new file mode 100644 index 00000000..1621b94b --- /dev/null +++ b/Sketch2D/src/libs/common/private/Stabilizer.cpp @@ -0,0 +1,152 @@ +#include "Stabilizer.hpp" +#include "Profiling.hpp" + +#include +#include + +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 Stabilizer::takeOutput() { + std::vector output; + output.swap(mOutput); + return output; + } +} diff --git a/Sketch2D/src/libs/common/private/StrokePath.cpp b/Sketch2D/src/libs/common/private/StrokePath.cpp new file mode 100644 index 00000000..d5960890 --- /dev/null +++ b/Sketch2D/src/libs/common/private/StrokePath.cpp @@ -0,0 +1,128 @@ +#include "StrokePath.hpp" + +#include +#include + +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 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& 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); + } +} diff --git a/Sketch2D/src/libs/common/private/ThreadPlacement.cpp b/Sketch2D/src/libs/common/private/ThreadPlacement.cpp new file mode 100644 index 00000000..e22a15da --- /dev/null +++ b/Sketch2D/src/libs/common/private/ThreadPlacement.cpp @@ -0,0 +1,100 @@ +#include "ThreadPlacement.hpp" + +#include "ThreadPinning.hpp" + +#include +#include +#include + +#if defined(__linux__) || defined(__ANDROID__) +#include +#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>& siblingGroups) { + Layout layout; + for (const std::vector& group : siblingGroups) { + const bool owned = std::count(group.begin(), group.end(), interactiveCpu) > 0; + std::vector& 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 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 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 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(2, layout.workers.size()); + const unsigned hardware = std::thread::hardware_concurrency(); + return hardware > 2 ? (size_t) hardware - 1 : 2; + } +} diff --git a/Sketch2D/src/libs/common/public/ChannelView.hpp b/Sketch2D/src/libs/common/public/ChannelView.hpp new file mode 100644 index 00000000..14a8878e --- /dev/null +++ b/Sketch2D/src/libs/common/public/ChannelView.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "Color.hpp" + +#include + +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; + + [[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); +} diff --git a/Sketch2D/src/libs/common/public/History.hpp b/Sketch2D/src/libs/common/public/History.hpp new file mode 100644 index 00000000..e2699548 --- /dev/null +++ b/Sketch2D/src/libs/common/public/History.hpp @@ -0,0 +1,129 @@ +#pragma once + +#include "Environment.hpp" + +#include +#include +#include + +namespace tp::sketch2d { + + template + 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& 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 + 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 mEntries; + size_t mCurrent = 0; + int4 mLimit = kDefaultLimit; + ualni mRevision = 0; + std::vector mEntryRevisions; + ualni mNextEntry = 0; + }; +} diff --git a/Sketch2D/src/libs/common/public/Profiling.hpp b/Sketch2D/src/libs/common/public/Profiling.hpp new file mode 100644 index 00000000..862ff4f9 --- /dev/null +++ b/Sketch2D/src/libs/common/public/Profiling.hpp @@ -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) diff --git a/Sketch2D/src/libs/common/public/Stabilizer.hpp b/Sketch2D/src/libs/common/public/Stabilizer.hpp new file mode 100644 index 00000000..74303844 --- /dev/null +++ b/Sketch2D/src/libs/common/public/Stabilizer.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "Environment.hpp" +#include "Vec.hpp" + +#include + +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 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 mOutput; + }; +} diff --git a/Sketch2D/src/libs/common/public/StrokePath.hpp b/Sketch2D/src/libs/common/public/StrokePath.hpp new file mode 100644 index 00000000..44bc1993 --- /dev/null +++ b/Sketch2D/src/libs/common/public/StrokePath.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "Vec.hpp" + +#include +#include + +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; + using Emit = std::function; + + 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 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& 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& visit) const; + + private: + [[nodiscard]] halnf parameterAtLength(halnf length) const; + [[nodiscard]] halnf lengthAt(halnf parameter) const; + void measureSegments(halnf end); + + std::vector mSamples; + std::vector mLengths = { 0.0f }; + }; +} diff --git a/Sketch2D/src/libs/common/public/ThreadPlacement.hpp b/Sketch2D/src/libs/common/public/ThreadPlacement.hpp new file mode 100644 index 00000000..947489fb --- /dev/null +++ b/Sketch2D/src/libs/common/public/ThreadPlacement.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +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 interactive; + std::vector workers; + }; + + [[nodiscard]] static Layout layoutFor(int interactiveCpu, const std::vector>& siblingGroups); + + static void reserveCallingThreadForInteraction(); + static void placeCallingThreadAsWorker(); + + [[nodiscard]] static Layout current(); + [[nodiscard]] static std::vector callingThreadCpus(); + [[nodiscard]] static size_t workerCount(); + }; +} diff --git a/Sketch2D/src/libs/document/private/BrushCursor.cpp b/Sketch2D/src/libs/document/private/BrushCursor.cpp new file mode 100644 index 00000000..af600c76 --- /dev/null +++ b/Sketch2D/src/libs/document/private/BrushCursor.cpp @@ -0,0 +1,57 @@ +#include "BrushCursor.hpp" + +#include +#include + +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; + } +} diff --git a/Sketch2D/src/libs/document/private/CanvasFrameGeometry.cpp b/Sketch2D/src/libs/document/private/CanvasFrameGeometry.cpp new file mode 100644 index 00000000..2e1adb65 --- /dev/null +++ b/Sketch2D/src/libs/document/private/CanvasFrameGeometry.cpp @@ -0,0 +1,48 @@ +#include "CanvasFrameGeometry.hpp" + +#include + +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 canvasShadow(const RectF& frame) { + std::vector 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; + } +} diff --git a/Sketch2D/src/libs/document/private/Checkerboard.cpp b/Sketch2D/src/libs/document/private/Checkerboard.cpp new file mode 100644 index 00000000..4e1b2d63 --- /dev/null +++ b/Sketch2D/src/libs/document/private/Checkerboard.cpp @@ -0,0 +1,21 @@ +#include "Checkerboard.hpp" + +#include + +namespace tp::sketch2d { + + std::vector checkerCells(const Document& document, Vec2F viewport) { + std::vector 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; + } +} diff --git a/Sketch2D/src/libs/document/private/ColorModel.cpp b/Sketch2D/src/libs/document/private/ColorModel.cpp new file mode 100644 index 00000000..7cb66d74 --- /dev/null +++ b/Sketch2D/src/libs/document/private/ColorModel.cpp @@ -0,0 +1,202 @@ +#include "ColorModel.hpp" + +#include +#include +#include +#include +#include +#include +#include + +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 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 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::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()); + } +} diff --git a/Sketch2D/src/libs/document/private/Compositor.cpp b/Sketch2D/src/libs/document/private/Compositor.cpp new file mode 100644 index 00000000..c8fc86a2 --- /dev/null +++ b/Sketch2D/src/libs/document/private/Compositor.cpp @@ -0,0 +1,508 @@ +#include "Compositor.hpp" +#include "Profiling.hpp" + +#include +#include +#include +#include + +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& 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& stack) { + DirtyBands changed; + for (const StackEntry& entry : stack) { + if (entry.tile) changed.include(entry.tile->compositeBands()); + } + return changed; + } + + bool Compositor::isPlainCopy(const std::vector& 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& 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& 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& 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& 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::stackAt(const Document& document, const TileCoord& coord) { + std::vector 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& layers, const TileCoord& coord, Tile& target) { + std::vector 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 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 stack; + Tile* target; + }; + std::vector jobs; + for (auto it : coords) { + const TileCoord coord = it->val; + std::vector 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(), 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 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 stack; + Tile* target; + DirtyBands bands; + bool whole; + }; + std::vector jobs; + + for (auto it : coords) { + const TileCoord coord = it->val; + std::vector 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(), 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; + } +} diff --git a/Sketch2D/src/libs/document/private/Document.cpp b/Sketch2D/src/libs/document/private/Document.cpp new file mode 100644 index 00000000..9ccc851c --- /dev/null +++ b/Sketch2D/src/libs/document/private/Document.cpp @@ -0,0 +1,2728 @@ +#include "Document.hpp" + +#include +#include "DabMask.hpp" +#include "Profiling.hpp" + +#include "Compositor.hpp" + +#include +#include +#include +#include +#include +#include + +namespace tp::sketch2d { + + namespace { + template + void forEachTileSpan(int4 originX, int4 originY, int4 diameter, const Visit& visit) { + const int4 endX = originX + diameter; + const int4 endY = originY + diameter; + for (int4 ty = floordiv(originY, kTileSize); ty <= floordiv(endY - 1, kTileSize); ty++) { + for (int4 tx = floordiv(originX, kTileSize); tx <= floordiv(endX - 1, kTileSize); tx++) { + const int4 x0 = std::max(originX, tx * kTileSize); + const int4 y0 = std::max(originY, ty * kTileSize); + const int4 x1 = std::min(endX, (tx + 1) * kTileSize); + const int4 y1 = std::min(endY, (ty + 1) * kTileSize); + visit(TileCoord{ tx, ty }, x0, y0, x1, y1); + } + } + } + + void decodeMixPixel(const uint1* stored, halnf* mix, bool linear) { + const halnf alpha = stored[3] / 255.0f; + mix[3] = alpha; + for (int4 channel = 0; channel < 3; channel++) { + const halnf premultiplied = stored[channel] / 255.0f; + if (!linear || alpha <= 0.0f) { + mix[channel] = premultiplied; + continue; + } + mix[channel] = gammaToLinear(std::min(premultiplied / alpha, 1.0f)) * alpha; + } + } + + void encodeMixPixel(const halnf* mix, uint1* stored, bool linear) { + const halnf alpha = std::clamp(mix[3], 0.0f, 1.0f); + stored[3] = (uint1) std::lround(alpha * 255.0f); + for (int4 channel = 0; channel < 3; channel++) { + halnf premultiplied = std::clamp(mix[channel], 0.0f, alpha); + if (linear && alpha > 0.0f) premultiplied = linearToGamma(premultiplied / alpha) * alpha; + stored[channel] = (uint1) std::lround(premultiplied * 255.0f); + } + } + + void keepSmudgedAlpha(halnf* mix, halnf alpha) { + if (mix[3] <= 0.0f) { + mix[0] = mix[1] = mix[2] = 0.0f; + mix[3] = alpha; + return; + } + const halnf scale = alpha / mix[3]; + for (int4 channel = 0; channel < 3; channel++) + mix[channel] *= scale; + mix[3] = alpha; + } + + int4 smudgeBlurRadius(halnf blur) { return (int4) std::lround(blur); } + + template + void boxBlurLine(int4 size, int4 radius, const Pixel& pixel) { + std::vector line((size_t) size * 4); + for (int4 index = 0; index < size; index++) + std::copy_n(pixel(index), 4, line.data() + (size_t) index * 4); + + halnf sum[4] = {}; + for (int4 offset = -radius; offset <= radius; offset++) { + const halnf* source = line.data() + (size_t) std::clamp(offset, 0, size - 1) * 4; + for (int4 channel = 0; channel < 4; channel++) + sum[channel] += source[channel]; + } + + const halnf window = (halnf) (2 * radius + 1); + for (int4 index = 0; index < size; index++) { + halnf* target = pixel(index); + for (int4 channel = 0; channel < 4; channel++) + target[channel] = sum[channel] / window; + + const halnf* leaving = line.data() + (size_t) std::clamp(index - radius, 0, size - 1) * 4; + const halnf* entering = line.data() + (size_t) std::clamp(index + radius + 1, 0, size - 1) * 4; + for (int4 channel = 0; channel < 4; channel++) + sum[channel] += entering[channel] - leaving[channel]; + } + } + + SmudgePatch blurredSmudgePatch(const SmudgePatch& patch, int4 radius) { + if (radius <= 0) return patch; + + SmudgePatch result = patch; + const int4 size = patch.diameter; + for (int4 y = 0; y < size; y++) + boxBlurLine(size, radius, [&](int4 x) { return result.at(x, y); }); + for (int4 x = 0; x < size; x++) + boxBlurLine(size, radius, [&](int4 y) { return result.at(x, y); }); + return result; + } + + void resampleSmudgeCarry(SmudgePatch& carry, int4 diameter) { + SmudgePatch resized; + resized.diameter = diameter; + resized.rgba.assign((size_t) diameter * diameter * 4, 0.0f); + for (int4 y = 0; y < diameter; y++) { + for (int4 x = 0; x < diameter; x++) { + const int4 sx = std::min(x * carry.diameter / diameter, carry.diameter - 1); + const int4 sy = std::min(y * carry.diameter / diameter, carry.diameter - 1); + std::copy_n(carry.at(sx, sy), 4, resized.at(x, y)); + } + } + carry = std::move(resized); + } + + void resaturateSmudgeCarry(SmudgePatch& carry, const Brush& brush, halnf resaturation) { + if (resaturation <= 0.0f) return; + const halnf color[3] = { brush.color.r, brush.color.g, brush.color.b }; + for (int4 y = 0; y < carry.diameter; y++) { + for (int4 x = 0; x < carry.diameter; x++) { + halnf* pixel = carry.at(x, y); + for (int4 channel = 0; channel < 3; channel++) + pixel[channel] += (color[channel] - pixel[channel]) * resaturation; + pixel[3] += (1.0f - pixel[3]) * resaturation; + } + } + } + + SmudgePatch readSmudgePatch(const Layer& layer, int4 originX, int4 originY, int4 diameter, bool linear) { + SmudgePatch patch; + patch.diameter = diameter; + patch.rgba.assign((size_t) diameter * diameter * 4, 0.0f); + + forEachTileSpan(originX, originY, diameter, [&](TileCoord coord, int4 x0, int4 y0, int4 x1, int4 y1) { + const Tile* tile = layer.tileIfExists(coord); + if (!tile) return; + for (int4 y = y0; y < y1; y++) { + for (int4 x = x0; x < x1; x++) { + const int4 lx = x - coord.x * kTileSize; + const int4 ly = y - coord.y * kTileSize; + const uint1* source = tile->pixels() + ((ualni) ly * kTileSize + lx) * 4; + decodeMixPixel(source, patch.at(x - originX, y - originY), linear); + } + } + }); + return patch; + } + } + + Document::Document() { reset(); } + + Document::~Document() { settlePaint(); } + + void Document::reset() { + settlePaint(); + mLayers.clear(); + mActive = nullptr; + mBackground = {}; + mFrame = {}; + mStrokeLayer = nullptr; + mStrokeTiles.removeAll(); + mStrokeMasks.removeAll(); + mPreviewMasks.removeAll(); + mSmudgeCarry.clear(); + addLayer(nextLayerName()); + mHistory.reset("New file", state()); + resetView(); + } + + std::string Document::uniqueLayerName(const std::string& name) const { + std::string base = name; + const size_t space = base.find_last_of(' '); + if ( + space != std::string::npos && space + 1 < base.size() && + base.find_first_not_of("0123456789", space + 1) == std::string::npos + ) { + base.resize(space); + } + + long highest = 0; + for (const auto& layer : mLayers) { + const std::string& existing = layer->name(); + if (existing.compare(0, base.size(), base) != 0) continue; + highest = std::max(highest, std::strtol(existing.c_str() + base.size(), nullptr, 10)); + } + return base + " " + std::to_string(highest + 1); + } + + size_t Document::indexOf(const Layer& layer) const { + for (size_t index = 0; index < mLayers.size(); index++) { + if (mLayers[index].get() == &layer) return index; + } + return mLayers.size(); + } + + Layer* Document::duplicateLayer(const Layer& source) { + settlePaint(); + const size_t index = indexOf(source); + if (index == mLayers.size() || !canAddLayer()) return nullptr; + + Layer::State state = source.state(); + state.id = mNextLayerId++; + state.properties.name = uniqueLayerName(source.name()); + state.properties.selected = false; + state.properties.locked = false; + + auto copy = std::make_unique(state.id, state.properties.name); + copy->restore(state); + Layer* added = copy.get(); + mLayers.insert(mLayers.begin() + (std::ptrdiff_t) index + 1, std::move(copy)); + mActive = added; + return added; + } + + bool Document::renameLayer(Layer& layer, const std::string& name) { + std::string sanitized; + for (const char character : name) { + if (character == '\n') continue; + sanitized += character == '"' ? ' ' : character; + } + if (sanitized.size() > kMaximumLayerNameLength) sanitized.resize(kMaximumLayerNameLength); + if (sanitized == layer.name()) return false; + + layer.setName(sanitized); + return true; + } + + bool Document::moveActiveLayer(int4 offset) { + if (!mActive) return false; + const size_t index = indexOf(*mActive); + const auto target = (std::ptrdiff_t) index + offset; + if (target < 0 || target >= (std::ptrdiff_t) mLayers.size()) return false; + + const auto from = mLayers.begin() + (std::ptrdiff_t) index; + const auto to = mLayers.begin() + target; + if (offset > 0) std::rotate(from, from + 1, to + 1); + else std::rotate(to, from, from + 1); + mLayers.front()->setClipped(false); + return true; + } + + Layer* Document::addLayer(const std::string& name) { + if (!canAddLayer()) return nullptr; + const bool clipped = mActive && mActive->clipped(); + auto layer = std::make_unique(mNextLayerId++, name); + layer->setClipped(clipped); + Layer* added = layer.get(); + + auto position = mLayers.end(); + for (auto it = mLayers.begin(); it != mLayers.end(); ++it) { + if (it->get() == mActive) position = it + 1; + } + mLayers.insert(position, std::move(layer)); + mActive = added; + return added; + } + + void Document::removeActiveLayer() { + settlePaint(); + if (mLayers.size() <= 1 || !mActive) return; + if (mStrokeLayer == mActive) finishStroke(); + + for (auto it = mLayers.begin(); it != mLayers.end(); ++it) { + if (it->get() != mActive) continue; + const auto removed = it - mLayers.begin(); + mLayers.erase(it); + mActive = mLayers[(size_t) (removed > 0 ? removed - 1 : 0)].get(); + return; + } + } + + void Document::setActiveLayer(Layer* layer) { + if (!layer) return; + mActive = layer; + } + + bool Document::selectOnly(Layer& layer) { + bool changed = mActive != &layer; + for (const auto& candidate : mLayers) { + changed |= candidate->selected(); + candidate->setSelected(false); + } + mActive = &layer; + return changed; + } + + bool Document::selectAdjacent(int4 offset) { + if (!mActive) return false; + const auto target = + std::clamp((std::ptrdiff_t) indexOf(*mActive) + offset, (std::ptrdiff_t) 0, (std::ptrdiff_t) mLayers.size() - 1); + return selectOnly(*mLayers[(size_t) target]); + } + + bool Document::selectAllLayers() { + bool changed = false; + for (const auto& layer : mLayers) { + changed |= !layer->selected() && layer.get() != mActive; + layer->setSelected(true); + } + return changed; + } + + bool Document::toggleLayerSelection(Layer& layer) { + if (&layer == mActive) { + if (selectedCount() <= 1) return false; + layer.setSelected(false); + for (const auto& candidate : mLayers) { + if (candidate->selected()) { + mActive = candidate.get(); + break; + } + } + return true; + } + + if (mActive) mActive->setSelected(true); + layer.setSelected(!layer.selected()); + return true; + } + + bool Document::selectRangeTo(Layer& layer) { + if (!mActive) return selectOnly(layer); + const size_t from = std::min(indexOf(*mActive), indexOf(layer)); + const size_t to = std::max(indexOf(*mActive), indexOf(layer)); + + bool changed = false; + for (size_t index = 0; index < mLayers.size(); index++) { + const bool inside = index >= from && index <= to; + changed |= mLayers[index]->selected() != inside; + mLayers[index]->setSelected(inside); + } + return changed; + } + + size_t Document::selectedCount() const { + size_t count = 0; + for (const auto& layer : mLayers) { + if (layer->selected() || layer.get() == mActive) count++; + } + return count; + } + + std::vector Document::mergeCandidates() const { + std::vector layers = targetLayers(); + if (layers.size() != 1) return layers; + + Layer* below = layerBelow(*layers.front()); + if (!below) return {}; + return { below, layers.front() }; + } + + bool Document::bakeOpacity(Layer& layer) { + settlePaint(); + const halnf opacity = layer.opacity(); + if (opacity >= 1.0f) return false; + + std::vector coords; + layer.forEachTile([&coords](const TileCoord& coord, Tile*) { coords.push_back(coord); }); + for (const TileCoord& coord : coords) { + uint1* pixels = layer.tileAt(coord)->pixels(); + for (ualni index = 0; index < (ualni) kTileSize * kTileSize * 4; index++) + pixels[index] = (uint1) std::lround(pixels[index] * opacity); + layer.tileAt(coord)->markDirty({ 0, 0, (halnf) kTileSize, (halnf) kTileSize }); + } + layer.setOpacity(1.0f); + return true; + } + + bool Document::mergeLayers(const std::vector& requested) { + settlePaint(); + std::vector layers = requested; + std::sort(layers.begin(), layers.end(), [this](const Layer* left, const Layer* right) { + return indexOf(*left) < indexOf(*right); + }); + if (layers.size() < 2) return false; + + Layer& target = *layers.front(); + bakeOpacity(target); + const Layer::Properties kept = target.properties(); + target.setBlend(BlendMode::Normal); + target.setClipped(false); + std::vector stack(layers.begin(), layers.end()); + + Map coords; + for (const Layer* layer : stack) + layer->forEachTile([&coords](const TileCoord& coord, Tile*) { coords.put(coord.key(), coord); }); + + Layer merged(target.id(), target.name()); + for (auto it : coords) { + Tile* tile = merged.tileAt(it->val); + Compositor::flatten(stack, it->val, *tile); + if (tile->isTransparent()) merged.removeTile(it->val); + } + + Layer::State state = merged.state(); + state.properties = kept; + target.restore(state); + + for (size_t index = 1; index < layers.size(); index++) { + if (mStrokeLayer == layers[index]) finishStroke(); + mLayers.erase(mLayers.begin() + (std::ptrdiff_t) indexOf(*layers[index])); + } + + for (const auto& layer : mLayers) + layer->setSelected(false); + mActive = ⌖ + return true; + } + + bool Document::mergeVisibleLayers() { + std::vector layers; + for (const auto& layer : mLayers) { + if (layer->visible()) layers.push_back(layer.get()); + } + return mergeLayers(layers); + } + + void Document::clearActiveLayer() { + settlePaint(); + if (mActive) mActive->clear(); + } + + void Document::setLayerVisible(Layer& layer, bool visible) { layer.setVisible(visible); } + + void Document::setLayerOpacity(Layer& layer, halnf opacity) { + layer.setOpacity(opacity < 0 ? 0 : (opacity > 1 ? 1 : opacity)); + } + + std::vector Document::targetLayers() const { + std::vector layers; + for (const auto& layer : mLayers) { + if (layer.get() == mActive || layer->selected()) layers.push_back(layer.get()); + } + return layers; + } + + Layer* Document::layerBelow(const Layer& layer) const { + for (size_t index = 1; index < mLayers.size(); index++) { + if (mLayers[index].get() == &layer) return mLayers[index - 1].get(); + } + return nullptr; + } + + Layer* Document::layerAbove(const Layer& layer) const { + for (size_t index = 0; index + 1 < mLayers.size(); index++) { + if (mLayers[index].get() == &layer) return mLayers[index + 1].get(); + } + return nullptr; + } + + bool Document::toggleVisible() { + const bool visible = mActive && !mActive->visible(); + bool changed = false; + for (Layer* layer : targetLayers()) { + changed |= layer->visible() != visible; + layer->setVisible(visible); + } + return changed; + } + + bool Document::toggleClip() { + const bool clipped = mActive && !mActive->clipped(); + bool changed = false; + for (Layer* layer : targetLayers()) { + if (clipped && !layerBelow(*layer)) continue; + changed |= layer->clipped() != clipped; + layer->setClipped(clipped); + } + return changed; + } + + bool Document::toggleLock() { + const bool locked = mActive && !mActive->locked(); + bool changed = false; + for (Layer* layer : targetLayers()) { + changed |= layer->locked() != locked; + layer->setLocked(locked); + } + return changed; + } + + bool Document::toggleAlphaLock() { + const bool alphaLocked = mActive && !mActive->alphaLocked(); + bool changed = false; + for (Layer* layer : targetLayers()) { + changed |= layer->alphaLocked() != alphaLocked; + layer->setAlphaLocked(alphaLocked); + } + return changed; + } + + bool Document::cycleBlend(int4 direction) { + bool changed = false; + for (Layer* layer : targetLayers()) { + if (!canEdit(*layer)) continue; + layer->setBlend(direction > 0 ? nextBlendMode(layer->blend()) : previousBlendMode(layer->blend())); + changed = true; + } + return changed; + } + + bool Document::resetBlend() { return setBlend(BlendMode::Normal); } + + bool Document::setBlend(BlendMode blend) { + bool changed = false; + for (Layer* layer : targetLayers()) { + if (!canEdit(*layer) || layer->blend() == blend) continue; + layer->setBlend(blend); + changed = true; + } + return changed; + } + + bool Document::changeOpacity(halnf delta) { + bool changed = false; + for (Layer* layer : targetLayers()) { + if (!canEdit(*layer)) continue; + const halnf before = layer->opacity(); + setLayerOpacity(*layer, before + delta); + changed |= layer->opacity() != before; + } + return changed; + } + + bool Document::setOpacity(halnf opacity) { + bool changed = false; + for (Layer* layer : targetLayers()) { + if (!canEdit(*layer)) continue; + const halnf before = layer->opacity(); + setLayerOpacity(*layer, opacity); + changed |= layer->opacity() != before; + } + return changed; + } + + DocumentState Document::state() const { + settlePaint(); + return stateWithoutSettling(); + } + + DocumentState Document::stateWithoutSettling() const { + DocumentState snapshot; + snapshot.layers.reserve(mLayers.size()); + for (const auto& layer : mLayers) + snapshot.layers.push_back(layer->state()); + snapshot.active = mActive ? mActive->id() : 0; + snapshot.background = mBackground; + snapshot.frame = mFrame; + snapshot.selection = mSelection; + return snapshot; + } + + RGBA HueColor::rgba(halnf alpha) const { + const halnf hue = std::fmod(std::fmod(hueDegrees, 360.0f) + 360.0f, 360.0f) / 60.0f; + const halnf chroma = value * saturation; + const halnf secondary = chroma * (1.0f - std::fabs(std::fmod(hue, 2.0f) - 1.0f)); + const halnf base = value - chroma; + + halnf r = 0; + halnf g = 0; + halnf b = 0; + switch ((int4) hue) { + case 0: r = chroma, g = secondary; break; + case 1: r = secondary, g = chroma; break; + case 2: g = chroma, b = secondary; break; + case 3: g = secondary, b = chroma; break; + case 4: r = secondary, b = chroma; break; + default: r = chroma, b = secondary; break; + } + return { r + base, g + base, b + base, alpha }; + } + + void CanvasFrame::clamp() { + left = std::clamp(left, -kCoordinateLimit, kCoordinateLimit - 1); + top = std::clamp(top, -kCoordinateLimit, kCoordinateLimit - 1); + right = std::max(left + 1, std::clamp(right, -kCoordinateLimit, kCoordinateLimit)); + bottom = std::max(top + 1, std::clamp(bottom, -kCoordinateLimit, kCoordinateLimit)); + } + + RGBA Document::sampleColor(Vec2F world, bool allLayers) const { + settlePaint(); + const int4 px = (int4) std::floor(world.x); + const int4 py = (int4) std::floor(world.y); + const TileCoord coord = { floordiv(px, kTileSize), floordiv(py, kTileSize) }; + const ualni index = (ualni) (py - coord.y * kTileSize) * kTileSize + (ualni) (px - coord.x * kTileSize); + + const uint1* pixel = nullptr; + Tile flattened; + if (allLayers) { + std::vector visible; + for (const auto& layer : mLayers) + if (layer->visible()) visible.push_back(layer.get()); + if (visible.empty()) return { 0, 0, 0, 0 }; + Compositor::flatten(visible, coord, flattened); + pixel = flattened.pixels() + index * 4; + } else { + if (!mActive) return { 0, 0, 0, 0 }; + const Tile* tile = mActive->tileIfExists(coord); + if (!tile) return { 0, 0, 0, 0 }; + pixel = tile->pixels() + index * 4; + } + const halnf alpha = pixel[3] / 255.0f; + if (alpha <= 0) return { 0, 0, 0, 0 }; + return { std::min(1.0f, pixel[0] / 255.0f / alpha), + std::min(1.0f, pixel[1] / 255.0f / alpha), + std::min(1.0f, pixel[2] / 255.0f / alpha), + alpha }; + } + + Document::FloodRegion Document::floodRegion(Vec2F world, const BucketSettings& settings, bool refuseLeaks) const { + FloodRegion region; + const int4 seedX = (int4) std::floor(world.x); + const int4 seedY = (int4) std::floor(world.y); + + int4 left = 0; + int4 top = 0; + int4 right = 0; + int4 bottom = 0; + const bool framed = !mFrame.infinite && !mFrame.isEmpty(); + if (framed) { + left = mFrame.left; + top = mFrame.top; + right = mFrame.right; + bottom = mFrame.bottom; + } else { + if (!contentBounds(left, top, right, bottom)) { + region.result = BucketResult::Empty; + return region; + } + left -= kBucketOpenMargin; + top -= kBucketOpenMargin; + right += kBucketOpenMargin; + bottom += kBucketOpenMargin; + } + if (seedX < left || seedX >= right || seedY < top || seedY >= bottom) { + region.result = BucketResult::Leak; + return region; + } + const int4 width = right - left; + const int4 height = bottom - top; + if ((int8) width * height > kBucketMaximumPixels) { + region.result = BucketResult::TooBig; + return region; + } + + std::vector sources; + if (settings.allLayers) { + for (const auto& layer : mLayers) + if (layer->visible()) sources.push_back(layer.get()); + } else { + sources.push_back(mActive); + } + + std::vector sample((size_t) width * height * 4, 0); + Tile flattened; + for (int4 ty = floordiv(top, kTileSize); ty <= floordiv(bottom - 1, kTileSize); ty++) { + for (int4 tx = floordiv(left, kTileSize); tx <= floordiv(right - 1, kTileSize); tx++) { + const TileCoord coord = { tx, ty }; + const uint1* pixels = nullptr; + if (sources.size() == 1) { + const Tile* tile = sources.front()->tileIfExists(coord); + if (!tile) continue; + pixels = tile->pixels(); + } else { + bool any = false; + for (const Layer* layer : sources) + any = any || layer->tileIfExists(coord) != nullptr; + if (!any) continue; + Compositor::flatten(sources, coord, flattened); + pixels = flattened.pixels(); + } + const int4 x0 = std::max(left, tx * kTileSize); + const int4 x1 = std::min(right, (tx + 1) * kTileSize); + const int4 y0 = std::max(top, ty * kTileSize); + const int4 y1 = std::min(bottom, (ty + 1) * kTileSize); + for (int4 y = y0; y < y1; y++) { + const uint1* from = pixels + ((size_t) (y - ty * kTileSize) * kTileSize + (size_t) (x0 - tx * kTileSize)) * 4; + uint1* to = sample.data() + ((size_t) (y - top) * width + (size_t) (x0 - left)) * 4; + std::memcpy(to, from, (size_t) (x1 - x0) * 4); + } + } + } + + const auto at = [&](int4 x, int4 y) { return sample.data() + ((size_t) y * width + (size_t) x) * 4; }; + const uint1* seed = at(seedX - left, seedY - top); + const int4 seedPixel[4] = { seed[0], seed[1], seed[2], seed[3] }; + const int4 limit = (int4) std::lround(std::clamp(settings.threshold, 0.0f, 1.0f) * 255.0f); + const auto matches = [&](int4 x, int4 y) { + const uint1* pixel = at(x, y); + if (settings.onlyAlpha) return std::abs(pixel[3] - seedPixel[3]) <= limit; + for (int4 channel = 0; channel < 4; channel++) + if (std::abs(pixel[channel] - seedPixel[channel]) > limit) return false; + return true; + }; + + std::vector mask((size_t) width * height, 0); + std::vector> stack = { { seedX - left, seedY - top } }; + bool leaked = false; + while (!stack.empty()) { + const auto [x, y] = stack.back(); + stack.pop_back(); + if (mask[(size_t) y * width + x] || !matches(x, y)) continue; + int4 from = x; + while (from > 0 && !mask[(size_t) y * width + from - 1] && matches(from - 1, y)) + from--; + int4 to = x; + while (to + 1 < width && !mask[(size_t) y * width + to + 1] && matches(to + 1, y)) + to++; + if (from == 0 || to == width - 1 || y == 0 || y == height - 1) leaked = true; + for (int4 run = from; run <= to; run++) + mask[(size_t) y * width + run] = 1; + for (const int4 row : { y - 1, y + 1 }) { + if (row < 0 || row >= height) continue; + for (int4 run = from; run <= to; run++) + if (!mask[(size_t) row * width + run] && matches(run, row)) stack.push_back({ run, row }); + } + } + if (leaked && !framed && refuseLeaks) { + region.result = BucketResult::Leak; + return region; + } + + for (int4 grow = 0; grow < settings.spread; grow++) { + std::vector next = mask; + for (int4 y = 0; y < height; y++) + for (int4 x = 0; x < width; x++) { + if (mask[(size_t) y * width + x]) continue; + const bool touches = + (x > 0 && mask[(size_t) y * width + x - 1]) || (x + 1 < width && mask[(size_t) y * width + x + 1]) || + (y > 0 && mask[(size_t) (y - 1) * width + x]) || (y + 1 < height && mask[(size_t) (y + 1) * width + x]); + if (touches) next[(size_t) y * width + x] = 1; + } + mask = std::move(next); + } + + region.result = BucketResult::Filled; + region.left = left; + region.top = top; + region.width = width; + region.height = height; + region.mask = std::move(mask); + return region; + } + + BucketResult Document::bucketFill(Vec2F world, const RGBA& color, const BucketSettings& settings) { + settlePaint(); + if (!mActive || !canEdit(*mActive)) return BucketResult::Locked; + FloodRegion region = floodRegion(world, settings); + if (region.result != BucketResult::Filled) return region.result; + const int4 left = region.left; + const int4 top = region.top; + const int4 width = region.width; + const int4 right = left + region.width; + const int4 bottom = top + region.height; + const std::vector& mask = region.mask; + const uint1 source[4] = { + (uint1) std::lround(std::clamp(color.r * color.a, 0.0f, 1.0f) * 255.0f), + (uint1) std::lround(std::clamp(color.g * color.a, 0.0f, 1.0f) * 255.0f), + (uint1) std::lround(std::clamp(color.b * color.a, 0.0f, 1.0f) * 255.0f), + (uint1) std::lround(std::clamp(color.a, 0.0f, 1.0f) * 255.0f), + }; + const bool keepAlpha = mActive->alphaLocked(); + const bool clipped = mSelection.isActive(); + for (int4 ty = floordiv(top, kTileSize); ty <= floordiv(bottom - 1, kTileSize); ty++) { + for (int4 tx = floordiv(left, kTileSize); tx <= floordiv(right - 1, kTileSize); tx++) { + const int4 x0 = std::max(left, tx * kTileSize); + const int4 x1 = std::min(right, (tx + 1) * kTileSize); + const int4 y0 = std::max(top, ty * kTileSize); + const int4 y1 = std::min(bottom, (ty + 1) * kTileSize); + bool any = false; + for (int4 y = y0; y < y1 && !any; y++) + for (int4 x = x0; x < x1 && !any; x++) + any = mask[(size_t) (y - top) * width + (x - left)] != 0; + if (!any) continue; + const TileCoord coord = { tx, ty }; + if (keepAlpha && !mActive->tileIfExists(coord)) continue; + Tile* tile = mActive->tileAt(coord); + uint1* pixels = tile->pixels(); + for (int4 y = y0; y < y1; y++) + for (int4 x = x0; x < x1; x++) { + if (!mask[(size_t) (y - top) * width + (x - left)]) continue; + uint1* pixel = pixels + ((size_t) (y - ty * kTileSize) * kTileSize + (size_t) (x - tx * kTileSize)) * 4; + const halnf selected = clipped ? mSelection.coverage(x, y) / 255.0f : 1.0f; + if (selected <= 0) continue; + if (selected < 1) { + const halnf rgb[3] = { color.r, color.g, color.b }; + const halnf alpha = std::clamp(color.a, 0.0f, 1.0f) * selected; + for (int4 channel = 0; channel < 3; channel++) + pixel[channel] = (uint1) std::lround( + std::clamp(rgb[channel], 0.0f, 1.0f) * alpha * 255.0f + pixel[channel] * (1 - alpha) + ); + if (!keepAlpha) pixel[3] = (uint1) std::lround(alpha * 255.0f + pixel[3] * (1 - alpha)); + continue; + } + if (keepAlpha) { + const halnf alpha = pixel[3] / 255.0f; + const halnf rgb[3] = { color.r, color.g, color.b }; + for (int4 channel = 0; channel < 3; channel++) + pixel[channel] = (uint1) std::lround(std::clamp(rgb[channel], 0.0f, 1.0f) * alpha * 255.0f); + continue; + } + const uint4 keep = 255u - source[3]; + for (int4 channel = 0; channel < 4; channel++) + pixel[channel] = (uint1) std::min(255u, source[channel] + (pixel[channel] * keep + 127) / 255); + } + tile->markDirty( + { (halnf) (x0 - tx * kTileSize), (halnf) (y0 - ty * kTileSize), (halnf) (x1 - x0), (halnf) (y1 - y0) } + ); + } + } + return BucketResult::Filled; + } + + halnf Document::gradientPosition(Vec2F point, Vec2F from, Vec2F to, GradientShape shape) { + const Vec2F axis = to - from; + const halnf length2 = axis.x * axis.x + axis.y * axis.y; + if (length2 <= 0) return 1; + const Vec2F offset = point - from; + if (shape == GradientShape::Radial) + return std::clamp(std::sqrt((offset.x * offset.x + offset.y * offset.y) / length2), 0.0f, 1.0f); + return std::clamp((offset.x * axis.x + offset.y * axis.y) / length2, 0.0f, 1.0f); + } + + GradientResult Document::drawGradient( + Vec2F fromWorld, Vec2F toWorld, const RGBA& start, const RGBA& end, const GradientSettings& settings + ) { + settlePaint(); + int4 areaLeft = mFrame.left, areaTop = mFrame.top, areaRight = mFrame.right, areaBottom = mFrame.bottom; + const bool clipped = mSelection.isActive(); + if (clipped && mSelection.bounds(areaLeft, areaTop, areaRight, areaBottom)) { + if (!mFrame.infinite && !mFrame.isEmpty()) { + areaLeft = std::max(areaLeft, mFrame.left); + areaTop = std::max(areaTop, mFrame.top); + areaRight = std::min(areaRight, mFrame.right); + areaBottom = std::min(areaBottom, mFrame.bottom); + } + } else if (mFrame.infinite || mFrame.isEmpty()) { + return GradientResult::NoFrame; + } + if (!mActive || !canEdit(*mActive)) return GradientResult::Locked; + const bool keepAlpha = mActive->alphaLocked(); + const RGBA last = settings.toTransparent ? RGBA(start.r, start.g, start.b, 0) : end; + for (int4 ty = floordiv(areaTop, kTileSize); ty <= floordiv(areaBottom - 1, kTileSize); ty++) { + for (int4 tx = floordiv(areaLeft, kTileSize); tx <= floordiv(areaRight - 1, kTileSize); tx++) { + const TileCoord coord = { tx, ty }; + if ((settings.eraser || keepAlpha) && !mActive->tileIfExists(coord)) continue; + const int4 x0 = std::max(areaLeft, tx * kTileSize); + const int4 x1 = std::min(areaRight, (tx + 1) * kTileSize); + const int4 y0 = std::max(areaTop, ty * kTileSize); + const int4 y1 = std::min(areaBottom, (ty + 1) * kTileSize); + Tile* tile = mActive->tileAt(coord); + uint1* pixels = tile->pixels(); + for (int4 y = y0; y < y1; y++) { + for (int4 x = x0; x < x1; x++) { + const halnf t = + gradientPosition({ (halnf) x + 0.5f, (halnf) y + 0.5f }, fromWorld, toWorld, settings.shape); + const halnf selected = clipped ? mSelection.coverage(x, y) / 255.0f : 1.0f; + if (selected <= 0) continue; + const halnf alpha = (start.a + (last.a - start.a) * t) * selected; + uint1* pixel = pixels + ((size_t) (y - ty * kTileSize) * kTileSize + (size_t) (x - tx * kTileSize)) * 4; + if (settings.eraser) { + const halnf keep = 1.0f - std::clamp(alpha, 0.0f, 1.0f); + for (int4 channel = 0; channel < 4; channel++) + pixel[channel] = (uint1) std::lround(pixel[channel] * keep); + continue; + } + halnf rgb[3] = { start.r + (end.r - start.r) * t, + start.g + (end.g - start.g) * t, + start.b + (end.b - start.b) * t }; + if (settings.toTransparent) { + rgb[0] = start.r; + rgb[1] = start.g; + rgb[2] = start.b; + } + if (keepAlpha) { + const halnf existing = pixel[3] / 255.0f; + for (int4 channel = 0; channel < 3; channel++) { + const halnf current = existing > 0 ? pixel[channel] / 255.0f / existing : 0; + const halnf mixed = current + (std::clamp(rgb[channel], 0.0f, 1.0f) - current) * alpha; + pixel[channel] = (uint1) std::lround(mixed * existing * 255.0f); + } + continue; + } + const halnf source = std::clamp(alpha, 0.0f, 1.0f); + for (int4 channel = 0; channel < 3; channel++) + pixel[channel] = (uint1) std::lround( + std::clamp(rgb[channel], 0.0f, 1.0f) * source * 255.0f + pixel[channel] * (1 - source) + ); + pixel[3] = (uint1) std::lround(source * 255.0f + pixel[3] * (1 - source)); + } + } + tile->markDirty( + { (halnf) (x0 - tx * kTileSize), (halnf) (y0 - ty * kTileSize), (halnf) (x1 - x0), (halnf) (y1 - y0) } + ); + } + } + return GradientResult::Drawn; + } + + bool Document::beginMove(bool duplicate) { + settlePaint(); + if (!mActive || !canEdit(*mActive) || mActive->tileCount() == 0) return false; + mMoveSource = mActive->state(); + mMoveDuplicate = duplicate; + mMoveSelection = mSelection; + mMoveSelectionOnly = mSelection.isActive(); + mMoveX = mMoveY = 0; + return true; + } + + void Document::moveTo(int4 dx, int4 dy) { + if (!mMoveSource || !mActive || (dx == mMoveX && dy == mMoveY)) return; + mMoveX = dx; + mMoveY = dy; + mActive->restore(*mMoveSource); + mSelection = mMoveSelection; + const Selection& carried = mMoveSelection; + if (!mMoveDuplicate) { + if (mMoveSelectionOnly) { + std::vector coords; + mActive->forEachTile([&coords](const TileCoord& coord, Tile*) { coords.push_back(coord); }); + for (const TileCoord& coord : coords) { + Tile* tile = nullptr; + for (int4 y = 0; y < kTileSize; y++) + for (int4 x = 0; x < kTileSize; x++) { + const halnf cut = carried.coverage(coord.x * kTileSize + x, coord.y * kTileSize + y) / 255.0f; + if (cut <= 0) continue; + if (!tile) tile = mActive->tileAt(coord); + uint1* pixel = tile->pixels() + ((size_t) y * kTileSize + (size_t) x) * 4; + for (int4 channel = 0; channel < 4; channel++) + pixel[channel] = (uint1) std::lround(pixel[channel] * (1 - cut)); + } + if (tile) tile->markDirty({ 0, 0, (halnf) kTileSize, (halnf) kTileSize }); + } + } else { + mActive->clear(); + } + } + const bool keepAlpha = mActive->alphaLocked() && !mMoveDuplicate; + for (auto it : mMoveSource->tiles) { + const TileCoord source = it->val.coord; + const uint1* from = it->val.tile->pixels(); + const int4 baseX = source.x * kTileSize + dx; + const int4 baseY = source.y * kTileSize + dy; + for (int4 ty = floordiv(baseY, kTileSize); ty <= floordiv(baseY + kTileSize - 1, kTileSize); ty++) { + for (int4 tx = floordiv(baseX, kTileSize); tx <= floordiv(baseX + kTileSize - 1, kTileSize); tx++) { + const int4 x0 = std::max(baseX, tx * kTileSize); + const int4 x1 = std::min(baseX + kTileSize, (tx + 1) * kTileSize); + const int4 y0 = std::max(baseY, ty * kTileSize); + const int4 y1 = std::min(baseY + kTileSize, (ty + 1) * kTileSize); + bool any = false; + for (int4 y = y0; y < y1 && !any; y++) + for (int4 x = x0; x < x1 && !any; x++) + any = from[((size_t) (y - baseY) * kTileSize + (size_t) (x - baseX)) * 4 + 3] != 0; + if (!any || keepAlpha) continue; + Tile* tile = mActive->tileAt({ tx, ty }); + uint1* to = tile->pixels(); + for (int4 y = y0; y < y1; y++) { + for (int4 x = x0; x < x1; x++) { + const uint1* source = from + ((size_t) (y - baseY) * kTileSize + (size_t) (x - baseX)) * 4; + if (source[3] == 0) continue; + uint4 above[4] = { source[0], source[1], source[2], source[3] }; + if (mMoveSelectionOnly) { + const uint4 take = carried.coverage(x - dx, y - dy); + if (take == 0) continue; + for (int4 channel = 0; channel < 4; channel++) + above[channel] = (above[channel] * take + 127) / 255; + } + uint1* below = to + ((size_t) (y - ty * kTileSize) * kTileSize + (size_t) (x - tx * kTileSize)) * 4; + const uint4 keep = 255u - above[3]; + for (int4 channel = 0; channel < 4; channel++) + below[channel] = (uint1) std::min(255u, above[channel] + (below[channel] * keep + 127) / 255); + } + } + tile->markDirty( + { (halnf) (x0 - tx * kTileSize), (halnf) (y0 - ty * kTileSize), (halnf) (x1 - x0), (halnf) (y1 - y0) } + ); + } + } + } + if (mMoveSelectionOnly) { + const Selection& origin = mMoveSelection; + int4 left = 0, top = 0, right = 0, bottom = 0; + if (origin.bounds(left, top, right, bottom)) { + std::vector shifted((size_t) (right - left) * (bottom - top)); + for (int4 y = top; y < bottom; y++) + for (int4 x = left; x < right; x++) + shifted[(size_t) (y - top) * (right - left) + (x - left)] = origin.coverage(x, y); + mSelection.clear(); + mSelection.addMask(left + dx, top + dy, right - left, shifted, SelectionMode::Replace); + } + } + } + + bool Document::finishMove() { + if (!mMoveSource) return false; + const bool moved = mMoveX != 0 || mMoveY != 0; + if (!moved) { + mActive->restore(*mMoveSource); + mSelection = mMoveSelection; + } + mMoveSource.reset(); + return moved; + } + + void Document::cancelMove() { + if (!mMoveSource) return; + if (mActive) mActive->restore(*mMoveSource); + mSelection = mMoveSelection; + mMoveSource.reset(); + } + + Vec2F LayerTransform::apply(Vec2F point) const { + const Vec2F local = { (point.x - pivot.x) * scale.x, (point.y - pivot.y) * scale.y }; + const halnf c = std::cos(rotation); + const halnf s = std::sin(rotation); + return { pivot.x + offset.x + local.x * c - local.y * s, pivot.y + offset.y + local.x * s + local.y * c }; + } + + Vec2F LayerTransform::invert(Vec2F point) const { + const Vec2F moved = { point.x - pivot.x - offset.x, point.y - pivot.y - offset.y }; + const halnf c = std::cos(rotation); + const halnf s = std::sin(rotation); + const Vec2F turned = { moved.x * c + moved.y * s, -moved.x * s + moved.y * c }; + return { pivot.x + (scale.x != 0 ? turned.x / scale.x : 0.0f), + pivot.y + (scale.y != 0 ? turned.y / scale.y : 0.0f) }; + } + + bool LayerTransform::isIdentity() const { + return scale.x == 1 && scale.y == 1 && rotation == 0 && offset.x == 0 && offset.y == 0; + } + + bool Document::layerBounds(const Layer& layer, int4& left, int4& top, int4& right, int4& bottom) const { + bool any = false; + layer.forEachTile([&](const TileCoord& coord, Tile* tile) { + for (int4 y = 0; y < kTileSize; y++) + for (int4 x = 0; x < kTileSize; x++) { + if (tile->pixels()[((ualni) y * kTileSize + (ualni) x) * 4 + 3] == 0) continue; + const int4 px = coord.x * kTileSize + x; + const int4 py = coord.y * 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; + } + + bool Document::beginTransform() { + settlePaint(); + if (!mActive || !canEdit(*mActive)) return false; + int4 bounds[4]; + if (!layerBounds(*mActive, bounds[0], bounds[1], bounds[2], bounds[3])) return false; + mTransformSource = mActive->state(); + mTransformChanged = false; + std::copy(bounds, bounds + 4, mTransformBounds); + return true; + } + + bool Document::transformBounds(int4& left, int4& top, int4& right, int4& bottom) const { + if (!mTransformSource) return false; + left = mTransformBounds[0]; + top = mTransformBounds[1]; + right = mTransformBounds[2]; + bottom = mTransformBounds[3]; + return true; + } + + namespace { + void sampleBilinear(const Map& tiles, halnf x, halnf y, halnf out[4]) { + const auto texel = [&tiles](int4 px, int4 py, halnf result[4]) { + const TileCoord coord = { floordiv(px, kTileSize), floordiv(py, kTileSize) }; + auto found = tiles.presents(coord.key()); + if (!found.isValid()) { + result[0] = result[1] = result[2] = result[3] = 0; + return; + } + const uint1* pixel = + tiles.getSlotVal(found).tile->pixels() + + ((size_t) (py - coord.y * kTileSize) * kTileSize + (size_t) (px - coord.x * kTileSize)) * 4; + for (int4 channel = 0; channel < 4; channel++) + result[channel] = pixel[channel]; + }; + const halnf fx = x - 0.5f; + const halnf fy = y - 0.5f; + const int4 x0 = (int4) std::floor(fx); + const int4 y0 = (int4) std::floor(fy); + const halnf tx = fx - (halnf) x0; + const halnf ty = fy - (halnf) y0; + halnf a[4], b[4], c[4], d[4]; + texel(x0, y0, a); + texel(x0 + 1, y0, b); + texel(x0, y0 + 1, c); + texel(x0 + 1, y0 + 1, d); + for (int4 channel = 0; channel < 4; channel++) { + const halnf top = a[channel] + (b[channel] - a[channel]) * tx; + const halnf bottom = c[channel] + (d[channel] - c[channel]) * tx; + out[channel] = top + (bottom - top) * ty; + } + } + } + + void Document::previewTransform(const LayerTransform& transform) { + if (!mTransformSource || !mActive) return; + mActive->restore(*mTransformSource); + mTransformChanged = !transform.isIdentity(); + if (!mTransformChanged) return; + mActive->clear(); + const halnf corners[4][2] = { + { (halnf) mTransformBounds[0], (halnf) mTransformBounds[1] }, + { (halnf) mTransformBounds[2], (halnf) mTransformBounds[1] }, + { (halnf) mTransformBounds[0], (halnf) mTransformBounds[3] }, + { (halnf) mTransformBounds[2], (halnf) mTransformBounds[3] }, + }; + halnf minX = 1e30f, minY = 1e30f, maxX = -1e30f, maxY = -1e30f; + for (const auto& corner : corners) { + const Vec2F mapped = transform.apply({ corner[0], corner[1] }); + minX = std::min(minX, mapped.x); + minY = std::min(minY, mapped.y); + maxX = std::max(maxX, mapped.x); + maxY = std::max(maxY, mapped.y); + } + const int4 left = (int4) std::floor(minX) - 1; + const int4 top = (int4) std::floor(minY) - 1; + const int4 right = (int4) std::ceil(maxX) + 1; + const int4 bottom = (int4) std::ceil(maxY) + 1; + if ((int8) (right - left) * (bottom - top) > kBucketMaximumPixels) return; + for (int4 ty = floordiv(top, kTileSize); ty <= floordiv(bottom - 1, kTileSize); ty++) { + for (int4 tx = floordiv(left, kTileSize); tx <= floordiv(right - 1, kTileSize); tx++) { + const int4 x0 = std::max(left, tx * kTileSize); + const int4 x1 = std::min(right, (tx + 1) * kTileSize); + const int4 y0 = std::max(top, ty * kTileSize); + const int4 y1 = std::min(bottom, (ty + 1) * kTileSize); + Tile* tile = nullptr; + for (int4 y = y0; y < y1; y++) { + for (int4 x = x0; x < x1; x++) { + const Vec2F source = transform.invert({ (halnf) x + 0.5f, (halnf) y + 0.5f }); + halnf color[4]; + sampleBilinear(mTransformSource->tiles, source.x, source.y, color); + if (color[3] < 0.5f) continue; + if (!tile) tile = mActive->tileAt({ tx, ty }); + uint1* pixel = + tile->pixels() + ((size_t) (y - ty * kTileSize) * kTileSize + (size_t) (x - tx * kTileSize)) * 4; + for (int4 channel = 0; channel < 4; channel++) + pixel[channel] = (uint1) std::clamp((int4) std::lround(color[channel]), 0, 255); + } + } + if (tile) + tile->markDirty( + { (halnf) (x0 - tx * kTileSize), (halnf) (y0 - ty * kTileSize), (halnf) (x1 - x0), (halnf) (y1 - y0) } + ); + } + } + } + + bool Document::finishTransform() { + if (!mTransformSource) return false; + const bool changed = mTransformChanged; + if (!changed && mActive) mActive->restore(*mTransformSource); + mTransformSource.reset(); + mTransformChanged = false; + return changed; + } + + void Document::cancelTransform() { + if (!mTransformSource) return; + if (mActive) mActive->restore(*mTransformSource); + mTransformSource.reset(); + } + + bool Document::flipActiveLayer(bool horizontal) { + if (!beginTransform()) return false; + LayerTransform flip; + flip.pivot = { (mTransformBounds[0] + mTransformBounds[2]) / 2.0f, + (mTransformBounds[1] + mTransformBounds[3]) / 2.0f }; + flip.scale = horizontal ? Vec2F{ -1, 1 } : Vec2F{ 1, -1 }; + previewTransform(flip); + return finishTransform(); + } + + CropResult Document::cropTo(int4 left, int4 top, int4 right, int4 bottom) { + settlePaint(); + if (right <= left || bottom <= top) return CropResult::NoRegion; + for (const auto& layer : mLayers) + if (layer->alphaLocked()) return CropResult::AlphaLocked; + bool changed = setInfinite(false); + changed |= setFrame(left, top, right, bottom); + for (const auto& layer : mLayers) { + std::vector coords; + layer->forEachTile([&coords](const TileCoord& coord, Tile*) { coords.push_back(coord); }); + for (const TileCoord& coord : coords) { + const int4 x0 = coord.x * kTileSize; + const int4 y0 = coord.y * kTileSize; + const int4 x1 = x0 + kTileSize; + const int4 y1 = y0 + kTileSize; + if (x1 <= left || x0 >= right || y1 <= top || y0 >= bottom) { + layer->removeTile(coord); + changed = true; + continue; + } + if (x0 >= left && x1 <= right && y0 >= top && y1 <= bottom) continue; + const Tile* existing = layer->tileIfExists(coord); + bool outside = false; + for (int4 y = 0; y < kTileSize && !outside; y++) + for (int4 x = 0; x < kTileSize && !outside; x++) { + const int4 px = x0 + x; + const int4 py = y0 + y; + if (px >= left && px < right && py >= top && py < bottom) continue; + outside = existing->pixels()[((ualni) y * kTileSize + (ualni) x) * 4 + 3] != 0; + } + if (!outside) continue; + Tile* tile = layer->tileAt(coord); + for (int4 y = 0; y < kTileSize; y++) + for (int4 x = 0; x < kTileSize; x++) { + const int4 px = x0 + x; + const int4 py = y0 + y; + if (px >= left && px < right && py >= top && py < bottom) continue; + uint1* pixel = tile->pixels() + ((ualni) y * kTileSize + (ualni) x) * 4; + pixel[0] = pixel[1] = pixel[2] = pixel[3] = 0; + } + tile->markDirty({ 0, 0, (halnf) kTileSize, (halnf) kTileSize }); + changed = true; + } + } + return changed ? CropResult::Cropped : CropResult::Unchanged; + } + + BucketResult Document::magicWand(Vec2F world, const BucketSettings& settings, SelectionMode mode) { + FloodRegion region = floodRegion(world, settings, false); + if (region.result != BucketResult::Filled) return region.result; + std::vector coverage(region.mask.size()); + for (size_t index = 0; index < coverage.size(); index++) + coverage[index] = region.mask[index] ? 255 : 0; + mSelection.addMask(region.left, region.top, region.width, coverage, mode); + return BucketResult::Filled; + } + + Document::SelectionEditResult Document::fillSelection(const RGBA& color) { + settlePaint(); + if (!mSelection.isActive()) return SelectionEditResult::NoSelection; + if (mSelection.isInverted()) return SelectionEditResult::Inverted; + if (!mActive || !canEdit(*mActive)) return SelectionEditResult::Locked; + int4 left = 0, top = 0, right = 0, bottom = 0; + if (!mSelection.bounds(left, top, right, bottom)) return SelectionEditResult::NoSelection; + const bool keepAlpha = mActive->alphaLocked(); + const halnf rgb[3] = { std::clamp(color.r, 0.0f, 1.0f), + std::clamp(color.g, 0.0f, 1.0f), + std::clamp(color.b, 0.0f, 1.0f) }; + for (int4 ty = floordiv(top, kTileSize); ty <= floordiv(bottom - 1, kTileSize); ty++) + for (int4 tx = floordiv(left, kTileSize); tx <= floordiv(right - 1, kTileSize); tx++) { + const TileCoord coord = { tx, ty }; + if (keepAlpha && !mActive->tileIfExists(coord)) continue; + const int4 x0 = std::max(left, tx * kTileSize), x1 = std::min(right, (tx + 1) * kTileSize); + const int4 y0 = std::max(top, ty * kTileSize), y1 = std::min(bottom, (ty + 1) * kTileSize); + Tile* tile = nullptr; + for (int4 y = y0; y < y1; y++) + for (int4 x = x0; x < x1; x++) { + const halnf alpha = mSelection.coverage(x, y) / 255.0f * std::clamp(color.a, 0.0f, 1.0f); + if (alpha <= 0) continue; + if (!tile) tile = mActive->tileAt(coord); + uint1* pixel = + tile->pixels() + ((size_t) (y - ty * kTileSize) * kTileSize + (size_t) (x - tx * kTileSize)) * 4; + if (keepAlpha) { + const halnf existing = pixel[3] / 255.0f; + for (int4 channel = 0; channel < 3; channel++) { + const halnf current = existing > 0 ? pixel[channel] / 255.0f / existing : 0; + pixel[channel] = (uint1) std::lround((current + (rgb[channel] - current) * alpha) * existing * 255.0f); + } + continue; + } + for (int4 channel = 0; channel < 3; channel++) + pixel[channel] = (uint1) std::lround(rgb[channel] * alpha * 255.0f + pixel[channel] * (1 - alpha)); + pixel[3] = (uint1) std::lround(alpha * 255.0f + pixel[3] * (1 - alpha)); + } + if (tile) + tile->markDirty( + { (halnf) (x0 - tx * kTileSize), (halnf) (y0 - ty * kTileSize), (halnf) (x1 - x0), (halnf) (y1 - y0) } + ); + } + return SelectionEditResult::Done; + } + + Document::SelectionEditResult Document::fillSelectionUnderneath(const RGBA& color) { + if (!mSelection.isActive()) return SelectionEditResult::NoSelection; + if (mSelection.isInverted()) return SelectionEditResult::Inverted; + if (!mActive || !canAddLayer()) return SelectionEditResult::Locked; + Layer* above = mActive; + Layer* fill = addLayer("Fill"); + if (!fill) return SelectionEditResult::Locked; + size_t aboveIndex = 0; + for (size_t index = 0; index < mLayers.size(); index++) + if (mLayers[index].get() == above) aboveIndex = index; + size_t fillIndex = mLayers.size() - 1; + for (size_t index = 0; index < mLayers.size(); index++) + if (mLayers[index].get() == fill) fillIndex = index; + std::unique_ptr moved = std::move(mLayers[fillIndex]); + mLayers.erase(mLayers.begin() + (std::ptrdiff_t) fillIndex); + if (fillIndex < aboveIndex) aboveIndex--; + mLayers.insert(mLayers.begin() + (std::ptrdiff_t) aboveIndex, std::move(moved)); + mActive = fill; + const SelectionEditResult result = fillSelection(color); + mActive = above; + return result; + } + + Document::SelectionEditResult Document::clearSelection() { + settlePaint(); + if (!mActive || !canEdit(*mActive)) return SelectionEditResult::Locked; + if (mActive->alphaLocked()) return SelectionEditResult::AlphaLocked; + if (!mSelection.isActive()) { + mActive->clear(); + return SelectionEditResult::Done; + } + std::vector coords; + mActive->forEachTile([&coords](const TileCoord& coord, Tile*) { coords.push_back(coord); }); + for (const TileCoord& coord : coords) { + Tile* tile = nullptr; + for (int4 y = 0; y < kTileSize; y++) + for (int4 x = 0; x < kTileSize; x++) { + const halnf cut = mSelection.coverage(coord.x * kTileSize + x, coord.y * kTileSize + y) / 255.0f; + if (cut <= 0) continue; + if (!tile) tile = mActive->tileAt(coord); + uint1* pixel = tile->pixels() + ((size_t) y * kTileSize + (size_t) x) * 4; + for (int4 channel = 0; channel < 4; channel++) + pixel[channel] = (uint1) std::lround(pixel[channel] * (1 - cut)); + } + if (tile) { + tile->markDirty({ 0, 0, (halnf) kTileSize, (halnf) kTileSize }); + if (tile->isTransparent()) mActive->removeTile(coord); + } + } + return SelectionEditResult::Done; + } + + bool Document::deselect() { + if (!mSelection.isActive()) return false; + mSelection.clear(); + return true; + } + + bool Document::selectAllCanvas() { + int4 left = 0, top = 0, right = 0, bottom = 0; + if (!mFrame.infinite && !mFrame.isEmpty()) { + left = mFrame.left; + top = mFrame.top; + right = mFrame.right; + bottom = mFrame.bottom; + } else if (!contentBounds(left, top, right, bottom)) { + return false; + } + mSelection.selectAll(left, top, right, bottom); + return true; + } + + bool Document::selectAllAsRectangle() { + settlePaint(); + int4 left = 0, top = 0, right = 0, bottom = 0; + if (!mActive || !layerBounds(*mActive, left, top, right, bottom)) return false; + mSelection.selectAll(left, top, right, bottom); + return true; + } + + bool Document::selectAllAsLasso() { + settlePaint(); + int4 left = 0, top = 0, right = 0, bottom = 0; + if (!mActive || !layerBounds(*mActive, left, top, right, bottom)) return false; + const int4 width = right - left; + std::vector mask((size_t) width * (bottom - top), 0); + mActive->forEachTile([&](const TileCoord& coord, Tile* tile) { + for (int4 y = 0; y < kTileSize; y++) + for (int4 x = 0; x < kTileSize; x++) { + const int4 px = coord.x * kTileSize + x; + const int4 py = coord.y * kTileSize + y; + if (px < left || px >= right || py < top || py >= bottom) continue; + mask[(size_t) (py - top) * width + (px - left)] = tile->pixels()[((ualni) y * kTileSize + (ualni) x) * 4 + 3]; + } + }); + mSelection.clear(); + mSelection.addMask(left, top, width, mask, SelectionMode::Replace); + return true; + } + + halnf Document::pixelValue(const halnf rgba[4]) { return 0.299f * rgba[0] + 0.587f * rgba[1] + 0.114f * rgba[2]; } + + namespace { + class PixelFunctionOperation : public Operation { + public: + PixelFunctionOperation(std::function function, std::shared_ptr selection) : + mFunction(std::move(function)), + mSelection(std::move(selection)) {} + + [[nodiscard]] const char* name() const override { return "pixel function"; } + + void prepare(const TileCoord& coord, std::shared_ptr source) { + mSources.put(coord.key(), std::move(source)); + } + + void run(const TileWork& work, Tile& target) override { + const Tile& source = *mSources.get(work.coord.key()); + const TileCoord& coord = work.coord; + for (int4 y = 0; y < kTileSize; y++) + for (int4 x = 0; x < kTileSize; x++) { + const size_t offset = ((size_t) y * kTileSize + (size_t) x) * 4; + const uint1* current = source.pixels() + offset; + uint1* pixel = target.pixels() + offset; + const halnf amount = + mSelection ? mSelection->coverage(coord.x * kTileSize + x, coord.y * kTileSize + y) / 255.0f : 1.0f; + if (amount <= 0 || current[3] == 0) { + std::copy(current, current + 4, pixel); + continue; + } + halnf before[4]; + before[3] = current[3] / 255.0f; + for (int4 channel = 0; channel < 3; channel++) + before[channel] = current[channel] / 255.0f / before[3]; + halnf after[4] = { before[0], before[1], before[2], before[3] }; + mFunction(after); + const halnf alpha = std::clamp(before[3] + (after[3] - before[3]) * amount, 0.0f, 1.0f); + for (int4 channel = 0; channel < 3; channel++) { + const halnf value = std::clamp(before[channel] + (after[channel] - before[channel]) * amount, 0.0f, 1.0f); + pixel[channel] = (uint1) std::lround(value * alpha * 255.0f); + } + pixel[3] = (uint1) std::lround(alpha * 255.0f); + } + } + + void finish(Layer& layer, const TileWork& work, const std::shared_ptr& result) override { + if (layer.sharedTile(work.coord).get() != mSources.get(work.coord.key()).get()) return; + if (result->isTransparent()) { + layer.removeTile(work.coord); + return; + } + layer.replaceTile(work.coord, result); + Operation::finish(layer, work, result); + } + + private: + std::function mFunction; + std::shared_ptr mSelection; + Map> mSources; + }; + } + + Document::FilterResult + Document::applyPixelFunction(const std::function& function, bool changesAlpha) { + settlePaint(); + if (!mActive || !canEdit(*mActive)) return FilterResult::Locked; + if (changesAlpha && mActive->alphaLocked()) return FilterResult::AlphaLocked; + if (mActive->tileCount() == 0) return FilterResult::Empty; + auto operation = std::make_shared( + function, mSelection.isActive() ? std::make_shared(mSelection) : nullptr + ); + const Layer::State before = mActive->state(); + for (auto it : before.tiles) { + const TileCoord coord = it->val.coord; + operation->prepare(coord, it->val.tile); + mEngine.submit({ operation, nullptr, mActive, { coord, 0 } }); + } + return FilterResult::Done; + } + + Document::FilterResult Document::applyFilter(PixelFilter filter) { + switch (filter) { + case PixelFilter::Invert: + return applyPixelFunction( + [](halnf rgba[4]) { + for (int4 channel = 0; channel < 3; channel++) + rgba[channel] = 1.0f - rgba[channel]; + }, + false + ); + case PixelFilter::Desaturate: + return applyPixelFunction( + [](halnf rgba[4]) { + const halnf value = pixelValue(rgba); + rgba[0] = rgba[1] = rgba[2] = value; + }, + false + ); + case PixelFilter::ValueToAlpha: + return applyPixelFunction( + [](halnf rgba[4]) { + rgba[3] *= 1.0f - pixelValue(rgba); + rgba[0] = rgba[1] = rgba[2] = 0; + }, + true + ); + case PixelFilter::ValueToAlphaInverted: + return applyPixelFunction( + [](halnf rgba[4]) { + rgba[3] *= pixelValue(rgba); + rgba[0] = rgba[1] = rgba[2] = 1; + }, + true + ); + } + return FilterResult::Empty; + } + + Document::PixelClip Document::copyPixels() const { + settlePaint(); + PixelClip clip; + if (!mActive) return clip; + int4 left = 0, top = 0, right = 0, bottom = 0; + if (!layerBounds(*mActive, left, top, right, bottom)) return clip; + if (mSelection.isActive() && !mSelection.isInverted()) { + int4 sl = 0, st = 0, sr = 0, sb = 0; + if (!mSelection.bounds(sl, st, sr, sb)) return clip; + left = std::max(left, sl); + top = std::max(top, st); + right = std::min(right, sr); + bottom = std::min(bottom, sb); + if (right <= left || bottom <= top) return clip; + } + clip.left = left; + clip.top = top; + clip.width = right - left; + clip.height = bottom - top; + clip.pixels.assign((size_t) clip.width * clip.height * 4, 0); + bool any = false; + for (int4 y = top; y < bottom; y++) + for (int4 x = left; x < right; x++) { + const TileCoord coord = { floordiv(x, kTileSize), floordiv(y, kTileSize) }; + const Tile* tile = mActive->tileIfExists(coord); + if (!tile) continue; + const uint1* pixel = + tile->pixels() + ((size_t) (y - coord.y * kTileSize) * kTileSize + (size_t) (x - coord.x * kTileSize)) * 4; + const uint4 take = mSelection.isActive() ? mSelection.coverage(x, y) : 255; + uint1* target = clip.pixels.data() + ((size_t) (y - top) * clip.width + (size_t) (x - left)) * 4; + for (int4 channel = 0; channel < 4; channel++) + target[channel] = (uint1) ((pixel[channel] * take + 127) / 255); + any = any || target[3] != 0; + } + if (!any) return {}; + return clip; + } + + Layer* Document::pasteAsLayer(const PixelClip& clip, const std::string& name) { + settlePaint(); + if (clip.isEmpty()) return nullptr; + Layer* layer = addLayer(name); + if (!layer) return nullptr; + for (int4 y = 0; y < clip.height; y++) + for (int4 x = 0; x < clip.width; x++) { + const uint1* source = clip.pixels.data() + ((size_t) y * clip.width + (size_t) x) * 4; + if (source[3] == 0) continue; + const int4 px = clip.left + x; + const int4 py = clip.top + y; + const TileCoord coord = { floordiv(px, kTileSize), floordiv(py, kTileSize) }; + Tile* tile = layer->tileAt(coord); + uint1* target = + tile->pixels() + ((size_t) (py - coord.y * kTileSize) * kTileSize + (size_t) (px - coord.x * kTileSize)) * 4; + std::copy(source, source + 4, target); + tile->markDirty({ 0, 0, (halnf) kTileSize, (halnf) kTileSize }); + } + return layer; + } + + namespace { + void rgbToHsl(const halnf rgb[3], halnf& h, halnf& s, halnf& l) { + const halnf high = std::max({ rgb[0], rgb[1], rgb[2] }); + const halnf low = std::min({ rgb[0], rgb[1], rgb[2] }); + l = (high + low) / 2; + const halnf delta = high - low; + if (delta <= 1e-6f) { + h = s = 0; + return; + } + s = l > 0.5f ? delta / (2 - high - low) : delta / (high + low); + if (high == rgb[0]) h = (rgb[1] - rgb[2]) / delta + (rgb[1] < rgb[2] ? 6.0f : 0.0f); + else if (high == rgb[1]) h = (rgb[2] - rgb[0]) / delta + 2; + else h = (rgb[0] - rgb[1]) / delta + 4; + h /= 6; + } + + halnf hueChannel(halnf p, halnf q, halnf t) { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1.0f / 6) return p + (q - p) * 6 * t; + if (t < 0.5f) return q; + if (t < 2.0f / 3) return p + (q - p) * (2.0f / 3 - t) * 6; + return p; + } + + void hslToRgb(halnf h, halnf s, halnf l, halnf rgb[3]) { + if (s <= 0) { + rgb[0] = rgb[1] = rgb[2] = l; + return; + } + const halnf q = l < 0.5f ? l * (1 + s) : l + s - l * s; + const halnf p = 2 * l - q; + rgb[0] = hueChannel(p, q, h + 1.0f / 3); + rgb[1] = hueChannel(p, q, h); + rgb[2] = hueChannel(p, q, h - 1.0f / 3); + } + + void applyLightness(halnf rgb[3], halnf lightness) { + for (int4 channel = 0; channel < 3; channel++) + rgb[channel] = lightness >= 0 ? rgb[channel] + (1 - rgb[channel]) * lightness : rgb[channel] * (1 + lightness); + } + } + + bool Adjustment::isNeutral() const { + switch (kind) { + case AdjustmentKind::BrightnessExposure: return brightness == 0 && exposure == 0; + case AdjustmentKind::HueSaturation: return hueDegrees == 0 && saturation == 0 && lightness == 0; + case AdjustmentKind::ColorBalance: return red == 0 && green == 0 && blue == 0; + case AdjustmentKind::Colorize: return false; + case AdjustmentKind::Alpha: return alpha == 0; + } + return true; + } + + void Adjustment::apply(halnf rgba[4]) const { + switch (kind) { + case AdjustmentKind::BrightnessExposure: + { + const halnf gain = std::pow(2.0f, exposure); + for (int4 channel = 0; channel < 3; channel++) + rgba[channel] = rgba[channel] * gain + brightness; + break; + } + case AdjustmentKind::HueSaturation: + { + halnf h = 0, s = 0, l = 0; + rgbToHsl(rgba, h, s, l); + h = std::fmod(h + hueDegrees / 360.0f + 1.0f, 1.0f); + s = std::clamp(saturation >= 0 ? s + (1 - s) * saturation : s * (1 + saturation), 0.0f, 1.0f); + hslToRgb(h, s, l, rgba); + applyLightness(rgba, lightness); + break; + } + case AdjustmentKind::ColorBalance: + { + const halnf shift[3] = { red, green, blue }; + for (int4 channel = 0; channel < 3; channel++) + rgba[channel] = shift[channel] >= 0 ? rgba[channel] + (1 - rgba[channel]) * shift[channel] + : rgba[channel] * (1 + shift[channel]); + break; + } + case AdjustmentKind::Colorize: + { + halnf h = 0, s = 0, l = 0; + rgbToHsl(rgba, h, s, l); + hslToRgb(std::fmod(hueDegrees / 360.0f + 1.0f, 1.0f), std::clamp(saturation, 0.0f, 1.0f), l, rgba); + applyLightness(rgba, lightness); + break; + } + case AdjustmentKind::Alpha: rgba[3] = alpha >= 0 ? rgba[3] + (1 - rgba[3]) * alpha : rgba[3] * (1 + alpha); break; + } + } + + bool Document::beginAdjustment() { + settlePaint(); + if (!mActive || !canEdit(*mActive) || mActive->tileCount() == 0) return false; + mAdjustSource = mActive->state(); + mAdjustChanged = false; + return true; + } + + void Document::previewAdjustment(const Adjustment& adjustment) { + if (!mAdjustSource || !mActive) return; + settlePaint(); + mActive->restore(*mAdjustSource); + mAdjustChanged = !adjustment.isNeutral(); + if (!mAdjustChanged) return; + const bool changesAlpha = adjustment.kind == AdjustmentKind::Alpha; + if (changesAlpha && mActive->alphaLocked()) return; + applyPixelFunction([adjustment](halnf rgba[4]) { adjustment.apply(rgba); }, changesAlpha); + } + + bool Document::finishAdjustment() { + if (!mAdjustSource) return false; + const bool changed = mAdjustChanged; + if (!changed && mActive) mActive->restore(*mAdjustSource); + mAdjustSource.reset(); + return changed; + } + + void Document::cancelAdjustment() { + if (!mAdjustSource) return; + if (mActive) mActive->restore(*mAdjustSource); + mAdjustSource.reset(); + } + + bool Document::invertSelection() { + if (!mSelection.isActive()) return false; + mSelection.invert(); + return true; + } + + bool Document::contentBounds(int4& left, int4& top, int4& right, int4& bottom) const { + settlePaint(); + bool any = false; + forEachTile([&](const TileCoord& coord, Tile* tile) { + for (int4 y = 0; y < kTileSize; y++) { + for (int4 x = 0; x < kTileSize; x++) { + if (tile->pixels()[((ualni) y * kTileSize + (ualni) x) * 4 + 3] == 0) continue; + const int4 px = coord.x * kTileSize + x; + const int4 py = coord.y * 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; + } + + CanvasFrame Document::proposedFrame(Vec2F viewCenterWorld) const { + CanvasFrame frame = mFrame; + frame.infinite = false; + + if (frame.isEmpty()) { + int4 left = 0; + int4 top = 0; + int4 right = 0; + int4 bottom = 0; + if (contentBounds(left, top, right, bottom)) { + frame.left = left; + frame.top = top; + frame.right = right; + frame.bottom = bottom; + } else { + const int4 cx = (int4) std::lround(viewCenterWorld.x); + const int4 cy = (int4) std::lround(viewCenterWorld.y); + frame.left = cx - CanvasFrame::kDefaultHalfSize; + frame.top = cy - CanvasFrame::kDefaultHalfSize; + frame.right = cx + CanvasFrame::kDefaultHalfSize; + frame.bottom = cy + CanvasFrame::kDefaultHalfSize; + } + } + + if (frame.width() > CanvasFrame::kMaximumSide) { + const int4 center = (frame.left + frame.right) / 2; + frame.left = center - CanvasFrame::kMaximumSide / 2; + frame.right = center + CanvasFrame::kMaximumSide / 2; + } + if (frame.height() > CanvasFrame::kMaximumSide) { + const int4 center = (frame.top + frame.bottom) / 2; + frame.top = center - CanvasFrame::kMaximumSide / 2; + frame.bottom = center + CanvasFrame::kMaximumSide / 2; + } + frame.clamp(); + return frame; + } + + bool Document::setInfinite(bool infinite, Vec2F viewCenterWorld) { + if (infinite == mFrame.infinite) return false; + if (infinite) mFrame.infinite = true; + else mFrame = proposedFrame(viewCenterWorld); + return true; + } + + bool Document::setFrame(int4 left, int4 top, int4 right, int4 bottom) { + CanvasFrame frame = mFrame; + frame.left = left; + frame.top = top; + frame.right = right; + frame.bottom = bottom; + frame.clamp(); + if (frame == mFrame) return false; + mFrame = frame; + return true; + } + + bool Document::setBackground(const CanvasBackground& background) { + if (background == mBackground) return false; + mBackground = background; + return true; + } + + void Document::restore(const DocumentState& snapshot) { + settlePaint(); + mBackground = snapshot.background; + mFrame = snapshot.frame; + mSelection = snapshot.selection; + std::vector> layers; + layers.reserve(snapshot.layers.size()); + + for (const Layer::State& layerState : snapshot.layers) { + std::unique_ptr layer; + for (auto& existing : mLayers) { + if (existing && existing->id() == layerState.id) layer = std::move(existing); + } + if (!layer) layer = std::make_unique(layerState.id, layerState.properties.name); + layer->restore(layerState); + layers.push_back(std::move(layer)); + } + + mLayers = std::move(layers); + mActive = layerWithId(snapshot.active); + if (!mActive && !mLayers.empty()) mActive = mLayers.back().get(); + mStrokeLayer = nullptr; + mStrokeTiles.removeAll(); + mStrokeMasks.removeAll(); + mPreviewMasks.removeAll(); + mSmudgeCarry.clear(); + } + + void Document::load(const DocumentState& snapshot, const std::string& label) { + restore(snapshot); + for (const auto& layer : mLayers) + mNextLayerId = std::max(mNextLayerId, layer->id() + 1); + mHistory.reset(label, state()); + } + + void Document::commit(const std::string& label, const std::string& mergeKey) { + mHistory.commit(label, state(), mergeKey); + } + + void Document::restoreStrokeBase() { + mSmudgeCarry.clear(); + if (mPreviewLevel >= 0) { + Layer* layer = mStrokeLayer; + dropOpenStroke(); + mStrokeLayer = layer; + if (!layer) return; + for (const Layer::State& layerState : mHistory.current().state.layers) + if (layerState.id == layer->id()) layer->restore(layerState); + return; + } + settlePaint(); + mStrokeMasks.removeAll(); + mPreviewMasks.removeAll(); + if (!mStrokeLayer) return; + for (const Layer::State& layerState : mHistory.current().state.layers) + if (layerState.id == mStrokeLayer->id()) mStrokeLayer->restore(layerState); + } + + void Document::discardStroke() { + settlePaint(); + mStrokeMasks.removeAll(); + mPreviewMasks.removeAll(); + mSmudgeCarry.clear(); + if (!mStrokeLayer) return; + restore(mHistory.current().state); + } + + void Document::dropOpenStroke() { + resetPathStroke(); + const bool open = mOpenTrace && !mOpenTrace->isEmpty(); + if (mPreviewLevel < 0 && !open) return; + if (mStrokeCancelled) mStrokeCancelled->store(true); + mStrokeCancelled.reset(); + mPendingSlots.removeAll(); + mOpenTrace.reset(); + mPreviewMasks.removeAll(); + mStrokeCoverage.removeAll(); + mPreviewLevel = -1; + mLastPreviewDab.reset(); + mStrokeLayer = nullptr; + mPendingStrokeEntries.clear(); + for (const auto& layer : mLayers) + if (layer->levels().previewCount() > 0) { + layer->levels().forEachBasePreview([&](const TileCoord& coord, Tile*) { layer->noteDisplayChanged(coord); }); + layer->levels().discardPreview(); + } + } + + bool Document::undo() { + dropOpenStroke(); + settlePaint(); + const DocumentState* snapshot = mHistory.undo(); + if (snapshot) restore(*snapshot); + return snapshot != nullptr; + } + + bool Document::redo() { + dropOpenStroke(); + settlePaint(); + const DocumentState* snapshot = mHistory.redo(); + if (snapshot) restore(*snapshot); + return snapshot != nullptr; + } + + bool Document::jumpToHistory(size_t index) { + dropOpenStroke(); + settlePaint(); + const DocumentState* snapshot = mHistory.jumpTo(index); + if (snapshot) restore(*snapshot); + return snapshot != nullptr; + } + + Layer* Document::layerWithId(ualni id) const { + for (const auto& layer : mLayers) { + if (layer->id() == id) return layer.get(); + } + return nullptr; + } + + namespace { + constexpr halnf kFullTurn = 6.28318530718f; + + Vec2F rotated(const Vec2F& vector, halnf radians) { + const halnf cosine = std::cos(radians); + const halnf sine = std::sin(radians); + return { vector.x * cosine - vector.y * sine, vector.x * sine + vector.y * cosine }; + } + + halnf normalizedAngle(halnf radians) { + halnf angle = std::fmod(radians, kFullTurn); + if (angle > kFullTurn / 2) angle -= kFullTurn; + if (angle <= -kFullTurn / 2) angle += kFullTurn; + if (std::abs(angle) < 1.0e-6f) angle = 0; + return angle; + } + } + + void Document::pan(const Vec2F& screenDelta) { mPan -= rotated(screenDelta, -mRotation) / mZoom; } + + void Document::setRotation(halnf radians) { mRotation = normalizedAngle(radians); } + + void Document::rotateAt(const Vec2F& screenPoint, halnf radians) { setRotationAt(screenPoint, mRotation + radians); } + + void Document::setRotationAt(const Vec2F& screenPoint, halnf radians) { + const Vec2F worldBefore = screenToWorld(screenPoint); + setRotation(radians); + const Vec2F worldAfter = screenToWorld(screenPoint); + mPan += worldBefore - worldAfter; + } + + void Document::zoomAt(const Vec2F& screenPoint, halnf factor) { + const Vec2F worldBefore = screenToWorld(screenPoint); + mZoom *= factor; + if (mZoom < 0.02f) mZoom = 0.02f; + if (mZoom > 64.0f) mZoom = 64.0f; + const Vec2F worldAfter = screenToWorld(screenPoint); + mPan += worldBefore - worldAfter; + } + + void Document::resetView() { + mPan = { 0, 0 }; + mZoom = 1.0f; + mRotation = 0; + } + + Vec2F Document::screenToWorld(const Vec2F& screen) const { return mPan + rotated(screen, -mRotation) / mZoom; } + + Vec2F Document::worldToScreen(const Vec2F& world) const { return rotated(worldToView(world), mRotation); } + + Vec2F Document::screenToView(const Vec2F& screen) const { return rotated(screen, -mRotation); } + + RectF Document::viewBoundsOfScreen(Vec2F screenSize) const { + const Vec2F corners[] = { screenToView({ 0, 0 }), + screenToView(Vec2F(screenSize.x, 0.0f)), + screenToView(Vec2F(0.0f, screenSize.y)), + screenToView(screenSize) }; + Vec2F low = corners[0]; + Vec2F high = corners[0]; + for (const Vec2F& corner : corners) { + low = Vec2F(std::min(low.x, corner.x), std::min(low.y, corner.y)); + high = Vec2F(std::max(high.x, corner.x), std::max(high.y, corner.y)); + } + return { low.x, low.y, high.x - low.x, high.y - low.y }; + } + + ualni Document::tileCount() const { + settlePaint(); + ualni total = 0; + for (const auto& layer : mLayers) + total += layer->tileCount(); + return total; + } + + void Document::finishStroke(const std::string& label) { + TP_PROFILE_SCOPE("brush.finish_stroke"); + advancePathStroke(true); + const bool deferred = mPathRangeOpen || (mOpenTrace && !mOpenTrace->isEmpty()) || mPreviewLevel >= 0; + flushDeferredDabs(); + resetPathStroke(); + mPainter.flush(); + mSmudgeCarry.clear(); + if (!mStrokeLayer) return; + if (deferred) { + mStrokeTiles.removeAll(); + mStrokeMasks.removeAll(); + mPreviewMasks.removeAll(); + mStrokeCoverage.removeAll(); + mStrokeCancelled.reset(); + mPreviewLevel = -1; + mLastPreviewDab.reset(); + mStrokeLayer = nullptr; + TP_PROFILE_SCOPE("history.commit"); + mHistory.commit(label, stateWithoutSettling(), {}); + for (const std::shared_ptr& entry : mPendingStrokeEntries) + if (*entry == 0) *entry = mHistory.currentEntryRevision(); + mPendingStrokeEntries.clear(); + return; + } + mEngine.drain(); + + if (mStrokeLayer == mActive) { + for (auto it : mStrokeTiles) { + const TileCoord coord = it->val; + Tile* tile = mActive->tileIfExists(coord); + if (tile && tile->isTransparent()) mActive->removeTile(coord); + } + } + mStrokeTiles.removeAll(); + mStrokeMasks.removeAll(); + mPreviewMasks.removeAll(); + mStrokeCoverage.removeAll(); + mStrokeCancelled.reset(); + mPreviewLevel = -1; + mLastPreviewDab.reset(); + mStrokeLayer = nullptr; + TP_PROFILE_SCOPE("history.commit"); + commit(label); + } + + bool Document::isLayerEmpty(const Layer& layer) const { + return layer.tileCount() == 0 && layer.levels().previewCount() == 0 && !mEngine.hasWorkFor(layer); + } + + std::string Document::strokeRefusal(const Brush& brush) const { + if (!mActive) return "Please select a layer"; + if (mActive->locked()) return "You can't edit a locked layer"; + + const bool erase = brush.erases(); + if (erase && mActive->alphaLocked()) return "You can't erase on a alpha locked layer"; + if (erase && isLayerEmpty(*mActive)) return "You can't erase a empty layer"; + if (brush.smudges() && isLayerEmpty(*mActive)) return "You can't smudge a empty layer"; + return {}; + } + + void Document::stampBrushDab(const Vec2F& worldCenter, const Brush& brush, halnf pressure) { + TP_PROFILE_SCOPE("brush.dab"); + TP_PROFILE_COUNT("brush.dabs", 1); + if (brush.smudges()) { + smudgeDab(worldCenter, brush, brush.dabAt(pressure)); + return; + } + stampDab(worldCenter, brush, brush.dabAt(pressure)); + } + + void Document::smudgeDab(const Vec2F& worldCenter, const Brush& brush, const DabShape& dab) { + settlePaint(); + TP_PROFILE_SCOPE("brush.smudge_dab"); + if (!mActive || !canEdit(*mActive)) return; + if (dab.radius() <= 0.0f) return; + + const int4 diameter = (int4) std::ceil(dab.size) + 1; + const int4 originX = (int4) std::floor(worldCenter.x - diameter * 0.5f); + const int4 originY = (int4) std::floor(worldCenter.y - diameter * 0.5f); + const bool linear = brush.linearMixing(); + + SmudgePatch canvas = readSmudgePatch(*mActive, originX, originY, diameter, linear); + const SmudgePatch pickup = blurredSmudgePatch(canvas, smudgeBlurRadius(brush.get(BrushParameter::Blur))); + + if (mSmudgeCarry.isEmpty()) { + mSmudgeCarry.diameter = diameter; + mSmudgeCarry.rgba = pickup.rgba; + } else if (mSmudgeCarry.diameter != diameter) { + resampleSmudgeCarry(mSmudgeCarry, diameter); + } + + const halnf strength = dab.flow * dab.opacity; + const bool keepAlpha = mActive->alphaLocked(); + for (int4 y = 0; y < diameter; y++) { + for (int4 x = 0; x < diameter; x++) { + const halnf wx = (halnf) (originX + x) + 0.5f - worldCenter.x; + const halnf wy = (halnf) (originY + y) + 0.5f - worldCenter.y; + halnf distance = 0; + halnf a = dab.coverage(wx, wy, &distance); + if (a <= 0.0f) continue; + a = dab.textured(a, { (halnf) (originX + x), (halnf) (originY + y) }, distance) * strength; + if (a <= 0.0f) continue; + + if (mSelection.isActive()) { + a *= mSelection.coverage(originX + x, originY + y) / 255.0f; + if (a <= 0.0f) continue; + } + halnf* pixel = canvas.at(x, y); + const halnf* carried = mSmudgeCarry.rgba.data() + ((size_t) y * diameter + x) * 4; + const halnf previousAlpha = pixel[3]; + for (int4 channel = 0; channel < 4; channel++) + pixel[channel] += (carried[channel] - pixel[channel]) * a; + if (keepAlpha) keepSmudgedAlpha(pixel, previousAlpha); + } + } + writeSmudgePatch(canvas, originX, originY, keepAlpha, linear); + + const halnf keep = brush.get(BrushParameter::Smudge); + for (size_t index = 0; index < mSmudgeCarry.rgba.size(); index++) { + halnf& carried = mSmudgeCarry.rgba[index]; + carried = pickup.rgba[index] + (carried - pickup.rgba[index]) * keep; + } + resaturateSmudgeCarry(mSmudgeCarry, brush, brush.get(BrushParameter::Resaturation)); + } + + void Document::writeSmudgePatch(const SmudgePatch& patch, int4 originX, int4 originY, bool keepAlpha, bool linear) { + forEachTileSpan(originX, originY, patch.diameter, [&](TileCoord coord, int4 x0, int4 y0, int4 x1, int4 y1) { + if (keepAlpha && !mActive->tileIfExists(coord)) return; + Tile* tile = mActive->tileAt(coord); + mStrokeLayer = mActive; + mStrokeTiles.put(coord.key(), coord); + + for (int4 y = y0; y < y1; y++) { + for (int4 x = x0; x < x1; x++) { + const int4 lx = x - coord.x * kTileSize; + const int4 ly = y - coord.y * kTileSize; + uint1* target = tile->pixels() + ((ualni) ly * kTileSize + lx) * 4; + encodeMixPixel(patch.at(x - originX, y - originY), target, linear); + } + } + const int4 left = x0 - coord.x * kTileSize; + const int4 top = y0 - coord.y * kTileSize; + tile->markDirty({ (halnf) left, (halnf) top, (halnf) (x1 - x0), (halnf) (y1 - y0) }); + }); + } + + StrokeMask* Document::strokeMask(const TileCoord& coord) { + auto found = mStrokeMasks.presents(coord.key()); + if (found.isValid()) return mStrokeMasks.getSlotVal(found).get(); + auto mask = std::make_shared(); + mask->setBase(mActive->sharedTile(coord)); + StrokeMask* raw = mask.get(); + mStrokeMasks.put(coord.key(), std::move(mask)); + return raw; + } + + void Document::stampDab(const Vec2F& worldCenter, const Brush& brush, const DabShape& requested) { + TP_PROFILE_SCOPE("brush.stamp"); + if (!mActive || !canEdit(*mActive)) return; + + const std::optional traced = traceDab(brush, requested, strokeTarget(), worldCenter); + if (!traced) return; + + const int4 level = deferredLevel(traced->work->marker); + if (level >= 0) { + usePreviewLevel(level); + openTrace().record(*traced); + stagePreviewDab(*traced); + return; + } + if (mPreviewLevel >= 0) settlePaint(); + stageEagerDab(*traced); + } + + void Document::stageEagerDab(const TracedDab& dab) { + const bool masked = dab.masked(); + for (int4 ty = floordiv(dab.minY, kTileSize); ty <= floordiv(dab.maxY, kTileSize); ty++) { + for (int4 tx = floordiv(dab.minX, kTileSize); tx <= floordiv(dab.maxX, kTileSize); tx++) { + const TileCoord coord = { tx, ty }; + const int4 originX = tx * kTileSize; + const int4 originY = ty * kTileSize; + const int4 lx0 = std::max(0, dab.minX - originX); + const int4 ly0 = std::max(0, dab.minY - originY); + const int4 lx1 = std::min(kTileSize, dab.maxX - originX); + const int4 ly1 = std::min(kTileSize, dab.maxY - originY); + if (lx1 <= lx0 || ly1 <= ly0) continue; + if (dab.work->keepAlpha && !mActive->tileIfExists(coord)) continue; + + StrokeMask* mask = masked ? strokeMask(coord) : nullptr; + Tile* tile = mActive->tileAt(coord); + mStrokeLayer = mActive; + mStrokeTiles.put(coord.key(), coord); + mPainter.stage(dab.work, *tile, *mActive, coord, lx0, ly0, lx1, ly1, mask); + TP_PROFILE_COUNT("brush.pixels_touched", (lx1 - lx0) * (ly1 - ly0)); + } + } + } + + DabTarget Document::strokeTarget() { + DabTarget target; + target.keepAlpha = mActive->alphaLocked(); + if (mSelection.isActive()) { + if (!mStrokeSelection || !(*mStrokeSelection == mSelection)) + mStrokeSelection = std::make_shared(mSelection); + target.selection = mStrokeSelection; + } + return target; + } + + int4 Document::deferredLevel(bool marker) const { + const int4 level = LayerLevels::levelForZoom(mZoom); + if (marker) return -1; + if (level > 0 && mActive->levels().builtLevels() < level) return -1; + return level; + } + + void Document::usePreviewLevel(int4 level) { + mStrokeLayer = mActive; + if (mPreviewLevel == level) return; + flushDeferredDabs(); + mPreviewMasks.removeAll(); + mLastPreviewDab.reset(); + mPreviewLevel = level; + } + + StrokeTrace& Document::openTrace() { + if (!mOpenTrace) mOpenTrace = std::make_shared(); + return *mOpenTrace; + } + + void Document::beginPathStroke(const Brush& brush, StrokeMirror mirror, const StrokePath::Sample& first) { + resetPathStroke(); + if (!mActive || !canEdit(*mActive)) return; + mPathStyle = { brush, strokeTarget(), std::move(mirror) }; + mStrokePath.begin(first); + mPathActive = true; + if (brush.smudges()) { + stampBrushDab(first.position, brush, first.pressure); + return; + } + mPathStyle.forEachDab(first.position, first.pressure, [this](TracedDab dab) { + TP_PROFILE_COUNT("brush.dabs", 1); + const int4 level = deferredLevel(dab.work->marker); + if (level < 0) { + if (mPreviewLevel >= 0) settlePaint(); + stageEagerDab(dab); + return; + } + usePreviewLevel(level); + openTrace().record(dab); + stagePreviewDab(dab); + }); + } + + void Document::addPathSample(const StrokePath::Sample& sample) { + if (!mPathActive) return; + mStrokePath.add(sample); + } + + void Document::advancePathStroke(bool finished) { + TP_PROFILE_SCOPE("brush.path"); + if (!mPathActive || !mActive) return; + const halnf end = mStrokePath.endParameter(finished); + const int4 level = mPathStyle.brush.smudges() ? -1 : deferredLevel(mPathStyle.brush.markerBlend()); + if (level <= 0) { + advanceDensePath(end); + return; + } + usePreviewLevel(level); + if (mDenseWalk) { + mRangeStart = { *mDenseWalk, nullptr }; + mPreviewWalk = *mDenseWalk; + mDenseWalk.reset(); + } + advancePreviewPath(level, end); + mPathRangeOpen = true; + mPathRangeTo = std::max(mPathRangeTo, end); + } + + void Document::advanceDensePath(halnf end) { + if (!mDenseWalk) { + flushDeferredDabs(); + mDenseWalk = mRangeStart.walk ? *mRangeStart.walk : mRangeStart.after->endWalk(); + mRangeStart = {}; + } + const Brush& brush = mPathStyle.brush; + mStrokePath.advanceTo( + *mDenseWalk, + end, + [&brush](halnf pressure) { return brush.dabDistance(pressure); }, + [this](const StrokePath::Sample& sample) { + if (mPathStyle.brush.smudges()) { + stampBrushDab(sample.position, mPathStyle.brush, sample.pressure); + return; + } + mPathStyle.forEachDab(sample.position, sample.pressure, [this](TracedDab dab) { + TP_PROFILE_COUNT("brush.dabs", 1); + const int4 level = deferredLevel(dab.work->marker); + if (level < 0) { + if (mPreviewLevel >= 0) settlePaint(); + stageEagerDab(dab); + return; + } + usePreviewLevel(level); + openTrace().record(dab); + stagePreviewDab(dab); + }); + } + ); + mPreviewWalk = *mDenseWalk; + } + + void Document::advancePreviewPath(int4 level, halnf end) { + const halnf scale = (halnf) (1 << level); + const Brush& brush = mPathStyle.brush; + mStrokePath.advanceTo( + mPreviewWalk, + end, + [&brush, scale](halnf pressure) { return std::max(brush.dabDistance(pressure), kPreviewPathStep * scale); }, + [this](const StrokePath::Sample& sample) { + mPathStyle.forEachDab(sample.position, sample.pressure, [this](TracedDab dab) { + TP_PROFILE_COUNT("brush.preview_dabs", 1); + paintPreviewDab(dab); + }); + } + ); + } + + void Document::resetPathStroke() { + mPathActive = false; + mStrokePath = StrokePath(); + mDenseWalk = StrokePath::Walk{}; + mPreviewWalk = {}; + mPathRangeOpen = false; + mPathRangeTo = 0; + mRangeStart = {}; + } + + bool Document::previewNeedsDab(const DabWork& work, int4 level) { + const halnf scale = (halnf) (1 << level); + const DabShape& shape = work.dab.shape(); + const bool sameShape = + mLastPreviewDab && std::abs(mLastPreviewDab->dab.shape().size - shape.size) <= kPreviewDabStep * scale && + mLastPreviewDab->dab.shape().hardness == shape.hardness && + std::abs(mLastPreviewDab->strength - work.strength) < 1.0f / 64.0f && mLastPreviewDab->erase == work.erase; + if (sameShape) { + const Vec2F moved = (work.center - mLastPreviewDab->center) / scale; + if (moved.x * moved.x + moved.y * moved.y < kPreviewDabStep * kPreviewDabStep) return false; + } + mLastPreviewDab = std::make_shared(work); + return true; + } + + void Document::stagePreviewDab(const TracedDab& dab) { + if (mPreviewLevel > 0 && !previewNeedsDab(*dab.work, mPreviewLevel)) return; + paintPreviewDab(dab); + } + + void Document::paintPreviewDab(const TracedDab& traced) { + TP_PROFILE_SCOPE("brush.preview_dab"); + const int4 level = mPreviewLevel; + const int4 span = kTileSize << level; + const std::shared_ptr& work = traced.work; + const int4 minX = traced.minX; + const int4 minY = traced.minY; + const int4 maxX = traced.maxX; + const int4 maxY = traced.maxY; + LayerLevels& levels = mActive->levels(); + for (int4 ty = floordiv(minY, span); ty <= floordiv(maxY, span); ty++) { + for (int4 tx = floordiv(minX, span); tx <= floordiv(maxX, span); tx++) { + const TileCoord coord = { tx, ty }; + if (level == 0) { + stageBasePreview(*work, coord, minX, minY, maxX, maxY); + continue; + } + const bool fresh = !levels.isPreview(level, coord); + StrokeMask* mask = nullptr; + if (traced.masked()) { + auto found = mPreviewMasks.presents(coord.key()); + if (!found.isValid()) { + auto created = std::make_shared(); + Tile* existing = levels.tileAt(level, coord); + if (existing) { + auto base = std::make_shared(Tile::Uninitialized{}); + std::memcpy(base->pixels(), existing->pixels(), (size_t) kTileSize * kTileSize * 4); + created->setBase(base); + } + mPreviewMasks.put(coord.key(), created); + found = mPreviewMasks.presents(coord.key()); + } + mask = mPreviewMasks.getSlotVal(found).get(); + } + Tile* tile = levels.previewTile(level, coord); + if (!tile) continue; + if (fresh && !mask && !levels.tileAt(level, coord)) continue; + const int4 originX = coord.x * kTileSize; + const int4 originY = coord.y * kTileSize; + const int4 x0 = std::max(0, floordiv(minX, 1 << level) - originX); + const int4 y0 = std::max(0, floordiv(minY, 1 << level) - originY); + const int4 x1 = std::min(kTileSize, floordiv(maxX + (1 << level) - 1, 1 << level) + 1 - originX); + const int4 y1 = std::min(kTileSize, floordiv(maxY + (1 << level) - 1, 1 << level) + 1 - originY); + if (x1 <= x0 || y1 <= y0) continue; + StrokePainter::paintAtLevel(*work, level, *tile, coord, x0, y0, x1, y1, mask); + tile->markDirty({ (halnf) x0, (halnf) y0, (halnf) (x1 - x0), (halnf) (y1 - y0) }); + TP_PROFILE_COUNT("brush.preview_pixels", (x1 - x0) * (y1 - y0)); + } + } + } + + void + Document::stageBasePreview(const DabWork& work, const TileCoord& coord, int4 minX, int4 minY, int4 maxX, int4 maxY) { + LayerLevels& levels = mActive->levels(); + const bool fresh = !levels.isPreview(0, coord); + if (fresh && work.keepAlpha && !mActive->tileIfExists(coord)) return; + const bool masked = !work.erase && !work.marker && !work.keepAlpha; + Tile* tile = levels.basePreviewTile(*mActive, coord); + StrokeMask* mask = nullptr; + if (masked) { + auto found = mPreviewMasks.presents(coord.key()); + if (!found.isValid()) { + auto created = std::make_shared(); + if (fresh) { + if (std::shared_ptr source = mActive->sharedTile(coord)) created->setBase(source); + } else { + auto base = std::make_shared(Tile::Uninitialized{}); + std::memcpy(base->pixels(), tile->pixels(), (size_t) kTileSize * kTileSize * 4); + created->setBase(base); + } + mPreviewMasks.put(coord.key(), created); + found = mPreviewMasks.presents(coord.key()); + } + mask = mPreviewMasks.getSlotVal(found).get(); + } + const int4 originX = coord.x * kTileSize; + const int4 originY = coord.y * kTileSize; + const int4 x0 = std::max(0, minX - originX); + const int4 y0 = std::max(0, minY - originY); + const int4 x1 = std::min(kTileSize, maxX - originX); + const int4 y1 = std::min(kTileSize, maxY - originY); + if (x1 <= x0 || y1 <= y0) return; + StrokePainter::paintAtLevel(work, 0, *tile, coord, x0, y0, x1, y1, mask); + tile->markDirty({ (halnf) x0, (halnf) y0, (halnf) (x1 - x0), (halnf) (y1 - y0) }); + mActive->noteDisplayChanged(coord); + TP_PROFILE_COUNT("brush.preview_pixels", (x1 - x0) * (y1 - y0)); + } + + namespace { + class StrokeTileOperation : public Operation { + public: + explicit StrokeTileOperation(std::shared_ptr trace) : + mTrace(std::move(trace)) {} + + [[nodiscard]] const char* name() const override { return "stroke tile"; } + + struct StrokeCoverage { + std::mutex mutex; + std::unique_ptr mask; + }; + + void setCancelled(std::shared_ptr> cancelled) { mCancelled = std::move(cancelled); } + + void setCoverage(const TileCoord& coord, std::shared_ptr coverage) { + std::lock_guard lock(mMutex); + mCoverage.put(coord.key(), std::move(coverage)); + } + + struct Slot { + std::mutex mutex; + std::shared_ptr tile; + bool ready = true; + bool removed = false; + }; + + void setSource( + const TileCoord& coord, + std::shared_ptr source, + std::shared_ptr previous, + std::shared_ptr output + ) { + std::lock_guard lock(mMutex); + mSources.put(coord.key(), std::move(source)); + mPrevious.put(coord.key(), std::move(previous)); + mOutputs.put(coord.key(), std::move(output)); + } + + void run(const TileWork& work, Tile& target) override { + (void) work; + (void) target; + } + + void run(const TileWork& work, const std::shared_ptr& result) override { + Tile& target = *result; + if (mCancelled && mCancelled->load()) { + std::shared_ptr output; + { + std::lock_guard lock(mMutex); + output = mOutputs.get(work.coord.key()); + } + if (output) { + std::lock_guard lock(output->mutex); + output->ready = true; + } + return; + } + std::shared_ptr source; + std::shared_ptr previous; + std::shared_ptr coverage; + { + std::lock_guard lock(mMutex); + source = mSources.get(work.coord.key()); + previous = mPrevious.get(work.coord.key()); + auto found = mCoverage.presents(work.coord.key()); + if (found.isValid()) coverage = mCoverage.getSlotVal(found); + } + mTrace->expand(); + const std::vector* dabs = mTrace->dabsFor(work.coord); + if (previous) { + std::lock_guard lock(previous->mutex); + source = previous->removed ? nullptr : previous->tile; + } + { + std::lock_guard lock(mMutex); + mInputs.put(work.coord.key(), source); + } + if (source) std::memcpy(target.pixels(), source->pixels(), (size_t) kTileSize * kTileSize * 4); + else std::memset(target.pixels(), 0, (size_t) kTileSize * kTileSize * 4); + const int4 originX = work.coord.x * kTileSize; + const int4 originY = work.coord.y * kTileSize; + bool erases = false; + std::unique_ptr ownMask; + std::unique_lock coverageLock; + StrokeMask* mask = nullptr; + if (coverage) { + coverageLock = std::unique_lock(coverage->mutex); + mask = coverage->mask.get(); + } + static const std::vector kNone; + for (const ualni index : dabs ? *dabs : kNone) { + const TracedDab& dab = mTrace->dab(index); + const int4 x0 = std::max(0, dab.minX - originX); + const int4 y0 = std::max(0, dab.minY - originY); + const int4 x1 = std::min(kTileSize, dab.maxX - originX); + const int4 y1 = std::min(kTileSize, dab.maxY - originY); + if (x1 <= x0 || y1 <= y0) continue; + if (dab.masked()) { + if (!mask) { + auto created = std::make_unique(); + created->setBase(source); + mask = created.get(); + if (coverage) coverage->mask = std::move(created); + else ownMask = std::move(created); + } + StrokePainter::paintMasked(*dab.work, *mask, target, work.coord, x0, y0, x1, y1); + } else { + StrokePainter::paint(*dab.work, target, work.coord, x0, y0, x1, y1); + } + erases = erases || dab.work->erase; + } + const bool transparent = erases && target.isTransparent(); + std::shared_ptr output; + { + std::lock_guard lock(mMutex); + if (transparent) mTransparent.put(work.coord.key(), true); + output = mOutputs.get(work.coord.key()); + } + if (output) { + std::lock_guard lock(output->mutex); + output->tile = result; + output->removed = transparent; + output->ready = true; + } + } + + void finish(Layer& layer, const TileWork& work, const std::shared_ptr& result) override { + if (mCancelled && mCancelled->load()) { + std::lock_guard lock(mMutex); + mSources.remove(work.coord.key()); + if (mInputs.presents(work.coord.key()).isValid()) mInputs.remove(work.coord.key()); + mPrevious.remove(work.coord.key()); + mOutputs.remove(work.coord.key()); + if (mCoverage.presents(work.coord.key()).isValid()) mCoverage.remove(work.coord.key()); + return; + } + std::shared_ptr source; + std::shared_ptr submitted; + bool transparent = false; + { + std::lock_guard lock(mMutex); + source = mInputs.get(work.coord.key()); + submitted = mSources.get(work.coord.key()); + transparent = mTransparent.presents(work.coord.key()).isValid(); + mSources.remove(work.coord.key()); + mInputs.remove(work.coord.key()); + mPrevious.remove(work.coord.key()); + mOutputs.remove(work.coord.key()); + mCoverage.remove(work.coord.key()); + } + patchHistory(layer, work.coord, source, submitted, transparent ? nullptr : result); + if (layer.sharedTile(work.coord) != source) return; + if (transparent) { + if (source) layer.removeTile(work.coord); + return; + } + layer.replaceTile(work.coord, result); + Operation::finish(layer, work, result); + } + + using Patch = std::function&, + const std::shared_ptr&, + const std::shared_ptr& + )>; + Patch patchHistory = []( + Layer&, + const TileCoord&, + const std::shared_ptr&, + const std::shared_ptr&, + const std::shared_ptr& + ) {}; + + private: + std::shared_ptr mTrace; + Map> mSources; + Map> mInputs; + Map> mPrevious; + Map> mOutputs; + std::mutex mMutex; + Map mTransparent; + Map> mCoverage; + std::shared_ptr> mCancelled; + }; + } + + void Document::flushDeferredDabs() const { + auto& self = const_cast(*this); + self.closePathRange(); + if (!mOpenTrace || mOpenTrace->isEmpty()) return; + TP_PROFILE_SCOPE("brush.flush_deferred"); + std::shared_ptr trace = std::move(self.mOpenTrace); + Layer* layer = mStrokeLayer; + if (layer) { + auto operation = std::make_shared(trace); + if (!self.mStrokeCancelled) self.mStrokeCancelled = std::make_shared>(false); + operation->setCancelled(self.mStrokeCancelled); + auto pendingEntry = std::make_shared(0); + self.mPendingStrokeEntries.push_back(pendingEntry); + operation->patchHistory = [&self, pendingEntry]( + Layer& target, + const TileCoord& coord, + const std::shared_ptr& input, + const std::shared_ptr& submitted, + const std::shared_ptr& result + ) { + if (*pendingEntry == 0) return; + self.mHistory.forEachEntryFrom(*pendingEntry, [&](auto& entry) { + for (Layer::State& layerState : entry.state.layers) { + if (layerState.id != target.id()) continue; + auto found = layerState.tiles.presents(coord.key()); + if (found.isValid()) { + const std::shared_ptr& stored = layerState.tiles.getSlotVal(found).tile; + if (stored != input && stored != submitted) continue; + if (result) layerState.tiles.getSlotVal(found).tile = result; + else layerState.tiles.remove(coord.key()); + } else if (!input && !submitted && result) { + layerState.tiles.put(coord.key(), { coord, result }); + } + } + }); + }; + const bool keepAlpha = trace->keepsAlpha(); + ualni submitted = 0; + for (const TileCoord& coord : trace->tiles()) { + std::shared_ptr source = layer->sharedTile(coord); + const ualni chainKey = coord.key() ^ ((ualni) (uintptr_t) layer * 0x9E3779B97F4A7C15ull); + std::shared_ptr previous; + auto pending = self.mPendingSlots.presents(chainKey); + if (pending.isValid()) { + previous = std::static_pointer_cast(self.mPendingSlots.getSlotVal(pending)); + if (!self.mEngine.hasWork(*layer, coord)) previous.reset(); + } + if (keepAlpha && !source && !previous) continue; + auto output = std::make_shared(); + output->ready = false; + self.mPendingSlots.put(chainKey, output); + operation->setSource(coord, source, previous, output); + auto coverage = self.mStrokeCoverage.presents(chainKey); + if (!coverage.isValid()) { + self.mStrokeCoverage.put(chainKey, std::make_shared()); + coverage = self.mStrokeCoverage.presents(chainKey); + } + operation->setCoverage( + coord, + std::static_pointer_cast(self.mStrokeCoverage.getSlotVal(coverage)) + ); + self.mEngine.submit({ operation, nullptr, layer, { coord, 0 } }); + submitted++; + } + TP_PROFILE_COUNT("brush.deferred_tiles", submitted); + } + } + + void Document::closePathRange() const { + if (!mPathRangeOpen) return; + auto& self = const_cast(*this); + StrokeTrace::PathRange range; + range.style = mPathStyle; + range.path = StrokePath(mStrokePath.samples()); + range.fromParameter = mRangeStart.walk ? mRangeStart.walk->parameter : 0.0f; + range.toParameter = mPathRangeTo; + range.from = mRangeStart.walk; + range.previous = mRangeStart.after; + self.openTrace().setRange(std::move(range)); + self.mRangeStart = { std::nullopt, mOpenTrace }; + self.mPathRangeOpen = false; + } + + void Document::setPaintWorkers(size_t count) { mPainter.setWorkers(count); } + + void Document::submitPaint() { + TP_PROFILE_SCOPE("brush.submit"); + mPainter.submit(); + TP_PROFILE_SCOPE("engine.apply"); + if (mEngine.threads() == 0) mEngine.runFor(kEngineSliceMs); + else mEngine.applyFor(Engine::kFrameApplyMs); + releaseFinishedPreviews(); + } + + void Document::releaseFinishedPreviews() const { + if (mPathRangeOpen || (mOpenTrace && !mOpenTrace->isEmpty()) || mEngine.pending() > 0) return; + mPendingSlots.removeAll(); + for (const auto& layer : mLayers) + if (layer->levels().previewCount() > 0) layer->levels().endPreview(); + } + + void Document::settlePaint() const { + flushDeferredDabs(); + mPainter.flush(); + mEngine.drain(); + releaseFinishedPreviews(); + } +} diff --git a/Sketch2D/src/libs/document/private/LayerThumbnail.cpp b/Sketch2D/src/libs/document/private/LayerThumbnail.cpp new file mode 100644 index 00000000..f4546efc --- /dev/null +++ b/Sketch2D/src/libs/document/private/LayerThumbnail.cpp @@ -0,0 +1,119 @@ +#include "LayerThumbnail.hpp" + +#include +#include + +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 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 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 LayerThumbnail::renderLevel( + const Layer& layer, const LayerLevels& levels, int4 level, const RectF& worldBounds, int4 size + ) { + (void) layer; + std::vector 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; + } +} diff --git a/Sketch2D/src/libs/document/private/StrokeTrace.cpp b/Sketch2D/src/libs/document/private/StrokeTrace.cpp new file mode 100644 index 00000000..ff1c4764 --- /dev/null +++ b/Sketch2D/src/libs/document/private/StrokeTrace.cpp @@ -0,0 +1,154 @@ +#include "StrokeTrace.hpp" + +#include "DabMask.hpp" + +#include +#include + +namespace tp::sketch2d { + + std::optional + 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(); + 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& emit) const { + const DabShape shape = brush.dabAt(pressure); + if (std::optional dab = traceDab(brush, shape, target, center)) emit(std::move(*dab)); + if (!mirror) return; + if (std::optional 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& 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* StrokeTrace::dabsFor(const TileCoord& coord) const { + auto found = mTileDabs.presents(coord.key()); + return found.isValid() ? &mTileDabs.getSlotVal(found) : nullptr; + } + + std::vector StrokeTrace::tiles() const { + Map tiles = mRecordedTiles; + collectRangeTiles(tiles); + std::vector result; + result.reserve(tiles.size()); + for (auto it : tiles) + result.push_back(it->val); + return result; + } + + void StrokeTrace::collectRangeTiles(Map& 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); + }); + } +} diff --git a/Sketch2D/src/libs/document/public/BrushCursor.hpp b/Sketch2D/src/libs/document/public/BrushCursor.hpp new file mode 100644 index 00000000..91c4ab71 --- /dev/null +++ b/Sketch2D/src/libs/document/public/BrushCursor.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "Brush.hpp" +#include "Document.hpp" +#include "Vec.hpp" + +#include +#include + +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 outline; + + static BrushCursorShape build( + const BrushCursorSettings& settings, + const Brush& brush, + Vec2F center, + halnf zoom, + halnf viewRotation, + halnf pressure = 1.0f + ); + }; +} diff --git a/Sketch2D/src/libs/document/public/CanvasFrameGeometry.hpp b/Sketch2D/src/libs/document/public/CanvasFrameGeometry.hpp new file mode 100644 index 00000000..f06f9567 --- /dev/null +++ b/Sketch2D/src/libs/document/public/CanvasFrameGeometry.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "Document.hpp" + +#include + +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 canvasShadow(const RectF& frame); +} diff --git a/Sketch2D/src/libs/document/public/Checkerboard.hpp b/Sketch2D/src/libs/document/public/Checkerboard.hpp new file mode 100644 index 00000000..83457ae5 --- /dev/null +++ b/Sketch2D/src/libs/document/public/Checkerboard.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "Document.hpp" + +#include + +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 checkerCells(const Document& document, Vec2F viewport); +} diff --git a/Sketch2D/src/libs/document/public/ColorModel.hpp b/Sketch2D/src/libs/document/public/ColorModel.hpp new file mode 100644 index 00000000..f2e24440 --- /dev/null +++ b/Sketch2D/src/libs/document/public/ColorModel.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include "Document.hpp" + +#include +#include +#include + +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 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 entries); + void clear() { mEntries.clear(); } + [[nodiscard]] const std::vector& entries() const { return mEntries; } + + private: + std::vector mEntries; + }; + + class SwatchSet { + public: + static constexpr size_t kCapacity = 64; + + bool add(const RGBA& color); + void remove(size_t index); + [[nodiscard]] const std::vector& colors() const { return mColors; } + + [[nodiscard]] std::string toJson(int8 created, int8 modified) const; + [[nodiscard]] static std::optional fromJson(const std::string& text); + bool save(const std::string& path) const; + [[nodiscard]] static SwatchSet load(const std::string& path); + + private: + std::vector 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; + }; +} diff --git a/Sketch2D/src/libs/document/public/Compositor.hpp b/Sketch2D/src/libs/document/public/Compositor.hpp new file mode 100644 index 00000000..53d330ca --- /dev/null +++ b/Sketch2D/src/libs/document/public/Compositor.hpp @@ -0,0 +1,104 @@ +#pragma once + +#include "Document.hpp" +#include "LayerLevels.hpp" +#include "ParallelFor.hpp" + +#include +#include + +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& layers, const TileCoord& coord, Tile& target); + + struct StackEntry { + const Layer* layer; + Tile* tile; + }; + + static void compositeRows(const std::vector& stack, uint1* out, int4 x0, int4 x1, int4 y); + [[nodiscard]] static ualni signatureOf(const std::vector& 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; + 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 + void forEachTile(Fn&& fn) const { + forEachTile(mLevel, fn); + } + template + 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& lastChanged() const { return mChanged; } + [[nodiscard]] const std::vector>& lastRemoved() const { return mRemoved; } + + private: + [[nodiscard]] static std::vector stackAt(const Document& document, const TileCoord& coord); + [[nodiscard]] static DirtyBands pendingChanges(const std::vector& stack); + static void composite(const std::vector& stack, Tile& target, const PixelRect& rect); + static void composite(const std::vector& stack, Tile& target, const DirtyBands& bands); + [[nodiscard]] static bool isPlainCopy(const std::vector& stack); + [[nodiscard]] static bool isNormalStack(const std::vector& 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 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 mChanged; + std::vector> mRemoved; + }; +} diff --git a/Sketch2D/src/libs/document/public/Document.hpp b/Sketch2D/src/libs/document/public/Document.hpp new file mode 100644 index 00000000..4d4a767a --- /dev/null +++ b/Sketch2D/src/libs/document/public/Document.hpp @@ -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 +#include +#include +#include +#include + +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 layers; + ualni active = 0; + CanvasBackground background; + CanvasFrame frame; + Selection selection; + }; + + struct SmudgePatch { + int4 diameter = 0; + std::vector 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 + void forEachVisibleTile(Fn&& fn) const { + for (const auto& layer : mLayers) { + if (!layer->visible()) continue; + layer->forEachTile(fn); + } + } + + template + 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 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 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& 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 + 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>& layers() const { return mLayers; } + [[nodiscard]] ualni layerCount() const { return mLayers.size(); } + [[nodiscard]] std::string nextLayerName() const { return uniqueLayerName("Layer"); } + + void clearActiveLayer(); + [[nodiscard]] std::vector mergeCandidates() const; + bool mergeLayers(const std::vector& layers); + bool mergeVisibleLayers(); + bool bakeOpacity(Layer& layer); + void setLayerVisible(Layer& layer, bool visible); + void setLayerOpacity(Layer& layer, halnf opacity); + + [[nodiscard]] std::vector 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& history() { return mHistory; } + [[nodiscard]] const History& history() const { return mHistory; } + bool undo(); + bool redo(); + bool jumpToHistory(size_t index); + + private: + Selection mSelection; + std::shared_ptr mStrokeSelection; + std::optional mMoveSource; + std::optional mTransformSource; + std::optional 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> mLayers; + Layer* mActive = nullptr; + CanvasBackground mBackground; + CanvasFrame mFrame; + ualni mNextLayerId = 1; + + mutable StrokePainter mPainter; + mutable Engine mEngine; + Layer* mStrokeLayer = nullptr; + Map mStrokeTiles; + Map> mStrokeMasks; + mutable Map> mPreviewMasks; + mutable Map> mPendingSlots; + mutable Map> mStrokeCoverage; + struct RangeStart { + std::optional walk = StrokePath::Walk{}; + std::shared_ptr after; + }; + mutable std::shared_ptr mOpenTrace; + StrokeStyle mPathStyle; + StrokePath mStrokePath; + std::optional mDenseWalk = StrokePath::Walk{}; + StrokePath::Walk mPreviewWalk; + mutable RangeStart mRangeStart; + mutable bool mPathRangeOpen = false; + mutable halnf mPathRangeTo = 0; + bool mPathActive = false; + mutable std::shared_ptr> mStrokeCancelled; + StrokeMask* strokeMask(const TileCoord& coord); + std::vector> mPendingStrokeEntries; + int4 mPreviewLevel = -1; + std::shared_ptr 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 mHistory; + + Vec2F mPan = { 0, 0 }; + halnf mZoom = 1.0f; + halnf mRotation = 0; + }; +} diff --git a/Sketch2D/src/libs/document/public/LayerThumbnail.hpp b/Sketch2D/src/libs/document/public/LayerThumbnail.hpp new file mode 100644 index 00000000..815dee19 --- /dev/null +++ b/Sketch2D/src/libs/document/public/LayerThumbnail.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include "Document.hpp" +#include "LayerLevels.hpp" + +#include + +namespace tp::sketch2d { + + class LayerThumbnail { + public: + static constexpr int4 kSize = 40; + + [[nodiscard]] static RectF documentBounds(const Document& document); + [[nodiscard]] static std::vector render(const Layer& layer, const RectF& worldBounds, int4 size = kSize); + [[nodiscard]] static std::vector + renderLevel(const Layer& layer, const LayerLevels& levels, int4 level, const RectF& worldBounds, int4 size); + }; +} diff --git a/Sketch2D/src/libs/document/public/StrokeTrace.hpp b/Sketch2D/src/libs/document/public/StrokeTrace.hpp new file mode 100644 index 00000000..62434d68 --- /dev/null +++ b/Sketch2D/src/libs/document/public/StrokeTrace.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include "Brush.hpp" +#include "StrokePainter.hpp" +#include "StrokePath.hpp" +#include "Tile.hpp" + +#include "Map.hpp" + +#include +#include +#include +#include +#include + +namespace tp::sketch2d { + + using StrokeMirror = std::function; + + struct TracedDab { + std::shared_ptr 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 selection; + }; + + [[nodiscard]] std::optional + 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& emit) const; + }; + + class StrokeTrace { + public: + struct PathRange { + StrokeStyle style; + StrokePath path; + halnf fromParameter = 0; + halnf toParameter = 0; + std::optional from; + std::shared_ptr 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* dabsFor(const TileCoord& coord) const; + [[nodiscard]] const TracedDab& dab(ualni index) const { return mDabs[index]; } + [[nodiscard]] std::vector 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& tiles) const; + + std::optional mRange; + std::once_flag mExpanded; + StrokePath::Walk mEndWalk; + std::vector mDabs; + Map> mTileDabs; + Map mRecordedTiles; + size_t mRecorded = 0; + bool mKeepsAlpha = false; + }; +} diff --git a/Sketch2D/src/libs/document/public/ViewSettings.hpp b/Sketch2D/src/libs/document/public/ViewSettings.hpp new file mode 100644 index 00000000..e7ec6cd8 --- /dev/null +++ b/Sketch2D/src/libs/document/public/ViewSettings.hpp @@ -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 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; + }; +} diff --git a/Sketch2D/src/libs/engine/private/Engine.cpp b/Sketch2D/src/libs/engine/private/Engine.cpp new file mode 100644 index 00000000..ec5d8681 --- /dev/null +++ b/Sketch2D/src/libs/engine/private/Engine.cpp @@ -0,0 +1,240 @@ +#include "Engine.hpp" +#include "ThreadPlacement.hpp" + +#include +#include +#include +#include + +namespace tp::sketch2d { + + void Operation::finish(Layer& layer, const TileWork& work, const std::shared_ptr& 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& 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::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(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& queue : mQueues) + if (!queue.empty()) return false; + return true; + }); + } + apply(); + } + + void Engine::apply() { applyFor(std::numeric_limits::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(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& 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& 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(); + } +} diff --git a/Sketch2D/src/libs/engine/private/Layer.cpp b/Sketch2D/src/libs/engine/private/Layer.cpp new file mode 100644 index 00000000..f83d68b2 --- /dev/null +++ b/Sketch2D/src/libs/engine/private/Layer.cpp @@ -0,0 +1,118 @@ +#include "Layer.hpp" +#include "LayerLevels.hpp" + +#include + +namespace tp::sketch2d { + + namespace { + ualni nextSerial() { + static std::atomic 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(); + 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 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() }); + 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 Layer::replaceTile(const TileCoord& coord, std::shared_ptr tile) { + noteChanged(coord); + std::shared_ptr 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; + } +} diff --git a/Sketch2D/src/libs/engine/private/LayerLevels.cpp b/Sketch2D/src/libs/engine/private/LayerLevels.cpp new file mode 100644 index 00000000..e9eb6e09 --- /dev/null +++ b/Sketch2D/src/libs/engine/private/LayerLevels.cpp @@ -0,0 +1,318 @@ +#include "LayerLevels.hpp" + +#include + +#include +#include + +namespace tp::sketch2d { + + namespace { + constexpr int4 kHalfTile = kTileSize / 2; + const std::vector 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 + void LayerLevels::updateLevel( + int4 level, + const Lookup& children, + const std::vector& changedChildren, + std::vector& changedParents, + ParallelFor* parallel + ) { + Level& target = mLevels[(size_t) level - 1]; + Map parents; + Map 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 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() }); + 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 LayerLevels::holdBackPending(std::vector changed) { + mPending.clear(); + for (int4 level = 1; level < kLevels; level++) + mStale[level].removeAll(); + if (!mIsPending) return changed; + + Map 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 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& 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& 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 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 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 original = found.isValid() ? target.getSlotVal(found).tile : nullptr; + mOriginals[level].put(coord.key(), { coord, original }); + auto copy = std::make_shared(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::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& 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& LayerLevels::lastChanged(int4 level) const { + if (level < 0 || level >= kLevels) return kNoChanges; + return mChanged[level]; + } +} diff --git a/Sketch2D/src/libs/engine/private/Selection.cpp b/Sketch2D/src/libs/engine/private/Selection.cpp new file mode 100644 index 00000000..e2ed0b57 --- /dev/null +++ b/Sketch2D/src/libs/engine/private/Selection.cpp @@ -0,0 +1,351 @@ +#include "Selection.hpp" + +#include +#include + +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& slot = mTiles[key(tx, ty)]; + if (!slot) slot = std::make_shared(); + else if (slot.use_count() > 1) slot = std::make_shared(*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& 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((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 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& 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 hits((size_t) width * height, 0); + std::vector 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 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 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 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 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 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> Selection::outline() const { + std::vector> 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; + } +} diff --git a/Sketch2D/src/libs/engine/private/StrokePainter.cpp b/Sketch2D/src/libs/engine/private/StrokePainter.cpp new file mode 100644 index 00000000..427c0abd --- /dev/null +++ b/Sketch2D/src/libs/engine/private/StrokePainter.cpp @@ -0,0 +1,268 @@ +#include "StrokePainter.hpp" +#include "Layer.hpp" + +#include +#include +#include + +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::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>(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& 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& 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& 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 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); + } +} diff --git a/Sketch2D/src/libs/engine/private/Tile.cpp b/Sketch2D/src/libs/engine/private/Tile.cpp new file mode 100644 index 00000000..30011106 --- /dev/null +++ b/Sketch2D/src/libs/engine/private/Tile.cpp @@ -0,0 +1,139 @@ +#include "Tile.hpp" + +#include +#include +#include + +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::takeForWriting() { + auto copy = std::make_shared(); + 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; + } + +} diff --git a/Sketch2D/src/libs/engine/public/Engine.hpp b/Sketch2D/src/libs/engine/public/Engine.hpp new file mode 100644 index 00000000..e659b33b --- /dev/null +++ b/Sketch2D/src/libs/engine/public/Engine.hpp @@ -0,0 +1,111 @@ +#pragma once + +#include "Layer.hpp" +#include "LayerLevels.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +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& target) { run(work, *target); } + virtual void finish(Layer& layer, const TileWork& work, const std::shared_ptr& result); + }; + + class Engine { + public: + struct Job { + std::shared_ptr operation; + std::shared_ptr 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 + void forEachPending(Fn&& fn) const { + std::lock_guard lock(mQueueMutex); + for (const std::deque& 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 + 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 mQueues[LayerLevels::kLevels]; + Map mQueued; + Map mBusy; + mutable std::mutex mQueueMutex; + std::condition_variable mWake; + std::condition_variable mIdle; + size_t mActive = 0; + std::vector> mRunning; + bool mStopping = false; + + mutable std::mutex mDoneMutex; + std::deque mDone; + std::vector mThreads; + std::atomic mCompleted{ 0 }; + std::atomic mPausedBy{}; + }; +} diff --git a/Sketch2D/src/libs/engine/public/Layer.hpp b/Sketch2D/src/libs/engine/public/Layer.hpp new file mode 100644 index 00000000..f4316425 --- /dev/null +++ b/Sketch2D/src/libs/engine/public/Layer.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include "Map.hpp" +#include "Tile.hpp" + +#include +#include + +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; + + 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 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 sharedTile(const TileCoord& coord) const; + std::shared_ptr replaceTile(const TileCoord& coord, std::shared_ptr tile); + [[nodiscard]] ualni tileCount() const { return mTiles.size(); } + + template + 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 + void forEachLevelChanged(Fn&& fn) const { + for (auto it : mLevelChanged) + fn(it->val); + } + void clearLevelChanges() const { mLevelChanged.removeAll(); } + template + 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 mTiles; + mutable Map mChanged; + mutable Map mLevelChanged; + mutable std::unique_ptr mLevels; + }; +} diff --git a/Sketch2D/src/libs/engine/public/LayerLevels.hpp b/Sketch2D/src/libs/engine/public/LayerLevels.hpp new file mode 100644 index 00000000..da346ad0 --- /dev/null +++ b/Sketch2D/src/libs/engine/public/LayerLevels.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include "Layer.hpp" +#include "ParallelFor.hpp" + +#include +#include +#include + +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; + + 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& lastChanged(int4 level) const; + template + 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>& lastRemoved() const { return mRemoved; } + + template + 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 + 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 holdBackPending(std::vector changed); + void markStale(const TileCoord& child); + [[nodiscard]] bool anyChildPending(const TileCoord& parent) const; + + struct LevelTile { + TileCoord coord; + std::shared_ptr tile; + }; + using Level = Map; + + template + void updateLevel( + int4 level, + const Lookup& children, + const std::vector& changedChildren, + std::vector& changedParents, + ParallelFor* parallel + ); + + Level mLevels[kLevels - 1]; + std::vector mChanged[kLevels]; + int4 mBuilt = 0; + ualni mLastDownsampled = 0; + ualni mLastPixels = 0; + std::vector> mRemoved; + std::vector mPending; + Map mAccumulated[kLevels]; + Map mPreview[kLevels]; + Map mOriginals[kLevels]; + Level mBase; + std::vector mReleased[kLevels]; + Map mStale[kLevels]; + PendingCheck mIsPending; + ualni mRebuilds = 0; + }; +} diff --git a/Sketch2D/src/libs/engine/public/Selection.hpp b/Sketch2D/src/libs/engine/public/Selection.hpp new file mode 100644 index 00000000..57b345a8 --- /dev/null +++ b/Sketch2D/src/libs/engine/public/Selection.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "Tile.hpp" + +#include +#include +#include + +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& points, SelectionMode mode); + void addMask(int4 left, int4 top, int4 width, const std::vector& mask, SelectionMode mode); + void spread(int4 pixels); + void blur(int4 radius); + + [[nodiscard]] std::vector> 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> mTiles; + bool mActive = false; + bool mInverted = false; + }; +} diff --git a/Sketch2D/src/libs/engine/public/StrokeMask.hpp b/Sketch2D/src/libs/engine/public/StrokeMask.hpp new file mode 100644 index 00000000..0080d93d --- /dev/null +++ b/Sketch2D/src/libs/engine/public/StrokeMask.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "Tile.hpp" + +#include +#include +#include + +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& base() const { return mBase; } + void setBase(std::shared_ptr 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 mCoverage; + std::shared_ptr mBase; + }; +} diff --git a/Sketch2D/src/libs/engine/public/StrokePainter.hpp b/Sketch2D/src/libs/engine/public/StrokePainter.hpp new file mode 100644 index 00000000..b181c0d9 --- /dev/null +++ b/Sketch2D/src/libs/engine/public/StrokePainter.hpp @@ -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 +#include +#include +#include + +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 selection; + }; + + struct DabJob { + std::shared_ptr 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& 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 + 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> mLanes; + std::vector> mStaged; + std::vector> mNotes; + size_t mStagedCount = 0; + std::atomic mWorkerNs{ 0 }; + std::atomic mPausedBy{}; + }; +} diff --git a/Sketch2D/src/libs/engine/public/Tile.hpp b/Sketch2D/src/libs/engine/public/Tile.hpp new file mode 100644 index 00000000..a9b2f2fd --- /dev/null +++ b/Sketch2D/src/libs/engine/public/Tile.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include "Environment.hpp" +#include "Vec.hpp" +#include "Rect.hpp" +#include "Color.hpp" + +#include + +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 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; + }; +} diff --git a/Sketch2D/src/libs/library/private/Session.cpp b/Sketch2D/src/libs/library/private/Session.cpp new file mode 100644 index 00000000..0c9ed860 --- /dev/null +++ b/Sketch2D/src/libs/library/private/Session.cpp @@ -0,0 +1,673 @@ +#include "Session.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +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(); + 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 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& 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::parse(const std::string& text) { + const std::optional 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::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(coordinates), sizeof(coordinates)); + bytes.append(reinterpret_cast(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(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::load(const std::string& directory) { + const std::filesystem::path root(directory); + std::optional snapshot = SessionSnapshot::parse(readBytes(root / "session.txt")); + std::optional 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; + } +} diff --git a/Sketch2D/src/libs/library/private/SketchLibrary.cpp b/Sketch2D/src/libs/library/private/SketchLibrary.cpp new file mode 100644 index 00000000..a042abf6 --- /dev/null +++ b/Sketch2D/src/libs/library/private/SketchLibrary.cpp @@ -0,0 +1,350 @@ +#include "SketchLibrary.hpp" + +#include "LayerThumbnail.hpp" +#include "Session.hpp" + +#include +#include +#include +#include +#include +#include + +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& 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 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(preview.rgba.data()), preview.rgba.size()); + written = written && writeFileBytes(pathOf(directory, kPreviewFile).string(), kPreviewHeader + bytes); + } + return written; + } + + std::optional 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 preview = loadPreview(directory)) sketch.preview = std::move(*preview); + return sketch; + } + + std::optional 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 SketchLibrary::storedOrder(const std::string& folder) const { + std::istringstream input(readFileBytes(pathOf(directoryOf(folder), kOrderFile).string())); + std::vector names; + std::string name; + while (std::getline(input, name)) + if (!name.empty()) names.push_back(name); + return names; + } + + std::vector SketchLibrary::children(const std::string& folder) const { + std::vector 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(written.time_since_epoch()).count(); + items.push_back({ LibraryItem::Kind::Sketch, name, path, entry.path().string(), (int8) seconds }); + } + + const std::vector 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 SketchLibrary::list() const { + std::vector entries; + std::vector 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 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& names) const { + std::string text; + for (const std::string& name : names) + text += name + "\n"; + return writeFileBytes(pathOf(directoryOf(folder), kOrderFile).string(), text); + } +} diff --git a/Sketch2D/src/libs/library/public/Session.hpp b/Sketch2D/src/libs/library/public/Session.hpp new file mode 100644 index 00000000..fd734f9f --- /dev/null +++ b/Sketch2D/src/libs/library/public/Session.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include "ViewSettings.hpp" +#include "Document.hpp" +#include "InputRecording.hpp" +#include "SessionRecording.hpp" + +#include +#include +#include + +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 parse(const std::string& text); + }; + + struct SessionPreferences { + Brush brush; + ViewSettings settings; + CanvasBackground background; + + [[nodiscard]] std::string serialize() const; + [[nodiscard]] static std::optional 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 load(const std::string& directory); + }; + + bool saveSessionSnapshot(const std::string& directory, const SessionSnapshot& snapshot); +} diff --git a/Sketch2D/src/libs/library/public/SketchLibrary.hpp b/Sketch2D/src/libs/library/public/SketchLibrary.hpp new file mode 100644 index 00000000..3520525b --- /dev/null +++ b/Sketch2D/src/libs/library/public/SketchLibrary.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include "AppStorage.hpp" + +#include "Document.hpp" +#include "Session.hpp" + +#include +#include +#include + +namespace tp::sketch2d { + + struct SketchPreview { + static constexpr int4 kSize = 256; + int4 size = kSize; + + std::vector 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 view; + + [[nodiscard]] static SketchFile capture(const Document& document); + bool save(const std::string& directory) const; + [[nodiscard]] static std::optional load(const std::string& directory); + [[nodiscard]] static std::optional 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 list() const; + [[nodiscard]] std::vector 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& 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 storedOrder(const std::string& folder) const; + + std::string mRoot; + }; +} diff --git a/Sketch2D/src/libs/render/private/CanvasPreviewImages.cpp b/Sketch2D/src/libs/render/private/CanvasPreviewImages.cpp new file mode 100644 index 00000000..a7d6031e --- /dev/null +++ b/Sketch2D/src/libs/render/private/CanvasPreviewImages.cpp @@ -0,0 +1,34 @@ +#include "CanvasPreviewImages.hpp" + +namespace tp::sketch2d { + + CanvasPreviewImages::CanvasPreviewImages(Canvas& canvas) : + mCanvas(canvas) {} + + CanvasPreviewImages::~CanvasPreviewImages() { + for (auto& entry : mImages) + mCanvas.deleteImage(entry.second); + } + + ImageHandle CanvasPreviewImages::image(const std::string& key, const SketchPreview& preview) { + auto found = mImages.find(key); + if (found != mImages.end()) return { found->second.id }; + + const ImageHandle handle = + mCanvas.createImageFromMemoryRGBA(preview.size, preview.size, preview.rgba.data()); + mImages[key] = handle; + return { handle.id }; + } + + ImageHandle CanvasPreviewImages::cached(const std::string& key) const { + auto found = mImages.find(key); + return found == mImages.end() ? ImageHandle{} : ImageHandle{ found->second.id }; + } + + void CanvasPreviewImages::forget(const std::string& key) { + auto found = mImages.find(key); + if (found == mImages.end()) return; + mCanvas.deleteImage(found->second); + mImages.erase(found); + } +} diff --git a/Sketch2D/src/libs/render/private/CanvasThumbnails.cpp b/Sketch2D/src/libs/render/private/CanvasThumbnails.cpp new file mode 100644 index 00000000..06234d2a --- /dev/null +++ b/Sketch2D/src/libs/render/private/CanvasThumbnails.cpp @@ -0,0 +1,33 @@ +#include "CanvasThumbnails.hpp" +#include "Profiling.hpp" + +namespace tp::sketch2d { + + CanvasThumbnails::CanvasThumbnails(Canvas& canvas) : + mCanvas(canvas) {} + + CanvasThumbnails::~CanvasThumbnails() { + for (auto entry : mImages) + mCanvas.deleteImage(entry->val); + } + + ImageHandle CanvasThumbnails::thumbnail(const Document& document, const Layer& layer) { + TP_PROFILE_SCOPE("layers.thumbnail"); + std::vector pixels; + document.whilePaintPaused([&]() { + pixels = LayerThumbnail::render(layer, LayerThumbnail::documentBounds(document)); + }); + + auto slot = mImages.presents(layer.id()); + if (slot.isValid()) { + const ImageHandle image = mImages.getSlotVal(slot); + mCanvas.updateImageRGBA(image, pixels.data()); + return { image.id }; + } + + const ImageHandle image = + mCanvas.createImageFromMemoryRGBA(LayerThumbnail::kSize, LayerThumbnail::kSize, pixels.data()); + mImages.put(layer.id(), image); + return { image.id }; + } +} diff --git a/Sketch2D/src/libs/render/private/CanvasViewport.cpp b/Sketch2D/src/libs/render/private/CanvasViewport.cpp new file mode 100644 index 00000000..401eab5f --- /dev/null +++ b/Sketch2D/src/libs/render/private/CanvasViewport.cpp @@ -0,0 +1,54 @@ +#include "CanvasViewport.hpp" + +#include +#include + +namespace tp::sketch2d { + + CanvasViewport::CanvasViewport(Canvas& canvas, TileRenderer& renderer) : + mCanvas(canvas), + mRenderer(renderer), + mImage(canvas.createImageFromTextId(renderer.textureId(), { 0, 0 })) {} + + CanvasViewport::~CanvasViewport() { mCanvas.deleteImage(mImage); } + + void CanvasViewport::render(Document& document, const ViewSettings& settings, Vec2F viewportSize) { + mLastDocument = &document; + mRenderer.setViewportSize(viewportSize); + mRenderer.render(document, settings); + mCanvas.updateTextureID(mImage, mRenderer.textureId()); + } + + std::string CanvasViewport::statistics() const { + char text[160]; + std::snprintf( + text, + sizeof(text), + "uploaded %llu pixels in %llu tiles | %llu tiles drawn at mip %d | %llu live", + (unsigned long long) mRenderer.uploadedPixelsLastFrame(), + (unsigned long long) mRenderer.uploadedTilesLastFrame(), + (unsigned long long) mRenderer.drawnTilesLastFrame(), + (int) mRenderer.mipLevelLastFrame(), + (unsigned long long) (mLastDocument ? mLastDocument->tileCount() : 0) + ); + std::string result = text; + result += " | " + mRenderer.debugLegend(); + if (mLastDocument) + result += "| engine " + std::to_string(mLastDocument->engine().pending()) + " paint " + + std::to_string(mLastDocument->paintBacklog()); + return result; + } + + std::vector CanvasViewport::debugLegend() const { + static const char* const kNames[] = { "done", "uploading", "not on GPU", "preview", + "queued", "working", "stale", "empty" }; + std::vector entries; + for (int4 index = 0; index < (int4) TileDebugState::Count; index++) { + const TileDebugState state = (TileDebugState) index; + RGBA color = TileRenderer::debugColor(state); + color.a = std::min(1.0f, color.a * 2.5f); + entries.push_back({ std::string(kNames[index]) + " " + std::to_string(mRenderer.debugCount(state)), color }); + } + return entries; + } +} diff --git a/Sketch2D/src/libs/render/private/IconSet.cpp b/Sketch2D/src/libs/render/private/IconSet.cpp new file mode 100644 index 00000000..30b9badb --- /dev/null +++ b/Sketch2D/src/libs/render/private/IconSet.cpp @@ -0,0 +1,40 @@ +#include "IconSet.hpp" + +namespace tp::sketch2d { + + namespace { + const char* const kIconNames[] = { + "brush", "eraser", "smudge", "eyedropper", "line", "move", "wand", "lasso", "rectselect", "fill", + "export", "history", "hand", "tool", "color", "size", "zoom", "undo", "redo", "select", + "view", "layers", "library", "settings", "menu", "rotate", "gradient", "transform", "crop", "edit", + }; + } + + IconSet::IconSet(Canvas& canvas, const std::string& directory) : + mCanvas(canvas) { + for (const char* name : kIconNames) { + const std::string path = directory + "/" + name + ".png"; + mIcons.put(hash(name), canvas.createImageFromFile(path.c_str())); + } + } + + IconSet::~IconSet() { + for (auto entry : mIcons) + mCanvas.deleteImage(entry->val); + } + + ImageHandle IconSet::icon(const std::string& name) const { + const auto slot = mIcons.presents(hash(name)); + if (!slot.isValid()) return {}; + return { mIcons.getSlotVal(slot).id }; + } + + ualni IconSet::hash(const std::string& name) { + ualni value = 1469598103934665603ULL; + for (char character : name) { + value ^= (ualni) (unsigned char) character; + value *= 1099511628211ULL; + } + return value; + } +} diff --git a/Sketch2D/src/libs/render/private/TileRenderer.cpp b/Sketch2D/src/libs/render/private/TileRenderer.cpp new file mode 100644 index 00000000..a62362c2 --- /dev/null +++ b/Sketch2D/src/libs/render/private/TileRenderer.cpp @@ -0,0 +1,978 @@ +#include "TileRenderer.hpp" +#include "Profiling.hpp" + +#include "VulkanContext.hpp" +#include "VulkanRenderTarget.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + + const uint32_t kTileVert[] = +#include "tile.vert.inc" + ; + + const uint32_t kTileFrag[] = +#include "tile.frag.inc" + ; + + const uint32_t kSolidVert[] = +#include "solid.vert.inc" + ; + + const uint32_t kSolidFrag[] = +#include "solid.frag.inc" + ; + + struct TilePush { + float screenPos[2]; + float screenSize[2]; + float viewport[2]; + float rotation[2]; + float channelRows[16]; + float clip[4]; + }; + + constexpr tp::sketch2d::ChannelMatrix kIdentityChannels = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + + struct SolidPush { + float rect[4]; + float viewportAndRotation[4]; + float color[4]; + }; + +} + +namespace tp::sketch2d { + + TileRenderer::TileRenderer(VulkanContext* context) : + mContext(context), + mBuffer({ 16, 16 }), + mParallel(std::max(2u, std::thread::hardware_concurrency() / 2)) { + mCompositor.setParallel(&mParallel); + mBuffer.mClearCol = { 0.11f, 0.11f, 0.13f, 1.0f }; + mBuffer.measureGpuTime("gpu.canvas"); + + createSampler(); + createDescriptorPool(); + + auto* target = static_cast(mBuffer.renderTarget()); + createPipeline(target->renderPass()); + createSolidPipeline(target->renderPass()); + } + + TileRenderer::~TileRenderer() { + const VkDevice device = mContext->device(); + mContext->waitIdle(); + + for (auto it : mGpuTiles) + destroyGpuTile(it->val); + for (const RetiredGpuTile& retired : mRetiredGpuTiles) + destroyGpuTile(retired.gpu); + destroyStaging(); + + if (mPipeline) vkDestroyPipeline(device, mPipeline, nullptr); + if (mPipelineLayout) vkDestroyPipelineLayout(device, mPipelineLayout, nullptr); + if (mSolidPipeline) vkDestroyPipeline(device, mSolidPipeline, nullptr); + if (mSolidPipelineLayout) vkDestroyPipelineLayout(device, mSolidPipelineLayout, nullptr); + if (mDescriptorLayout) vkDestroyDescriptorSetLayout(device, mDescriptorLayout, nullptr); + if (mDescriptorPool) vkDestroyDescriptorPool(device, mDescriptorPool, nullptr); + if (mSampler) vkDestroySampler(device, mSampler, nullptr); + } + + void TileRenderer::createSampler() { + VkSamplerCreateInfo info{ + .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, + .magFilter = VK_FILTER_NEAREST, + .minFilter = VK_FILTER_LINEAR, + .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST, + .addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .maxLod = 1.0f, + .borderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK, + }; + if (vkCreateSampler(mContext->device(), &info, nullptr, &mSampler) != VK_SUCCESS) { + throw std::runtime_error("sketch2d: sampler create failed"); + } + } + + void TileRenderer::createDescriptorPool() { + VkDescriptorPoolSize poolSize{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 16384 }; + VkDescriptorPoolCreateInfo info{ + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, + .flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, + .maxSets = 16384, + .poolSizeCount = 1, + .pPoolSizes = &poolSize, + }; + if (vkCreateDescriptorPool(mContext->device(), &info, nullptr, &mDescriptorPool) != VK_SUCCESS) { + throw std::runtime_error("sketch2d: descriptor pool create failed"); + } + + VkDescriptorSetLayoutBinding binding{ + .binding = 0, + .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + .descriptorCount = 1, + .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT, + }; + VkDescriptorSetLayoutCreateInfo layoutInfo{ + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO, + .bindingCount = 1, + .pBindings = &binding, + }; + if (vkCreateDescriptorSetLayout(mContext->device(), &layoutInfo, nullptr, &mDescriptorLayout) != VK_SUCCESS) { + throw std::runtime_error("sketch2d: descriptor set layout create failed"); + } + } + + void TileRenderer::createPipeline(VkRenderPass renderPass) { + const VkDevice device = mContext->device(); + + auto makeModule = [&](const uint32_t* code, size_t bytes) { + VkShaderModuleCreateInfo info{ + .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, + .codeSize = bytes, + .pCode = code, + }; + VkShaderModule m = VK_NULL_HANDLE; + if (vkCreateShaderModule(device, &info, nullptr, &m) != VK_SUCCESS) { + throw std::runtime_error("sketch2d: shader module create failed"); + } + return m; + }; + + VkShaderModule vert = makeModule(kTileVert, sizeof(kTileVert)); + VkShaderModule frag = makeModule(kTileFrag, sizeof(kTileFrag)); + + VkPushConstantRange pushRange{ + .stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + .offset = 0, + .size = sizeof(TilePush), + }; + VkPipelineLayoutCreateInfo layoutInfo{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, + .setLayoutCount = 1, + .pSetLayouts = &mDescriptorLayout, + .pushConstantRangeCount = 1, + .pPushConstantRanges = &pushRange, + }; + if (vkCreatePipelineLayout(device, &layoutInfo, nullptr, &mPipelineLayout) != VK_SUCCESS) { + throw std::runtime_error("sketch2d: pipeline layout create failed"); + } + + const std::array stages = { + VkPipelineShaderStageCreateInfo{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, + .stage = VK_SHADER_STAGE_VERTEX_BIT, + .module = vert, + .pName = "main", + }, + VkPipelineShaderStageCreateInfo{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, + .stage = VK_SHADER_STAGE_FRAGMENT_BIT, + .module = frag, + .pName = "main", + }, + }; + + VkPipelineVertexInputStateCreateInfo vertexInput{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO, + }; + VkPipelineInputAssemblyStateCreateInfo inputAssembly{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO, + .topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP, + }; + VkPipelineViewportStateCreateInfo viewportState{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO, + .viewportCount = 1, + .scissorCount = 1, + }; + VkPipelineRasterizationStateCreateInfo rasterization{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO, + .polygonMode = VK_POLYGON_MODE_FILL, + .cullMode = VK_CULL_MODE_NONE, + .frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, + .lineWidth = 1.0f, + }; + VkPipelineMultisampleStateCreateInfo multisample{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO, + .rasterizationSamples = VK_SAMPLE_COUNT_1_BIT, + }; + VkPipelineDepthStencilStateCreateInfo depthStencil{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO, + .depthTestEnable = VK_FALSE, + .depthWriteEnable = VK_FALSE, + }; + VkPipelineColorBlendAttachmentState blendAttachment{ + .blendEnable = VK_TRUE, + .srcColorBlendFactor = VK_BLEND_FACTOR_ONE, + .dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, + .colorBlendOp = VK_BLEND_OP_ADD, + .srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE, + .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, + .alphaBlendOp = VK_BLEND_OP_ADD, + .colorWriteMask = + VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT, + }; + VkPipelineColorBlendStateCreateInfo colorBlend{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO, + .attachmentCount = 1, + .pAttachments = &blendAttachment, + }; + const std::array dynamicStates = { VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }; + VkPipelineDynamicStateCreateInfo dynamicState{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO, + .dynamicStateCount = static_cast(dynamicStates.size()), + .pDynamicStates = dynamicStates.data(), + }; + + VkGraphicsPipelineCreateInfo pipelineInfo{ + .sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, + .stageCount = static_cast(stages.size()), + .pStages = stages.data(), + .pVertexInputState = &vertexInput, + .pInputAssemblyState = &inputAssembly, + .pViewportState = &viewportState, + .pRasterizationState = &rasterization, + .pMultisampleState = &multisample, + .pDepthStencilState = &depthStencil, + .pColorBlendState = &colorBlend, + .pDynamicState = &dynamicState, + .layout = mPipelineLayout, + .renderPass = renderPass, + .subpass = 0, + }; + + const VkResult result = vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &mPipeline); + + vkDestroyShaderModule(device, vert, nullptr); + vkDestroyShaderModule(device, frag, nullptr); + + if (result != VK_SUCCESS) throw std::runtime_error("sketch2d: tile pipeline create failed"); + } + + void TileRenderer::createSolidPipeline(VkRenderPass renderPass) { + const VkDevice device = mContext->device(); + + auto makeModule = [&](const uint32_t* code, size_t bytes) { + VkShaderModuleCreateInfo info{ + .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, + .codeSize = bytes, + .pCode = code, + }; + VkShaderModule m = VK_NULL_HANDLE; + if (vkCreateShaderModule(device, &info, nullptr, &m) != VK_SUCCESS) { + throw std::runtime_error("sketch2d: solid shader module create failed"); + } + return m; + }; + + VkShaderModule vert = makeModule(kSolidVert, sizeof(kSolidVert)); + VkShaderModule frag = makeModule(kSolidFrag, sizeof(kSolidFrag)); + + VkPushConstantRange pushRange{ + .stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + .offset = 0, + .size = sizeof(SolidPush), + }; + VkPipelineLayoutCreateInfo layoutInfo{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, + .pushConstantRangeCount = 1, + .pPushConstantRanges = &pushRange, + }; + if (vkCreatePipelineLayout(device, &layoutInfo, nullptr, &mSolidPipelineLayout) != VK_SUCCESS) { + throw std::runtime_error("sketch2d: solid pipeline layout create failed"); + } + + const std::array stages = { + VkPipelineShaderStageCreateInfo{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, + .stage = VK_SHADER_STAGE_VERTEX_BIT, + .module = vert, + .pName = "main", + }, + VkPipelineShaderStageCreateInfo{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, + .stage = VK_SHADER_STAGE_FRAGMENT_BIT, + .module = frag, + .pName = "main", + }, + }; + + VkPipelineVertexInputStateCreateInfo vertexInput{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO, + }; + VkPipelineInputAssemblyStateCreateInfo inputAssembly{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO, + .topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP, + }; + VkPipelineViewportStateCreateInfo viewportState{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO, + .viewportCount = 1, + .scissorCount = 1, + }; + VkPipelineRasterizationStateCreateInfo rasterization{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO, + .polygonMode = VK_POLYGON_MODE_FILL, + .cullMode = VK_CULL_MODE_NONE, + .frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, + .lineWidth = 1.0f, + }; + VkPipelineMultisampleStateCreateInfo multisample{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO, + .rasterizationSamples = VK_SAMPLE_COUNT_1_BIT, + }; + VkPipelineDepthStencilStateCreateInfo depthStencil{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO, + }; + VkPipelineColorBlendAttachmentState blendAttachment{ + .blendEnable = VK_TRUE, + .srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA, + .dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, + .colorBlendOp = VK_BLEND_OP_ADD, + .srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE, + .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, + .alphaBlendOp = VK_BLEND_OP_ADD, + .colorWriteMask = + VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT, + }; + VkPipelineColorBlendStateCreateInfo colorBlend{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO, + .attachmentCount = 1, + .pAttachments = &blendAttachment, + }; + const std::array dynamicStates = { VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }; + VkPipelineDynamicStateCreateInfo dynamicState{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO, + .dynamicStateCount = static_cast(dynamicStates.size()), + .pDynamicStates = dynamicStates.data(), + }; + + VkGraphicsPipelineCreateInfo pipelineInfo{ + .sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, + .stageCount = static_cast(stages.size()), + .pStages = stages.data(), + .pVertexInputState = &vertexInput, + .pInputAssemblyState = &inputAssembly, + .pViewportState = &viewportState, + .pRasterizationState = &rasterization, + .pMultisampleState = &multisample, + .pDepthStencilState = &depthStencil, + .pColorBlendState = &colorBlend, + .pDynamicState = &dynamicState, + .layout = mSolidPipelineLayout, + .renderPass = renderPass, + .subpass = 0, + }; + + const VkResult result = + vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &mSolidPipeline); + + vkDestroyShaderModule(device, vert, nullptr); + vkDestroyShaderModule(device, frag, nullptr); + + if (result != VK_SUCCESS) throw std::runtime_error("sketch2d: solid pipeline create failed"); + } + + void TileRenderer::drawSolidRect( + void* commandBuffer, const Vec2F& pos, const Vec2F& size, const Vec2F& viewport, const RGBA& color + ) { + SolidPush push{}; + push.rect[0] = pos.x; + push.rect[1] = pos.y; + push.rect[2] = size.x; + push.rect[3] = size.y; + push.viewportAndRotation[0] = viewport.x; + push.viewportAndRotation[1] = viewport.y; + push.viewportAndRotation[2] = mRotation[0]; + push.viewportAndRotation[3] = mRotation[1]; + push.color[0] = color.r; + push.color[1] = color.g; + push.color[2] = color.b; + push.color[3] = color.a; + + auto cmd = static_cast(commandBuffer); + vkCmdPushConstants( + cmd, mSolidPipelineLayout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(SolidPush), &push + ); + vkCmdDraw(cmd, 4, 1, 0, 0); + } + + void TileRenderer::drawCanvasBackground( + const Document& document, const ViewSettings& settings, void* commandBuffer, const Vec2F& bufferSize + ) { + auto cmd = static_cast(commandBuffer); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, mSolidPipeline); + + const RectF area = visibleCanvasArea(document, bufferSize); + if (!document.isInfinite() && settings.canvasShadow) { + for (const ShadowBand& band : canvasShadow(frameInView(document))) + drawSolidRect(cmd, band.rect.pos, band.rect.size, bufferSize, band.color); + } + if (area.size.x <= 0 || area.size.y <= 0) return; + + const CanvasBackground& background = document.background(); + if (settings.showChannel) { + drawSolidRect(cmd, area.pos, area.size, bufferSize, channelBackground(settings.channel, background.rgba())); + return; + } + if (!background.transparent) { + drawSolidRect(cmd, area.pos, area.size, bufferSize, background.rgba()); + return; + } + + const std::array rotation = mRotation; + mRotation = { 1, 0 }; + const bool rotated = document.isRotated(); + if (!rotated) { + VkRect2D clip{ { (int32_t) area.pos.x, (int32_t) area.pos.y }, + { (uint32_t) std::ceil(area.size.x), (uint32_t) std::ceil(area.size.y) } }; + vkCmdSetScissor(cmd, 0, 1, &clip); + } + if (rotated && !document.isInfinite()) { + mRotation = rotation; + drawSolidRect(cmd, area.pos, area.size, bufferSize, kCheckerLight); + const halnf cell = kCheckerCellPixels; + const Vec2F origin = document.worldToView({ 0, 0 }); + const int4 firstColumn = (int4) std::floor((area.pos.x - origin.x) / cell); + const int4 firstRow = (int4) std::floor((area.pos.y - origin.y) / cell); + for (int4 row = firstRow; origin.y + (halnf) row * cell < area.pos.y + area.size.y; row++) { + for (int4 column = firstColumn; origin.x + (halnf) column * cell < area.pos.x + area.size.x; column++) { + if (((row + column) % 2 + 2) % 2 == 0) continue; + const halnf x0 = std::max(area.pos.x, origin.x + (halnf) column * cell); + const halnf y0 = std::max(area.pos.y, origin.y + (halnf) row * cell); + const halnf x1 = std::min(area.pos.x + area.size.x, origin.x + (halnf) (column + 1) * cell); + const halnf y1 = std::min(area.pos.y + area.size.y, origin.y + (halnf) (row + 1) * cell); + if (x1 > x0 && y1 > y0) drawSolidRect(cmd, { x0, y0 }, { x1 - x0, y1 - y0 }, bufferSize, kCheckerDark); + } + } + } else { + for (const CheckerCell& cell : checkerCells(document, bufferSize)) + drawSolidRect(cmd, cell.position, cell.size, bufferSize, cell.color); + } + mRotation = rotation; + VkRect2D full{ { 0, 0 }, { (uint32_t) bufferSize.x, (uint32_t) bufferSize.y } }; + vkCmdSetScissor(cmd, 0, 1, &full); + } + + TileDebugState TileRenderer::debugStateOf( + const Document& document, + const TileCoord& coord, + Tile* tile, + int4 level, + const Map& running, + const Map& queued + ) const { + if (running.presents(coord.key()).isValid()) return TileDebugState::Working; + if (queued.presents(coord.key()).isValid()) return TileDebugState::Queued; + for (const auto& layer : document.layers()) + if (level > 0 && layer->levels().isPreview(level, coord)) return TileDebugState::Preview; + for (const auto& layer : document.layers()) + if (level > 0 && layer->levels().isStale(level, coord)) return TileDebugState::Stale; + if (!tile) return TileDebugState::Missing; + auto found = mGpuTiles.presents(tile->gpuId()); + if (tile->gpuId() == 0 || !found.isValid() || !mGpuTiles.getSlotVal(found)->initialized) + return TileDebugState::NotUploaded; + if (tile->hasDirty()) return TileDebugState::Uploading; + return TileDebugState::Done; + } + + RGBA TileRenderer::debugColor(TileDebugState state) { + switch (state) { + case TileDebugState::Working: return { 1.0f, 0.35f, 0.1f, 0.35f }; + case TileDebugState::Queued: return { 1.0f, 0.85f, 0.1f, 0.30f }; + case TileDebugState::Preview: return { 0.75f, 0.3f, 1.0f, 0.30f }; + case TileDebugState::Uploading: return { 0.2f, 0.6f, 1.0f, 0.30f }; + case TileDebugState::NotUploaded: return { 1.0f, 0.1f, 0.2f, 0.35f }; + case TileDebugState::Stale: return { 0.95f, 0.45f, 0.75f, 0.30f }; + case TileDebugState::Missing: return { 0.5f, 0.5f, 0.5f, 0.15f }; + case TileDebugState::Done: return { 0.2f, 0.9f, 0.4f, 0.12f }; + } + return { 1, 1, 1, 0.1f }; + } + + void TileRenderer::drawDebugOverlay(Document& document, void* commandBuffer, const Vec2F& bufferSize) { + auto cmd = static_cast(commandBuffer); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, mSolidPipeline); + mRotation = { std::cos(document.rotation()), std::sin(document.rotation()) }; + + const int4 level = mLastLevel; + const int4 levelSpan = kTileSize << level; + const halnf screenTile = (halnf) levelSpan * document.zoom(); + const halnf line = 1.0f; + + Map running; + Map queued; + document.engine().forEachPending([&](const Layer&, const TileWork& work, bool active) { + const TileCoord at = { floordiv(work.coord.x, 1 << std::max(0, level - work.level)), + floordiv(work.coord.y, 1 << std::max(0, level - work.level)) }; + (active ? running : queued).put(at.key(), true); + }); + + for (int4 index = 0; index < (int4) TileDebugState::Count; index++) + mDebugCounts[index] = 0; + + const auto outline = [&](Vec2F view, const RGBA& color) { + drawSolidRect(cmd, view, { screenTile, line }, bufferSize, color); + drawSolidRect(cmd, { view.x, view.y + screenTile - line }, { screenTile, line }, bufferSize, color); + drawSolidRect(cmd, view, { line, screenTile }, bufferSize, color); + drawSolidRect(cmd, { view.x + screenTile - line, view.y }, { line, screenTile }, bufferSize, color); + }; + + const RectF bounds = document.viewBoundsOfScreen(bufferSize); + const Vec2F topLeft = document.panWorld() + bounds.pos / document.zoom(); + const Vec2F bottomRight = document.panWorld() + (bounds.pos + bounds.size) / document.zoom(); + const int4 x0 = (int4) std::floor(std::min(topLeft.x, bottomRight.x) / (halnf) levelSpan); + const int4 y0 = (int4) std::floor(std::min(topLeft.y, bottomRight.y) / (halnf) levelSpan); + const int4 x1 = (int4) std::floor(std::max(topLeft.x, bottomRight.x) / (halnf) levelSpan); + const int4 y1 = (int4) std::floor(std::max(topLeft.y, bottomRight.y) / (halnf) levelSpan); + if ((ualni) (x1 - x0 + 1) * (ualni) (y1 - y0 + 1) > kDebugGridLimit) return; + + for (int4 y = y0; y <= y1; y++) { + for (int4 x = x0; x <= x1; x++) { + const TileCoord coord = { x, y }; + if (!isTileVisible(document, coord, bufferSize, level)) continue; + const TileDebugState state = + debugStateOf(document, coord, mCompositor.tileAt(level, coord), level, running, queued); + mDebugCounts[(int4) state]++; + const Vec2F view = document.worldToView({ (halnf) (x * levelSpan), (halnf) (y * levelSpan) }); + const RGBA color = debugColor(state); + if (state != TileDebugState::Missing) drawSolidRect(cmd, view, { screenTile, screenTile }, bufferSize, color); + outline(view, { color.r, color.g, color.b, std::min(1.0f, color.a * 2.5f) }); + } + } + mRotation = { 1, 0 }; + } + + std::string TileRenderer::debugLegend() const { + static const char* const kNames[] = { "done", "uploading", "not uploaded", "preview", + "queued", "working", "stale", "empty" }; + std::string text = "L" + std::to_string(mLastLevel) + " "; + for (int4 index = 0; index < (int4) TileDebugState::Count; index++) + text += std::string(kNames[index]) + " " + std::to_string(mDebugCounts[index]) + " "; + return text; + } + + void TileRenderer::destroyGpuTile(GpuTile* gpu) { + const VkDevice device = mContext->device(); + if (gpu->descriptor) vkFreeDescriptorSets(device, mDescriptorPool, 1, &gpu->descriptor); + if (gpu->view) vkDestroyImageView(device, gpu->view, nullptr); + if (gpu->image) vkDestroyImage(device, gpu->image, nullptr); + if (gpu->memory) vkFreeMemory(device, gpu->memory, nullptr); + delete gpu; + } + + void TileRenderer::releaseGpuTile(const Tile& tile) { + if (tile.gpuId() == 0) return; + auto found = mGpuTiles.presents(tile.gpuId()); + if (!found.isValid()) return; + mRetiredGpuTiles.push_back({ mGpuTiles.getSlotVal(found), mFrame }); + mGpuTiles.remove(tile.gpuId()); + } + + void TileRenderer::releaseUnusedGpuTiles() { + for (const std::shared_ptr& tile : mCompositor.lastRemoved()) + if (tile.use_count() == 1) releaseGpuTile(*tile); + + size_t kept = 0; + for (const RetiredGpuTile& retired : mRetiredGpuTiles) { + if (retired.frame + 2 < mFrame) destroyGpuTile(retired.gpu); + else mRetiredGpuTiles[kept++] = retired; + } + mRetiredGpuTiles.resize(kept); + } + + TileRenderer::GpuTile* TileRenderer::gpuTileFor(Tile* tile) { + if (tile->gpuId() != 0) { + auto idx = mGpuTiles.presents(tile->gpuId()); + if (idx.isValid()) return mGpuTiles.getSlotVal(idx); + } + + const VkDevice device = mContext->device(); + auto* gpu = new GpuTile(); + + VkImageCreateInfo imageInfo{ + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .imageType = VK_IMAGE_TYPE_2D, + .format = VK_FORMAT_R8G8B8A8_UNORM, + .extent = { (uint32_t) kTileSize, (uint32_t) kTileSize, 1 }, + .mipLevels = 1, + .arrayLayers = 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + }; + vkCreateImage(device, &imageInfo, nullptr, &gpu->image); + + VkMemoryRequirements req{}; + vkGetImageMemoryRequirements(device, gpu->image, &req); + VkMemoryAllocateInfo alloc{ + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = req.size, + .memoryTypeIndex = mContext->findMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT), + }; + vkAllocateMemory(device, &alloc, nullptr, &gpu->memory); + vkBindImageMemory(device, gpu->image, gpu->memory, 0); + + VkImageViewCreateInfo viewInfo{ + .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, + .image = gpu->image, + .viewType = VK_IMAGE_VIEW_TYPE_2D, + .format = VK_FORMAT_R8G8B8A8_UNORM, + .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }, + }; + vkCreateImageView(device, &viewInfo, nullptr, &gpu->view); + + VkDescriptorSetAllocateInfo descAlloc{ + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO, + .descriptorPool = mDescriptorPool, + .descriptorSetCount = 1, + .pSetLayouts = &mDescriptorLayout, + }; + vkAllocateDescriptorSets(device, &descAlloc, &gpu->descriptor); + + VkDescriptorImageInfo descImage{ + .sampler = mSampler, + .imageView = gpu->view, + .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + }; + VkWriteDescriptorSet write{ + .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + .dstSet = gpu->descriptor, + .dstBinding = 0, + .descriptorCount = 1, + .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + .pImageInfo = &descImage, + }; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + + const ualni id = mNextGpuId++; + tile->setGpuId(id); + mGpuTiles.put(id, gpu); + + tile->markDirty({ 0, 0, (halnf) kTileSize, (halnf) kTileSize }); + return gpu; + } + + void TileRenderer::ensureStaging(VkDeviceSize bytes) { + if (bytes <= mStagingSize) return; + destroyStaging(); + + const VkDevice device = mContext->device(); + const VkDeviceSize size = std::max(bytes, (VkDeviceSize) kTileSize * kTileSize * 4 * 16); + VkBufferCreateInfo bufferInfo{ + .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, + .size = size, + .usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + }; + vkCreateBuffer(device, &bufferInfo, nullptr, &mStaging); + VkMemoryRequirements req{}; + vkGetBufferMemoryRequirements(device, mStaging, &req); + VkMemoryAllocateInfo alloc{ + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = req.size, + .memoryTypeIndex = mContext->findMemoryType( + req.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT + ), + }; + vkAllocateMemory(device, &alloc, nullptr, &mStagingMemory); + vkBindBufferMemory(device, mStaging, mStagingMemory, 0); + void* mapped = nullptr; + vkMapMemory(device, mStagingMemory, 0, size, 0, &mapped); + mStagingMapped = static_cast(mapped); + mStagingSize = size; + } + + void TileRenderer::destroyStaging() { + if (!mStaging) return; + const VkDevice device = mContext->device(); + vkUnmapMemory(device, mStagingMemory); + vkDestroyBuffer(device, mStaging, nullptr); + vkFreeMemory(device, mStagingMemory, nullptr); + mStaging = VK_NULL_HANDLE; + mStagingMemory = VK_NULL_HANDLE; + mStagingMapped = nullptr; + mStagingSize = 0; + } + + bool TileRenderer::isTileVisible( + const Document& document, const TileCoord& coord, const Vec2F& bufferSize, int4 level + ) const { + const int4 levelTiles = 1 << level; + const halnf screenTile = (halnf) (kTileSize * levelTiles) * document.zoom(); + const Vec2F view = + document.worldToView({ (halnf) (coord.x * kTileSize * levelTiles), (halnf) (coord.y * kTileSize * levelTiles) }); + const RectF bounds = document.viewBoundsOfScreen(bufferSize); + return view.x + screenTile >= bounds.pos.x && view.y + screenTile >= bounds.pos.y && + view.x <= bounds.pos.x + bounds.size.x && view.y <= bounds.pos.y + bounds.size.y; + } + + bool TileRenderer::uploadDirty(Tile* tile, GpuTile* gpu, VkCommandBuffer cmd) { + if (!tile->hasDirty() && gpu->initialized) return true; + + const RectF dirty = tile->hasDirty() ? tile->dirtyRect() : RectF{ 0, 0, (halnf) kTileSize, (halnf) kTileSize }; + const int4 dx = gpu->initialized ? (int4) dirty.pos.x : 0; + const int4 dy = gpu->initialized ? (int4) dirty.pos.y : 0; + const int4 dw = gpu->initialized ? (int4) dirty.size.x : kTileSize; + const int4 dh = gpu->initialized ? (int4) dirty.size.y : kTileSize; + + const VkDeviceSize uploadBytes = (VkDeviceSize) dw * dh * 4; + if (mStagingUsed + uploadBytes > mStagingBudget) return false; + + const VkDeviceSize offset = mStagingUsed; + mStagingUsed += (uploadBytes + 15) & ~(VkDeviceSize) 15; + uint1* dst = mStagingMapped + offset; + const uint1* src = tile->pixels(); + for (int4 row = 0; row < dh; row++) { + std::memcpy(dst + (ualni) row * dw * 4, src + ((ualni) (dy + row) * kTileSize + dx) * 4, (size_t) dw * 4); + } + + VkImageMemoryBarrier toDst{ + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = gpu->initialized ? VK_ACCESS_SHADER_READ_BIT : (VkAccessFlags) 0, + .dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, + .oldLayout = gpu->initialized ? VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL : VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = gpu->image, + .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }, + }; + vkCmdPipelineBarrier( + cmd, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toDst + ); + + VkBufferImageCopy region{ + .bufferOffset = offset, + .bufferRowLength = (uint32_t) dw, + .bufferImageHeight = (uint32_t) dh, + .imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 }, + .imageOffset = { dx, dy, 0 }, + .imageExtent = { (uint32_t) dw, (uint32_t) dh, 1 }, + }; + vkCmdCopyBufferToImage(cmd, mStaging, gpu->image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); + + VkImageMemoryBarrier toRead = toDst; + toRead.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + toRead.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + toRead.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + toRead.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + vkCmdPipelineBarrier( + cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toRead + ); + + gpu->initialized = true; + mLastUploadedPixels += (ualni) dw * dh; + mLastUploadedTiles++; + tile->clearDirty(); + return true; + } + + template + void TileRenderer::forEachDrawnTile(const Document& document, const Vec2F& bufferSize, int4 level, Fn&& fn) { + const int4 levelTiles = 1 << level; + const halnf span = (halnf) (kTileSize * levelTiles); + const RectF bounds = document.viewBoundsOfScreen(bufferSize); + const Vec2F topLeft = document.panWorld() + bounds.pos / document.zoom(); + const Vec2F bottomRight = document.panWorld() + (bounds.pos + bounds.size) / document.zoom(); + const int4 x0 = (int4) std::floor(std::min(topLeft.x, bottomRight.x) / span); + const int4 y0 = (int4) std::floor(std::min(topLeft.y, bottomRight.y) / span); + const int4 x1 = (int4) std::floor(std::max(topLeft.x, bottomRight.x) / span); + const int4 y1 = (int4) std::floor(std::max(topLeft.y, bottomRight.y) / span); + const ualni visible = (ualni) (x1 - x0 + 1) * (ualni) (y1 - y0 + 1); + const ualni stored = mCompositor.tileCount(level); + + if (visible > stored) { + const auto inView = [&](const TileCoord& coord, Tile* tile) { + if (coord.x >= x0 && coord.x <= x1 && coord.y >= y0 && coord.y <= y1) fn(coord, tile); + }; + mCompositor.forEachTile(level, inView); + return; + } + for (int4 y = y0; y <= y1; y++) + for (int4 x = x0; x <= x1; x++) { + Tile* tile = mCompositor.tileAt(level, { x, y }); + if (tile) fn(TileCoord{ x, y }, tile); + } + } + + void TileRenderer::setViewportSize(const Vec2F& size) { + if (size.x <= 0 || size.y <= 0) return; + mBuffer.resize(size); + } + + uint4 TileRenderer::textureId() const { return mBuffer.texId(); } + + bool TileRenderer::isReady(const Tile* tile) const { + if (!tile || tile->gpuId() == 0) return false; + auto found = mGpuTiles.presents(tile->gpuId()); + return found.isValid() && mGpuTiles.getSlotVal(found)->initialized; + } + + ualni TileRenderer::pendingPixels(const Document& document, const Vec2F& bufferSize, int4 level) { + ualni pending = 0; + forEachDrawnTile(document, bufferSize, level, [&](const TileCoord& coord, Tile* tile) { + if (!isTileVisible(document, coord, bufferSize, level)) return; + if (!isReady(tile)) pending += (ualni) kTileSize * kTileSize; + else if (tile->hasDirty()) pending += (ualni) (tile->dirtyRect().size.x * tile->dirtyRect().size.y); + }); + return pending; + } + + void + TileRenderer::uploadLevel(const Document& document, const Vec2F& bufferSize, int4 level, VkCommandBuffer commands) { + forEachDrawnTile(document, bufferSize, level, [&](const TileCoord& coord, Tile* tile) { + if (!isTileVisible(document, coord, bufferSize, level)) return; + GpuTile* gpu = gpuTileFor(tile); + gpu->frame = mFrame; + if (!uploadDirty(tile, gpu, commands)) mPendingUploads = true; + }); + } + + void TileRenderer::render(Document& document, const ViewSettings& settings) { + mLastUploadedPixels = 0; + mLastUploadedTiles = 0; + mLastDrawnTiles = 0; + mLastFallbackTiles = 0; + + TP_PROFILE_SCOPE("canvas.render"); + mFrame++; + const Vec2F bufferSize = mBuffer.getSize(); + mWantedLevel = LayerLevels::levelForZoom(document.zoom()); + const int4 coarser = std::min(mWantedLevel + 1, LayerLevels::kLevels - 1); + { + TP_PROFILE_SCOPE("canvas.composite"); + document.whilePaintPaused([&]() { + if (coarser != mWantedLevel) mCompositor.update(document, coarser); + mCompositor.update(document, mWantedLevel); + }); + TP_PROFILE_COUNT("canvas.composited_pixels", mCompositor.lastCompositedPixels()); + } + if (coarser != mWantedLevel && pendingPixels(document, bufferSize, mWantedLevel) > kUploadPixelBudget) + mCoarseHold = kCoarseHoldFrames; + mLastLevel = mCoarseHold > 0 ? coarser : mWantedLevel; + if (mCoarseHold > 0) mCoarseHold--; + + mBuffer.beginCommands(); + VkCommandBuffer commandBuffer = static_cast(mBuffer.activeCommandBuffer()); + { + TP_PROFILE_SCOPE("canvas.upload"); + const VkDeviceSize tileBytes = (VkDeviceSize) kTileSize * kTileSize * 4; + const VkDeviceSize budget = std::min( + tileBytes * (VkDeviceSize) mUploadTilesPerFrame, + std::max((VkDeviceSize) kUploadPixelBudget * 4, tileBytes) + ); + ensureStaging(budget); + mStagingUsed = 0; + mStagingBudget = budget; + mPendingUploads = false; + uploadLevel(document, bufferSize, coarser, commandBuffer); + if (coarser != mWantedLevel) uploadLevel(document, bufferSize, mWantedLevel, commandBuffer); + releaseUnusedGpuTiles(); + } + TP_PROFILE_SCOPE("canvas.record"); + + const halnf zoom = document.zoom(); + + mBuffer.mClearCol = settings.boardColor.rgba(); + mBuffer.beginPass(); + + mBuffer.setViewport({ 0.0f, 0.0f, bufferSize.x, bufferSize.y }); + VkRect2D scissor{ { 0, 0 }, { (uint32_t) bufferSize.x, (uint32_t) bufferSize.y } }; + vkCmdSetScissor(commandBuffer, 0, 1, &scissor); + mRotation = { std::cos(document.rotation()), std::sin(document.rotation()) }; + + drawCanvasBackground(document, settings, commandBuffer, bufferSize); + + const ChannelMatrix channelRows = settings.showChannel ? channelMatrix(settings.channel) : kIdentityChannels; + const RectF clip = document.isInfinite() ? RectF{ -1.0e9f, -1.0e9f, 2.0e9f, 2.0e9f } : frameInView(document); + vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, mPipeline); + + const auto drawTile = [&](const TileCoord& coord, Tile* tile, int4 level, const RectF& area) { + const int4 levelTiles = 1 << level; + const halnf screenTile = (halnf) (kTileSize * levelTiles) * zoom; + const Vec2F worldTopLeft = { (halnf) (coord.x * kTileSize * levelTiles), + (halnf) (coord.y * kTileSize * levelTiles) }; + const Vec2F screen = document.worldToView(worldTopLeft); + + auto found = mGpuTiles.presents(tile->gpuId()); + if (tile->gpuId() == 0 || !found.isValid()) return false; + GpuTile* gpu = mGpuTiles.getSlotVal(found); + if (!gpu->initialized) return false; + const halnf left = std::max(clip.pos.x, area.pos.x); + const halnf top = std::max(clip.pos.y, area.pos.y); + const halnf right = std::min(clip.pos.x + clip.size.x, area.pos.x + area.size.x); + const halnf bottom = std::min(clip.pos.y + clip.size.y, area.pos.y + area.size.y); + + TilePush push{}; + push.screenPos[0] = screen.x; + push.screenPos[1] = screen.y; + push.screenSize[0] = screenTile; + push.screenSize[1] = screenTile; + push.viewport[0] = bufferSize.x; + push.viewport[1] = bufferSize.y; + push.rotation[0] = mRotation[0]; + push.rotation[1] = mRotation[1]; + push.clip[0] = left; + push.clip[1] = top; + push.clip[2] = right; + push.clip[3] = bottom; + + std::copy(channelRows.begin(), channelRows.end(), push.channelRows); + vkCmdPushConstants( + commandBuffer, + mPipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, + sizeof(TilePush), + &push + ); + vkCmdBindDescriptorSets( + commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, mPipelineLayout, 0, 1, &gpu->descriptor, 0, nullptr + ); + vkCmdDraw(commandBuffer, 4, 1, 0, 0); + return true; + }; + + const RectF everywhere = { -1.0e9f, -1.0e9f, 2.0e9f, 2.0e9f }; + const int4 drawLevel = mLastLevel; + const int4 parentLevel = std::min(drawLevel + 1, LayerLevels::kLevels - 1); + forEachDrawnTile(document, bufferSize, drawLevel, [&](const TileCoord& coord, Tile* tile) { + if (!isTileVisible(document, coord, bufferSize, drawLevel)) return; + if (drawTile(coord, tile, drawLevel, everywhere)) { + mLastDrawnTiles++; + return; + } + if (parentLevel == drawLevel) return; + const TileCoord parent = LayerLevels::parentOf(coord); + Tile* coarse = mCompositor.tileAt(parentLevel, parent); + if (!coarse) return; + const halnf span = (halnf) (kTileSize << drawLevel) * zoom; + const Vec2F origin = document.worldToView( + { (halnf) (coord.x * (kTileSize << drawLevel)), (halnf) (coord.y * (kTileSize << drawLevel)) } + ); + if (drawTile(parent, coarse, parentLevel, { origin.x, origin.y, span, span })) mLastFallbackTiles++; + }); + + vkCmdSetScissor(commandBuffer, 0, 1, &scissor); + if (mDebug) { + drawDebugOverlay(document, commandBuffer, bufferSize); + } + + mBuffer.endDraw(); + TP_PROFILE_COUNT("canvas.tiles_drawn", mLastDrawnTiles); + TP_PROFILE_COUNT("canvas.tiles_uploaded", mLastUploadedTiles); + TP_PROFILE_COUNT("canvas.pixels_uploaded", mLastUploadedPixels); + TP_PROFILE_COUNT("canvas.mip_level", mLastLevel); + } +} diff --git a/Sketch2D/src/libs/render/public/CanvasPreviewImages.hpp b/Sketch2D/src/libs/render/public/CanvasPreviewImages.hpp new file mode 100644 index 00000000..2011c3a0 --- /dev/null +++ b/Sketch2D/src/libs/render/public/CanvasPreviewImages.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "Graphics.hpp" +#include "LibraryView.hpp" + +#include + +namespace tp::sketch2d { + + class CanvasPreviewImages : public PreviewImages { + public: + explicit CanvasPreviewImages(Canvas& canvas); + ~CanvasPreviewImages() override; + + CanvasPreviewImages(const CanvasPreviewImages&) = delete; + CanvasPreviewImages& operator=(const CanvasPreviewImages&) = delete; + + [[nodiscard]] ImageHandle image(const std::string& key, const SketchPreview& preview) override; + [[nodiscard]] ImageHandle cached(const std::string& key) const override; + void forget(const std::string& key) override; + + private: + Canvas& mCanvas; + std::map mImages; + }; +} diff --git a/Sketch2D/src/libs/render/public/CanvasThumbnails.hpp b/Sketch2D/src/libs/render/public/CanvasThumbnails.hpp new file mode 100644 index 00000000..9e009692 --- /dev/null +++ b/Sketch2D/src/libs/render/public/CanvasThumbnails.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "Graphics.hpp" +#include "LayerThumbnail.hpp" +#include "ThumbnailProvider.hpp" +#include "Map.hpp" + +namespace tp::sketch2d { + + class CanvasThumbnails : public ThumbnailProvider { + public: + explicit CanvasThumbnails(Canvas& canvas); + ~CanvasThumbnails() override; + + CanvasThumbnails(const CanvasThumbnails&) = delete; + CanvasThumbnails& operator=(const CanvasThumbnails&) = delete; + + [[nodiscard]] ImageHandle thumbnail(const Document& document, const Layer& layer) override; + + private: + Canvas& mCanvas; + Map mImages; + }; +} diff --git a/Sketch2D/src/libs/render/public/CanvasViewport.hpp b/Sketch2D/src/libs/render/public/CanvasViewport.hpp new file mode 100644 index 00000000..ff1cd4a8 --- /dev/null +++ b/Sketch2D/src/libs/render/public/CanvasViewport.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "CanvasWidget.hpp" +#include "Graphics.hpp" +#include "TileRenderer.hpp" + +namespace tp::sketch2d { + + class CanvasViewport : public ViewportRenderer { + public: + CanvasViewport(Canvas& canvas, TileRenderer& renderer); + ~CanvasViewport() override; + + CanvasViewport(const CanvasViewport&) = delete; + CanvasViewport& operator=(const CanvasViewport&) = delete; + + void render(Document& document, const ViewSettings& settings, Vec2F viewportSize) override; + [[nodiscard]] ImageHandle image() const override { return { mImage.id }; } + + void setDebugOverlay(bool enabled) override { mRenderer.setDebug(enabled); } + [[nodiscard]] bool debugOverlay() const override { return mRenderer.debug(); } + [[nodiscard]] std::vector debugLegend() const override; + [[nodiscard]] std::string statistics() const override; + [[nodiscard]] bool isCatchingUp() const override { return mRenderer.hasPendingUploads(); } + + private: + Canvas& mCanvas; + TileRenderer& mRenderer; + Document* mLastDocument = nullptr; + ImageHandle mImage; + }; +} diff --git a/Sketch2D/src/libs/render/public/IconSet.hpp b/Sketch2D/src/libs/render/public/IconSet.hpp new file mode 100644 index 00000000..a19665fd --- /dev/null +++ b/Sketch2D/src/libs/render/public/IconSet.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "Graphics.hpp" +#include "Sketch2DUI.hpp" +#include "Map.hpp" + +#include + +namespace tp::sketch2d { + + class IconSet : public IconProvider { + public: + IconSet(Canvas& canvas, const std::string& directory); + ~IconSet() override; + + IconSet(const IconSet&) = delete; + IconSet& operator=(const IconSet&) = delete; + + [[nodiscard]] ImageHandle icon(const std::string& name) const override; + + private: + static ualni hash(const std::string& name); + + Canvas& mCanvas; + Map mIcons; + }; +} diff --git a/Sketch2D/src/libs/render/public/TileRenderer.hpp b/Sketch2D/src/libs/render/public/TileRenderer.hpp new file mode 100644 index 00000000..5b295650 --- /dev/null +++ b/Sketch2D/src/libs/render/public/TileRenderer.hpp @@ -0,0 +1,149 @@ +#pragma once + +#include "CanvasWidget.hpp" +#include "CanvasFrameGeometry.hpp" +#include "Checkerboard.hpp" +#include "Compositor.hpp" +#include "Document.hpp" +#include "List.hpp" +#include "Map.hpp" +#include "FrameBuffer.hpp" + +#include + +#include + +namespace tp { + class VulkanContext; +} + +namespace tp::sketch2d { + + enum class TileDebugState { Done, Uploading, NotUploaded, Preview, Queued, Working, Stale, Missing, Count }; + + class TileRenderer { + public: + explicit TileRenderer(VulkanContext* context); + ~TileRenderer(); + + TileRenderer(const TileRenderer&) = delete; + TileRenderer& operator=(const TileRenderer&) = delete; + + void setViewportSize(const Vec2F& size); + + void render(Document& document, const ViewSettings& settings); + + [[nodiscard]] uint4 textureId() const; + + void setDebug(bool on) { mDebug = on; } + [[nodiscard]] bool debug() const { return mDebug; } + + [[nodiscard]] ualni uploadedPixelsLastFrame() const { return mLastUploadedPixels; } + [[nodiscard]] ualni uploadedTilesLastFrame() const { return mLastUploadedTiles; } + [[nodiscard]] ualni drawnTilesLastFrame() const { return mLastDrawnTiles; } + [[nodiscard]] ualni compositedTilesLastFrame() const { return mCompositor.lastCompositedTiles(); } + [[nodiscard]] int4 mipLevelLastFrame() const { return mLastLevel; } + [[nodiscard]] std::string debugLegend() const; + [[nodiscard]] ualni debugCount(TileDebugState state) const { return mDebugCounts[(int4) state]; } + [[nodiscard]] static RGBA debugColor(TileDebugState state); + static constexpr ualni kDebugGridLimit = 20000; + [[nodiscard]] int4 wantedLevelLastFrame() const { return mWantedLevel; } + [[nodiscard]] ualni fallbackTilesLastFrame() const { return mLastFallbackTiles; } + static constexpr ualni kUploadPixelBudget = 200000; + static constexpr int4 kCoarseHoldFrames = 5; + [[nodiscard]] bool hasPendingUploads() const { return mPendingUploads; } + + static constexpr ualni kDefaultUploadTilesPerFrame = 64; + void setUploadTilesPerFrame(ualni tiles) { mUploadTilesPerFrame = tiles > 0 ? tiles : 1; } + [[nodiscard]] ualni uploadTilesPerFrame() const { return mUploadTilesPerFrame; } + + private: + struct GpuTile { + VkImage image = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + VkImageView view = VK_NULL_HANDLE; + VkDescriptorSet descriptor = VK_NULL_HANDLE; + bool initialized = false; + ualni frame = 0; + }; + + void createPipeline(VkRenderPass renderPass); + void createSolidPipeline(VkRenderPass renderPass); + void createSampler(); + void createDescriptorPool(); + + GpuTile* gpuTileFor(Tile* tile); + void destroyGpuTile(GpuTile* gpu); + void releaseUnusedGpuTiles(); + void releaseGpuTile(const Tile& tile); + bool uploadDirty(Tile* tile, GpuTile* gpu, VkCommandBuffer commands); + void ensureStaging(VkDeviceSize bytes); + void destroyStaging(); + [[nodiscard]] bool + isTileVisible(const Document& document, const TileCoord& coord, const Vec2F& bufferSize, int4 level) const; + template + void forEachDrawnTile(const Document& document, const Vec2F& bufferSize, int4 level, Fn&& fn); + [[nodiscard]] bool isReady(const Tile* tile) const; + [[nodiscard]] ualni pendingPixels(const Document& document, const Vec2F& bufferSize, int4 level); + void uploadLevel(const Document& document, const Vec2F& bufferSize, int4 level, VkCommandBuffer commands); + + void + drawSolidRect(void* commandBuffer, const Vec2F& pos, const Vec2F& size, const Vec2F& viewport, const RGBA& color); + void drawDebugOverlay(Document& document, void* commandBuffer, const Vec2F& bufferSize); + [[nodiscard]] TileDebugState debugStateOf( + const Document& document, + const TileCoord& coord, + Tile* tile, + int4 level, + const Map& running, + const Map& queued + ) const; + void drawCanvasBackground( + const Document& document, const ViewSettings& settings, void* commandBuffer, const Vec2F& bufferSize + ); + + VulkanContext* mContext = nullptr; + RenderBuffer mBuffer; + std::array mRotation = { 1, 0 }; + + VkDescriptorSetLayout mDescriptorLayout = VK_NULL_HANDLE; + VkPipelineLayout mPipelineLayout = VK_NULL_HANDLE; + VkPipeline mPipeline = VK_NULL_HANDLE; + VkSampler mSampler = VK_NULL_HANDLE; + VkDescriptorPool mDescriptorPool = VK_NULL_HANDLE; + + VkPipelineLayout mSolidPipelineLayout = VK_NULL_HANDLE; + VkPipeline mSolidPipeline = VK_NULL_HANDLE; + + VkBuffer mStaging = VK_NULL_HANDLE; + VkDeviceMemory mStagingMemory = VK_NULL_HANDLE; + uint1* mStagingMapped = nullptr; + VkDeviceSize mStagingSize = 0; + VkDeviceSize mStagingUsed = 0; + VkDeviceSize mStagingBudget = 0; + bool mPendingUploads = false; + ualni mUploadTilesPerFrame = kDefaultUploadTilesPerFrame; + + struct RetiredGpuTile { + GpuTile* gpu; + ualni frame; + }; + + Map mGpuTiles; + std::vector mRetiredGpuTiles; + ualni mNextGpuId = 1; + ualni mFrame = 0; + ParallelFor mParallel; + Compositor mCompositor; + int4 mLastLevel = 0; + ualni mDebugCounts[(int4) TileDebugState::Count] = {}; + int4 mWantedLevel = 0; + int4 mCoarseHold = 0; + ualni mLastFallbackTiles = 0; + + bool mDebug = false; + ualni mLastUploadedPixels = 0; + ualni mLastUploadedTiles = 0; + ualni mLastDrawnTiles = 0; + }; +} diff --git a/Sketch2D/src/libs/render/shaders/solid.frag b/Sketch2D/src/libs/render/shaders/solid.frag new file mode 100644 index 00000000..860d0917 --- /dev/null +++ b/Sketch2D/src/libs/render/shaders/solid.frag @@ -0,0 +1,13 @@ +#version 450 + +layout(push_constant) uniform PushConstants { + vec4 rect; + vec4 viewportAndRotation; + vec4 color; +} pc; + +layout(location = 0) out vec4 outColor; + +void main() { + outColor = pc.color; +} diff --git a/Sketch2D/src/libs/render/shaders/solid.vert b/Sketch2D/src/libs/render/shaders/solid.vert new file mode 100644 index 00000000..9b3038ba --- /dev/null +++ b/Sketch2D/src/libs/render/shaders/solid.vert @@ -0,0 +1,16 @@ +#version 450 + +layout(push_constant) uniform PushConstants { + vec4 rect; + vec4 viewportAndRotation; + vec4 color; +} pc; + +void main() { + vec2 corner = vec2((gl_VertexIndex & 1), (gl_VertexIndex >> 1)); + vec2 view = pc.rect.xy + corner * pc.rect.zw; + vec2 rotation = pc.viewportAndRotation.zw; + vec2 pixel = vec2(view.x * rotation.x - view.y * rotation.y, view.x * rotation.y + view.y * rotation.x); + vec2 ndc = (pixel / pc.viewportAndRotation.xy) * 2.0 - 1.0; + gl_Position = vec4(ndc, 0.0, 1.0); +} diff --git a/Sketch2D/src/libs/render/shaders/tile.frag b/Sketch2D/src/libs/render/shaders/tile.frag new file mode 100644 index 00000000..776ced9a --- /dev/null +++ b/Sketch2D/src/libs/render/shaders/tile.frag @@ -0,0 +1,19 @@ +#version 450 + +layout(location = 0) in vec2 uv; +layout(location = 1) in vec2 viewPosition; +layout(location = 0) out vec4 color; + +layout(binding = 0) uniform sampler2D tileTex; + +layout(push_constant) uniform PushConstants { + layout(offset = 32) vec4 rows[4]; + vec4 clip; +} pc; + +void main() { + if (viewPosition.x < pc.clip.x || viewPosition.y < pc.clip.y || viewPosition.x > pc.clip.z || viewPosition.y > pc.clip.w) + discard; + vec4 texel = texture(tileTex, uv); + color = vec4(dot(pc.rows[0], texel), dot(pc.rows[1], texel), dot(pc.rows[2], texel), dot(pc.rows[3], texel)); +} diff --git a/Sketch2D/src/libs/render/shaders/tile.vert b/Sketch2D/src/libs/render/shaders/tile.vert new file mode 100644 index 00000000..ce34988d --- /dev/null +++ b/Sketch2D/src/libs/render/shaders/tile.vert @@ -0,0 +1,22 @@ +#version 450 + +layout(push_constant) uniform PushConstants { + vec2 screenPos; + vec2 screenSize; + vec2 viewport; + vec2 rotation; +} pc; + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec2 viewPosition; + +void main() { + vec2 corner = vec2((gl_VertexIndex & 1), (gl_VertexIndex >> 1)); + uv = corner; + + vec2 view = pc.screenPos + corner * pc.screenSize; + viewPosition = view; + vec2 pixel = vec2(view.x * pc.rotation.x - view.y * pc.rotation.y, view.x * pc.rotation.y + view.y * pc.rotation.x); + vec2 ndc = (pixel / pc.viewport) * 2.0 - 1.0; + gl_Position = vec4(ndc, 0.0, 1.0); +} diff --git a/Sketch2D/src/libs/ui/private/CanvasWidget.cpp b/Sketch2D/src/libs/ui/private/CanvasWidget.cpp new file mode 100644 index 00000000..507c7e2c --- /dev/null +++ b/Sketch2D/src/libs/ui/private/CanvasWidget.cpp @@ -0,0 +1,1375 @@ +#include "CanvasWidget.hpp" + +#include + +#include +#include +#include "Profiling.hpp" + +#include "Event.hpp" +#include "Theme.hpp" +#include "Ui.hpp" + +namespace tp::sketch2d { + + using ui::Event; + + namespace { + constexpr halnf kZoomStep = 1.1f; + constexpr halnf kStatisticsBarHeight = 22; + constexpr halnf kLegendRowHeight = 20; + const RGBA kRopeColor = { 0.9f, 0.2f, 0.2f, 0.85f }; + const RGBA kContourOuter = { 0.0f, 0.0f, 0.0f, 0.5f }; + const RGBA kContourInner = { 1.0f, 1.0f, 1.0f, 0.9f }; + + std::string strokeLabel(const Brush& brush) { + if (brush.erases()) return "Eraser"; + if (brush.smudges()) return "Smudge"; + return "Paint"; + } + } + + CanvasWidget::CanvasWidget(Document& document, ViewportRenderer& renderer, Brush& brush) : + mDocument(document), + mRenderer(renderer), + mBrush(brush) { + setDebugName("canvas"); + setSizing(ui::AxisSizing::fill(), ui::AxisSizing::fill()); + } + + void CanvasWidget::onEvent(Event& event) { + switch (event.type()) { + case Event::Type::Wheel: + if (event.scrollDelta().y == 0) break; + changeView([&event](Document& view) { + view.zoomAt(event.position(), event.scrollDelta().y > 0 ? kZoomStep : 1.0f / kZoomStep); + }); + break; + + case Event::Type::PointerDown: + trackHover(event); + if (wantsPan(event) || (mTool == CanvasTool::Hand && event.button() == PointerButton::Left)) + beginStroke(Stroke::Panning, event.position()); + else if (mTool == CanvasTool::Bucket && event.button() == PointerButton::Left && !wantsPicker()) { + fillAt(event.position()); + break; + } else if (event.button() == PointerButton::Left && mPerspectiveMoveArmed) { + const Vec2F world = mDocument.screenToWorld(event.position()); + mPerspectivePoint = 0; + for (int4 index = 1; index < mSettings.perspective.pointCount(); index++) + if ( + (mSettings.perspective.points[index] - world).length() < + (mSettings.perspective.points[mPerspectivePoint] - world).length() + ) + mPerspectivePoint = index; + beginStroke(Stroke::MovingPerspective, event.position()); + } else if (event.button() == PointerButton::Left && mSymmetryMoveArmed) { + mSymmetryGrab = mDocument.screenToWorld(event.position()) - mSettings.symmetry.center; + beginStroke(Stroke::MovingSymmetry, event.position()); + } else if (mTool == CanvasTool::Transform && event.button() == PointerButton::Left && isTransforming()) { + beginTransformDrag(event.position(), event.modifiers().shift != mSettings.transform.proportional); + beginStroke(Stroke::Transforming, event.position()); + } else if (mTool == CanvasTool::Wand && event.button() == PointerButton::Left) { + const Selection before = mDocument.selection(); + const BucketResult result = mDocument.magicWand( + mDocument.screenToWorld(event.position()), mSettings.selection.wand, selectionMode(event.modifiers()) + ); + if (!(before == mDocument.selection())) { + mDocument.commit("Magic wand"); + if (onSelectionChanged) onSelectionChanged(); + } + if (onWand) onWand(result); + break; + } else if ( + (mTool == CanvasTool::Marquee || mTool == CanvasTool::Lasso) && event.button() == PointerButton::Left + ) { + mSelectMode = selectionMode(event.modifiers()); + const bool polygon = mTool == CanvasTool::Lasso && mSettings.selection.lasso == LassoShape::Polygon; + if (polygon) { + if (mPolygon.size() >= 3 && (event.position() - mPolygon.front()).length() <= kPolygonCloseRadius) { + finishPolygon(); + break; + } + mPolygon.push_back(event.position()); + mPickLocal = event.position(); + requestPaint(); + break; + } + mToolOrigin = event.position(); + mPickLocal = event.position(); + mLassoPath = { event.position() }; + beginStroke(Stroke::Selecting, event.position()); + } else if (mTool == CanvasTool::Crop && event.button() == PointerButton::Left) { + mToolOrigin = event.position(); + mPickLocal = event.position(); + beginStroke(Stroke::Cropping, event.position()); + } else if (mTool == CanvasTool::Move && event.button() == PointerButton::Left && !wantsPicker()) { + const bool duplicate = mSettings.move.duplicate || event.modifiers().alt; + if (!mDocument.beginMove(duplicate)) { + if (onMoveFinished) onMoveFinished(false); + break; + } + mToolOrigin = event.position(); + beginStroke(Stroke::Moving, event.position()); + } else if (mTool == CanvasTool::Gradient && event.button() == PointerButton::Left && !wantsPicker()) { + mToolOrigin = event.position(); + mPickLocal = event.position(); + beginStroke(Stroke::Gradient, event.position()); + } else if (mTool == CanvasTool::Zoom && event.button() == PointerButton::Left) { + mToolOrigin = event.position(); + mToolStartZoom = mDocument.zoom(); + mToolMoved = false; + mZoomOut = event.modifiers().alt; + beginStroke(Stroke::Zooming, event.position()); + } else if (mTool == CanvasTool::Rotate && event.button() == PointerButton::Left) { + const Vec2F center = size() / 2.0f; + const Vec2F arm = event.position() - center; + mToolStartAngle = std::atan2(arm.y, arm.x); + mToolStartRotation = mDocument.rotation(); + beginStroke(Stroke::Rotating, event.position()); + } else if (wantsPicker() && event.button() == PointerButton::Left) { + mPickStart = mBrush.color; + beginStroke(Stroke::Picking, event.position()); + pickAt(event.position()); + } else if (const auto parameter = pendingAdjustment(); parameter && event.button() == PointerButton::Left) + beginAdjusting(*parameter, event.position()); + else if (event.button() == PointerButton::Left) { + const bool finger = event.sample().device == PointerDevice::Finger; + if (finger && (mGestureFingersDown || mSinceGestureMs < kFingerGestureGraceMs)) break; + if (finger) { + beginStroke(Stroke::FingerPending, event.position()); + mFingerStart = { event.position(), pressureOf(event) }; + mFingerPath.clear(); + mFingerWaitMs = 0; + startTicking(); + } else beginPainting(event.position(), pressureOf(event)); + } else return; + break; + + case Event::Type::PointerMove: + trackHover(event); + if (!mPolygon.empty()) mPickLocal = event.position(); + if (mStroke == Stroke::None) { + requestPaint(); + return; + } + if (mStroke == Stroke::FingerPending) { + mFingerPath.push_back({ event.position(), pressureOf(event) }); + if ((event.position() - mFingerStart.position).length() >= kFingerCommitTravel) commitFingerStroke(); + break; + } + continueStroke(event.position(), pressureOf(event)); + break; + + case Event::Type::PointerUp: + if (mStroke == Stroke::None) return; + if (mStroke == Stroke::FingerPending) commitFingerStroke(); + endStroke(); + break; + + case Event::Type::Gesture: handleGesture(event); break; + + default: return; + } + + event.accept(); + requestPaint(); + } + + void CanvasWidget::paint(tp::Canvas& painter) { + TP_PROFILE_SCOPE("canvas.view"); + mDocument.submitPaint(); + TP_PROFILE_COUNT("brush.worker_ms", mDocument.takePaintWorkerMs()); + TP_PROFILE_COUNT("brush.backlog", mDocument.paintBacklog()); + mRenderer.render(mDocument, mSettings, size()); + painter.drawImage(localRect(), mRenderer.image()); + if (isCatchingUp()) startTicking(); + } + + bool CanvasWidget::isCatchingUp() const { return mDocument.paintBacklog() > 0 || mRenderer.isCatchingUp(); } + + void CanvasWidget::paintOverlay(tp::Canvas& painter) { + paintStabilizerRope(painter); + paintBrushCursor(painter); + paintAdjustment(painter); + paintBrushPreview(painter); + paintPicker(painter); + paintTransform(painter); + paintSelection(painter); + paintSymmetry(painter); + paintPerspective(painter); + if (!mRenderer.debugOverlay()) return; + + const RectF bar = ui::makeRect(0, 0, size().x, kStatisticsBarHeight); + painter.rect(bar, RGBA(0, 0, 0, 0.55f)); + painter.text( + mRenderer.statistics(), bar, + TextStyle{ theme().metrics.smallFontSize, theme().palette.text, TextAlign::leftCenter() } + ); + halnf y = kStatisticsBarHeight + 6.0f; + for (const ViewportRenderer::LegendEntry& entry : mRenderer.debugLegend()) { + const RectF row = ui::makeRect(6, y, 190, kLegendRowHeight); + painter.rect(row, RGBA(0, 0, 0, 0.55f)); + painter.rect(ui::makeRect(10, y + 3, kLegendRowHeight - 6, kLegendRowHeight - 6), entry.color); + painter.text( + entry.label, + ui::makeRect(10 + kLegendRowHeight, y, 180 - kLegendRowHeight, kLegendRowHeight), + TextStyle{ theme().metrics.smallFontSize, theme().palette.text, TextAlign::leftCenter() } + ); + y += kLegendRowHeight + 2.0f; + } + } + + void CanvasWidget::paintStabilizerRope(tp::Canvas& painter) const { + if (!isStabilizerRopeVisible()) return; + const Vec2F pen = mStabilizer.pen().position; + const Vec2F line = mStabilizer.smoothed().position; + painter.line(line, pen, kRopeColor, 1.5f); + painter.circleOutline(line, 3, kRopeColor, 1.5f); + } + + void CanvasWidget::onHoverChanged() { + if (isHovered() || mStroke != Stroke::None) return; + mHoverInside = false; + requestPaint(); + } + + void CanvasWidget::trackHover(const Event& event) { + mHoverLocal = event.position(); + mHoverInside = ui::contains(localRect(), event.position()); + mHoverStylus = event.sample().isStylus(); + refreshCursor(); + } + + BrushCursorShape CanvasWidget::brushCursor() const { + if (mStroke == Stroke::Adjusting) { + BrushCursorSettings contour = mSettings.cursor; + contour.style = BrushCursorStyle::Contour; + contour.contourForEraserAndSmudge = true; + return BrushCursorShape::build(contour, mBrush, mAdjustOrigin, mDocument.zoom(), mDocument.rotation()); + } + if (!mHoverInside || mStroke == Stroke::Panning || mStroke == Stroke::Picking || wantsPicker()) return {}; + if (mTool != CanvasTool::Paint) return {}; + if (mHoverStylus && !mSettings.cursor.showForPen) return {}; + return BrushCursorShape::build(mSettings.cursor, mBrush, mHoverLocal, mDocument.zoom(), mDocument.rotation()); + } + + CursorShape CanvasWidget::systemCursor() const { + if (mStroke == Stroke::Panning) return CursorShape::Grabbing; + if (mTool == CanvasTool::Hand) return CursorShape::Grab; + if (mTool == CanvasTool::Move || mStroke == Stroke::Moving) return CursorShape::Move; + if ( + mTool == CanvasTool::Transform || mTool == CanvasTool::Crop || mTool == CanvasTool::Marquee || + mTool == CanvasTool::Lasso || mTool == CanvasTool::Wand + ) + return CursorShape::Crosshair; + if (mTool == CanvasTool::Bucket || mTool == CanvasTool::Gradient) return CursorShape::Crosshair; + if (mTool == CanvasTool::Zoom || mStroke == Stroke::Zooming) return CursorShape::ResizeHorizontal; + if (mTool == CanvasTool::Rotate || mStroke == Stroke::Rotating) return CursorShape::Crosshair; + if (mStroke == Stroke::Adjusting || pendingAdjustment()) return CursorShape::ResizeHorizontal; + if (mStroke == Stroke::Picking) + return mSettings.picker.style == PickerStyle::Cursor ? CursorShape::Crosshair : CursorShape::Hidden; + if (wantsPicker()) return CursorShape::Crosshair; + if (mHoverStylus && !mSettings.cursor.showForPen) return CursorShape::Hidden; + switch (mSettings.cursor.style) { + case BrushCursorStyle::Arrow: return CursorShape::Default; + case BrushCursorStyle::Cross: return CursorShape::Crosshair; + case BrushCursorStyle::Brush: return CursorShape::Pointer; + case BrushCursorStyle::Contour: break; + } + return brushCursor().contour ? CursorShape::Hidden : CursorShape::Crosshair; + } + + void CanvasWidget::refreshCursor() { setCursor(systemCursor()); } + + void CanvasWidget::paintBrushCursor(tp::Canvas& painter) const { + const BrushCursorShape shape = brushCursor(); + if (shape.spotlight && shape.contour) { + const RGBA color = mSettings.cursor.spotlightColor.rgba(mSettings.cursor.spotlightAlpha); + painter.polygon(shape.outline, color); + } + if (shape.contour && shape.outline.size() > 1) { + painter.polyline(shape.outline, kContourOuter, 3.0f); + painter.polyline(shape.outline, kContourInner, 1.0f); + } + if (shape.crosshair && shape.contour) { + const halnf arm = BrushCursorShape::kCrossArm; + const Vec2F c = shape.center; + for (const auto& [color, width] : { std::pair{ kContourOuter, 3.0f }, std::pair{ kContourInner, 1.0f } }) { + painter.line({ c.x - arm, c.y }, { c.x + arm, c.y }, color, width); + painter.line({ c.x, c.y - arm }, { c.x, c.y + arm }, color, width); + } + } + } + + std::optional CanvasWidget::pendingAdjustment() const { + if (heldAdjustment) + if (const auto held = heldAdjustment()) return held; + return mAdjustTool; + } + + halnf CanvasWidget::adjustedValue(BrushParameter parameter, halnf start, halnf dragPixels) { + switch (parameter) { + case BrushParameter::Size: return std::round(start * std::exp2(dragPixels / kSizeDoublingPixels)); + case BrushParameter::Angle: + { + const halnf angle = std::fmod(start + dragPixels * kAngleDegreesPerPixel, 360.0f); + return angle < 0 ? angle + 360.0f : angle; + } + default: return start + dragPixels * kAmountPerPixel; + } + } + + void CanvasWidget::beginAdjusting(BrushParameter parameter, Vec2F local) { + mAdjusting = parameter; + mAdjustOrigin = local; + mAdjustStart = mBrush.get(parameter); + beginStroke(Stroke::Adjusting, local); + refreshCursor(); + } + + std::string CanvasWidget::adjustmentText() const { + const halnf value = mBrush.get(mAdjusting); + char text[64]; + switch (mAdjusting) { + case BrushParameter::Size: std::snprintf(text, sizeof(text), "Size %d px", (int) std::round(value)); break; + case BrushParameter::Angle: std::snprintf(text, sizeof(text), "Angle %d°", (int) std::round(value)); break; + case BrushParameter::Flow: std::snprintf(text, sizeof(text), "Flow %d%%", (int) std::round(value * 100)); break; + default: std::snprintf(text, sizeof(text), "Opacity %d%%", (int) std::round(value * 100)); break; + } + return text; + } + + void CanvasWidget::paintAdjustment(tp::Canvas& painter) const { + if (mStroke != Stroke::Adjusting) return; + const halnf fontSize = theme().metrics.smallFontSize; + const std::string text = adjustmentText(); + const halnf width = painter.textWidth(text, fontSize) + 16; + const RectF box = ui::makeRect(mAdjustOrigin.x - width * 0.5f, mAdjustOrigin.y - 40, width, fontSize + 10); + painter.rect(box, RGBA(0, 0, 0, 0.65f), 4); + painter.text(text, box, TextStyle{ fontSize, theme().palette.text, TextAlign::centered() }); + } + + void CanvasWidget::setBrushPreview(std::optional parameter) { + mBrushPreview = parameter; + requestPaint(); + } + + BrushCursorShape CanvasWidget::brushPreviewShape() const { + if (!mBrushPreview) return {}; + BrushCursorSettings contour = mSettings.cursor; + contour.style = BrushCursorStyle::Contour; + contour.contourForEraserAndSmudge = true; + contour.spotlight = false; + return BrushCursorShape::build(contour, mBrush, localRect().center(), mDocument.zoom(), mDocument.rotation()); + } + + RectF CanvasWidget::brushPreviewBackdrop() const { + const BrushCursorShape shape = brushPreviewShape(); + const halnf padding = theme().metrics.controlHeight; + const halnf half = std::min(shape.radius + padding, std::min(size().x, size().y) * 0.5f); + const halnf textRoom = theme().metrics.lineHeight + 8; + return ui::makeRect(shape.center.x - half, shape.center.y - half, half * 2, half * 2 + textRoom); + } + + std::string CanvasWidget::brushPreviewText() const { + if (!mBrushPreview) return {}; + if (*mBrushPreview == BrushParameter::Opacity) + return std::to_string((int) std::round(mBrush.opacity() * 100)) + "%"; + return std::to_string((int) std::round(mBrush.size())) + " px"; + } + + void CanvasWidget::paintBrushPreview(tp::Canvas& painter) const { + if (!mBrushPreview) return; + const BrushCursorShape shape = brushPreviewShape(); + const RectF backdrop = brushPreviewBackdrop(); + painter.rect(backdrop, RGBA(0.08f, 0.08f, 0.09f, 0.82f), std::min(backdrop.size.x * 0.5f, 24.0f)); + const RGBA color = { mBrush.color.r, mBrush.color.g, mBrush.color.b, mBrush.erases() ? 0.0f : mBrush.opacity() }; + if (shape.outline.size() > 2 && color.a > 0) painter.polygon(shape.outline, color); + if (shape.outline.size() > 1) { + painter.polyline(shape.outline, kContourOuter, 3.0f); + painter.polyline(shape.outline, kContourInner, 1.0f); + } + const halnf fontSize = theme().metrics.fontSize; + const RectF label = + ui::makeRect(backdrop.pos.x, backdrop.pos.y + backdrop.size.y - fontSize - 12, backdrop.size.x, fontSize + 8); + painter.text(brushPreviewText(), label, TextStyle{ fontSize, RGBA(1, 1, 1, 1), TextAlign::centered() }); + } + + bool CanvasWidget::isStabilizerRopeVisible() const { + return mStroke == Stroke::Painting && !mLineLocked && mStabilizer.isSmoothing() && !mStabilizer.isSettled(); + } + + bool CanvasWidget::wantsPan(const Event& event) const { + const bool middle = event.button() == PointerButton::Middle; + const bool spaceDrag = event.button() == PointerButton::Left && ui() && ui()->isKeyDown(Key::Space); + return middle || spaceDrag; + } + + halnf CanvasWidget::pressureOf(const Event& event) { return event.sample().isStylus() ? event.sample().pressure : 1.0f; } + + Vec2F SymmetrySettings::mirror(Vec2F point) const { + const Vec2F direction = { std::cos(angle), std::sin(angle) }; + const Vec2F offset = point - center; + const halnf along = offset.x * direction.x + offset.y * direction.y; + const Vec2F foot = center + direction * along; + return foot * 2.0f - point; + } + + int4 PerspectiveSettings::pointCount() const { + switch (type) { + case PerspectiveType::OnePoint: + case PerspectiveType::Radial: + case PerspectiveType::Isometric: return 1; + case PerspectiveType::TwoPoint: return 2; + case PerspectiveType::ThreePoint: return 3; + } + return 1; + } + + std::vector PerspectiveSettings::directions(Vec2F from) const { + std::vector result; + const auto toward = [&result, from](Vec2F point) { + const Vec2F direction = point - from; + const halnf length = direction.length(); + if (length > 1e-3f) result.push_back(direction / length); + }; + switch (type) { + case PerspectiveType::OnePoint: + toward(points[0]); + result.push_back({ 1, 0 }); + result.push_back({ 0, 1 }); + break; + case PerspectiveType::TwoPoint: + toward(points[0]); + toward(points[1]); + result.push_back({ 0, 1 }); + break; + case PerspectiveType::ThreePoint: + toward(points[0]); + toward(points[1]); + toward(points[2]); + break; + case PerspectiveType::Isometric: + { + const halnf angle = isometricDegrees * 3.14159265f / 180.0f; + result.push_back({ std::cos(angle), std::sin(angle) }); + result.push_back({ std::cos(angle), -std::sin(angle) }); + result.push_back({ 0, 1 }); + break; + } + case PerspectiveType::Radial: toward(points[0]); break; + } + return result; + } + + Vec2F PerspectiveSettings::snap(Vec2F from, Vec2F to) const { + const Vec2F drag = to - from; + const halnf length = drag.length(); + if (length <= 1e-3f) return to; + Vec2F best = to; + halnf bestAlignment = -1; + for (const Vec2F& direction : directions(from)) { + const halnf along = drag.x * direction.x + drag.y * direction.y; + const halnf alignment = std::abs(along) / length; + if (alignment > bestAlignment) { + bestAlignment = alignment; + best = from + direction * along; + } + } + return best; + } + + void CanvasWidget::resetPerspective() { + PerspectiveSettings& perspective = mSettings.perspective; + const Vec2F center = mDocument.screenToWorld(size() / 2.0f); + const halnf reach = size().x / std::max(mDocument.zoom(), 1e-3f) * 0.4f; + perspective.points[0] = perspective.pointCount() == 1 ? center : center + Vec2F{ -reach, 0.0f }; + perspective.points[1] = center + Vec2F{ reach, 0.0f }; + perspective.points[2] = center + Vec2F{ 0.0f, reach * 1.5f }; + perspective.placed = true; + requestPaint(); + } + + void CanvasWidget::paintPerspective(tp::Canvas& painter) const { + if (mSettings.line.mode != LineMode::Perspective) return; + const PerspectiveSettings& perspective = mSettings.perspective; + const RGBA guide = { 1.0f, 0.55f, 0.2f, 0.35f }; + const RGBA point = { 1.0f, 0.55f, 0.2f, 0.95f }; + const halnf reach = size().length(); + if (perspective.type == PerspectiveType::Isometric) { + const Vec2F origin = mDocument.worldToScreen(perspective.points[0]); + for (const Vec2F& direction : perspective.directions(perspective.points[0])) { + const Vec2F screen = mDocument.worldToScreen(perspective.points[0] + direction) - origin; + const halnf length = screen.length(); + if (length <= 0) continue; + for (int4 offset = -6; offset <= 6; offset++) { + const Vec2F normal = Vec2F{ -screen.y, screen.x } / length * (halnf) offset * 60.0f; + painter.line( + origin + normal - screen / length * reach, origin + normal + screen / length * reach, guide, 1.0f + ); + } + } + painter.circle(origin, 5, point); + return; + } + for (int4 index = 0; index < perspective.pointCount(); index++) { + const Vec2F vanishing = mDocument.worldToScreen(perspective.points[index]); + for (int4 ray = 0; ray < 24; ray++) { + const halnf angle = 2.0f * 3.14159265f * (halnf) ray / 24.0f; + painter.line(vanishing, vanishing + Vec2F{ std::cos(angle), std::sin(angle) } * reach, guide, 1.0f); + } + painter.circle(vanishing, 7, kContourOuter); + painter.circle(vanishing, 5, point); + } + } + + void CanvasWidget::stamp(Vec2F world, halnf pressure) { + mDocument.stampBrushDab(world, mBrush, pressure); + if (mSettings.symmetry.enabled && !mBrush.smudges()) + mDocument.stampBrushDab(mSettings.symmetry.mirror(world), mBrush, pressure); + } + + void CanvasWidget::resetSymmetry() { + mSettings.symmetry.center = mDocument.screenToWorld(size() / 2.0f); + mSettings.symmetry.angle = 1.5707963f - mDocument.rotation(); + requestPaint(); + } + + void CanvasWidget::paintSymmetry(tp::Canvas& painter) const { + const SymmetrySettings& symmetry = mSettings.symmetry; + if (!symmetry.enabled || !symmetry.visible) return; + const Vec2F center = mDocument.worldToScreen(symmetry.center); + Vec2F along = + mDocument.worldToScreen(symmetry.center + Vec2F{ std::cos(symmetry.angle), std::sin(symmetry.angle) }) - center; + const halnf length = along.length(); + if (length <= 0) return; + along = along / length; + const halnf reach = size().length(); + const RGBA color = symmetry.locked ? RGBA(0.4f, 0.6f, 1.0f, 0.6f) : RGBA(0.4f, 0.6f, 1.0f, 0.9f); + painter.line(center - along * reach, center + along * reach, kContourOuter, 3.0f); + painter.line(center - along * reach, center + along * reach, color, 1.5f); + } + + void CanvasWidget::beginPainting(Vec2F local, halnf pressure) { + const std::string refusal = mDocument.strokeRefusal(mBrush); + if (!refusal.empty()) { + if (onRefused) onRefused(refusal); + return; + } + + mDabCarry = 0; + mStabilizer.begin({ local, pressure }, activeStabilizer()); + mStabilizer.takeOutput(); + mLastPainted = { mDocument.screenToWorld(local), pressure }; + mStrokeStart = mLastPainted; + mHoldAnchor = local; + mHoldMs = 0; + mLineLocked = mSettings.line.mode != LineMode::Normal; + if (mSettings.line.mode == LineMode::Perspective && !mSettings.perspective.placed) resetPerspective(); + mEllipseStroke = mSettings.line.mode == LineMode::Ellipse; + if (!mEllipseStroke) mDocument.beginPathStroke(mBrush, strokeMirror(), { mLastPainted.position, pressure }); + beginStroke(Stroke::Painting, local); + if (mStabilizer.isSmoothing() || mSettings.line.holdForLine) startTicking(); + } + + halnf CanvasWidget::activeStabilizer() const { + return mSettings.useBrushStabilizer ? mBrush.stabilizer() : mSettings.globalStabilizer; + } + + void CanvasWidget::beginStroke(Stroke stroke, Vec2F local) { + mStroke = stroke; + mLastLocal = local; + ui()->capturePointer(*this); + } + + void CanvasWidget::continueStroke(Vec2F local, halnf pressure) { + TP_PROFILE_COUNT("stroke.input_points", mStroke == Stroke::Painting ? 1 : 0); + if (mStroke == Stroke::Panning) { + const Vec2F delta = local - mLastLocal; + changeView([delta](Document& view) { view.pan(delta); }); + } else if (mStroke == Stroke::Adjusting) { + mBrush.set(mAdjusting, adjustedValue(mAdjusting, mAdjustStart, local.x - mAdjustOrigin.x)); + if (onBrushAdjusted) onBrushAdjusted(); + } else if (mStroke == Stroke::Picking) { + pickAt(local); + } else if (mStroke == Stroke::Gradient || mStroke == Stroke::Cropping) { + mPickLocal = local; + requestPaint(); + } else if (mStroke == Stroke::Selecting) { + mPickLocal = local; + if (mTool == CanvasTool::Lasso) mLassoPath.push_back(local); + requestPaint(); + } else if (mStroke == Stroke::Transforming) { + continueTransformDrag(local); + } else if (mStroke == Stroke::MovingPerspective) { + mSettings.perspective.points[mPerspectivePoint] = mDocument.screenToWorld(local); + requestPaint(); + } else if (mStroke == Stroke::MovingSymmetry) { + mSettings.symmetry.center = mDocument.screenToWorld(local) - mSymmetryGrab; + requestPaint(); + } else if (mStroke == Stroke::Moving) { + const Vec2F from = mDocument.screenToWorld(mToolOrigin); + const Vec2F to = mDocument.screenToWorld(local); + mDocument.moveTo((int4) std::lround(to.x - from.x), (int4) std::lround(to.y - from.y)); + requestPaint(); + } else if (mStroke == Stroke::Zooming) { + const halnf dragged = local.x - mToolOrigin.x; + if (std::abs(dragged) > kToolClickPixels) mToolMoved = true; + if (mToolMoved) { + const halnf target = mToolStartZoom * std::exp2(dragged / kZoomDoublingPixels); + const Vec2F origin = mToolOrigin; + changeView([origin, target](Document& view) { view.zoomAt(origin, target / view.zoom()); }); + } + } else if (mStroke == Stroke::Rotating) { + const Vec2F center = size() / 2.0f; + const Vec2F arm = local - center; + if (arm.length() > kToolClickPixels) { + halnf rotation = mToolStartRotation + std::atan2(arm.y, arm.x) - mToolStartAngle; + rotation = std::remainder(rotation, 2.0f * 3.14159265f); + if (std::abs(rotation) < kRotationSnapRadians) rotation = 0; + changeView([center, rotation](Document& view) { view.setRotationAt(center, rotation); }); + } + } else if (mEllipseStroke) { + redrawEllipse(local, ui() && (ui()->isKeyDown(Key::LeftShift) || ui()->isKeyDown(Key::RightShift))); + } else if (mLineLocked) { + redrawLine(local); + } else { + if ((local - mHoldAnchor).length() > kHoldStillPixels) { + mHoldAnchor = local; + mHoldMs = 0; + } + mStabilizer.moveTo({ local, pressure }); + paintStabilized(); + } + mLastLocal = local; + } + + void CanvasWidget::commitFingerStroke() { + if (mStroke != Stroke::FingerPending) return; + mStroke = Stroke::None; + const std::vector path = std::move(mFingerPath); + mFingerPath.clear(); + beginPainting(mFingerStart.position, mFingerStart.pressure); + if (mStroke != Stroke::Painting) return; + for (const FingerPoint& point : path) + continueStroke(point.position, point.pressure); + } + + void CanvasWidget::onTick(halnf deltaMs) { + if (mSinceGestureMs < kFingerGestureGraceMs) mSinceGestureMs += deltaMs; + if (mStroke == Stroke::FingerPending) { + mFingerWaitMs += deltaMs; + if (mFingerWaitMs >= kFingerCommitMs) commitFingerStroke(); + } + advanceView(deltaMs); + const bool smoothing = mStroke == Stroke::Painting && mStabilizer.isActive() && !mLineLocked; + if (smoothing) { + mStabilizer.advance(deltaMs); + paintStabilized(); + } + const bool holding = mStroke == Stroke::Painting && mSettings.line.holdForLine && !mLineLocked; + if (holding) { + mHoldMs += deltaMs; + const Vec2F start = mDocument.worldToScreen(mStrokeStart.position); + if (mHoldMs >= mSettings.line.holdSeconds * 1000.0f && (mHoldAnchor - start).length() >= kMinimumLinePixels) { + mLineLocked = true; + mStabilizer.finish(); + mStabilizer.takeOutput(); + redrawLine(mHoldAnchor); + } + } + const bool catchingUp = isCatchingUp(); + if (catchingUp) requestPaint(); + const bool waiting = mStroke == Stroke::FingerPending || mSinceGestureMs < kFingerGestureGraceMs; + if (!smoothing && !holding && !catchingUp && !mViewMoving && !waiting) stopTicking(); + } + + void CanvasWidget::redrawLine(Vec2F local) { + mDocument.restoreStrokeBase(); + mDabCarry = 0; + Vec2F end = mDocument.screenToWorld(local); + if (mSettings.line.mode == LineMode::Perspective) end = mSettings.perspective.snap(mStrokeStart.position, end); + stamp(mStrokeStart.position, mStrokeStart.pressure); + paintSegment(mStrokeStart.position, end, mStrokeStart.pressure, mStrokeStart.pressure); + mLastPainted = { end, mStrokeStart.pressure }; + requestPaint(); + } + + void CanvasWidget::redrawEllipse(Vec2F local, bool circle) { + mDocument.restoreStrokeBase(); + mDabCarry = 0; + const Vec2F a = mStrokeStart.position; + Vec2F b = mDocument.screenToWorld(local); + if (circle) { + const halnf side = std::max(std::abs(b.x - a.x), std::abs(b.y - a.y)); + b = { a.x + (b.x < a.x ? -side : side), a.y + (b.y < a.y ? -side : side) }; + } + const Vec2F center = (a + b) / 2.0f; + const Vec2F radius = { std::abs(b.x - a.x) / 2.0f, std::abs(b.y - a.y) / 2.0f }; + if (radius.x < 0.5f && radius.y < 0.5f) return; + Vec2F previous = { center.x + radius.x, center.y }; + stamp(previous, mStrokeStart.pressure); + for (int4 step = 1; step <= kEllipseSegments; step++) { + const halnf angle = 2.0f * 3.14159265f * (halnf) step / (halnf) kEllipseSegments; + const Vec2F next = { center.x + std::cos(angle) * radius.x, center.y + std::sin(angle) * radius.y }; + paintSegment(previous, next, mStrokeStart.pressure, mStrokeStart.pressure); + previous = next; + } + mLastPainted = { previous, mStrokeStart.pressure }; + requestPaint(); + } + + void CanvasWidget::paintStabilized() { + TP_PROFILE_SCOPE("stroke.paint"); + bool painted = false; + for (const StrokePoint& screen : mStabilizer.takeOutput()) { + const StrokePoint point = { mDocument.screenToWorld(screen.position), screen.pressure }; + mDocument.addPathSample({ point.position, point.pressure }); + mLastPainted = point; + painted = true; + } + walkPath(false); + if (painted || mStabilizer.isSmoothing()) requestPaint(); + } + + void CanvasWidget::walkPath(bool finished) { mDocument.advancePathStroke(finished); } + + StrokeMirror CanvasWidget::strokeMirror() const { + if (!mSettings.symmetry.enabled || mBrush.smudges()) return {}; + const SymmetrySettings symmetry = mSettings.symmetry; + return [symmetry](Vec2F point) { return symmetry.mirror(point); }; + } + + void CanvasWidget::handleGesture(const Event& event) { + const TouchGesture* gesture = event.gesture(); + if (gesture->phase == GesturePhase::Begin) { + mGestureFingersDown = true; + if (mStroke == Stroke::FingerPending) { + mStroke = Stroke::None; + ui()->releasePointer(*this); + } else if (mStroke == Stroke::Painting) discardStroke(); + else if (mStroke == Stroke::Panning) endStroke(); + return; + } + if (gesture->phase == GesturePhase::End) { + mGestureFingersDown = false; + mSinceGestureMs = 0; + startTicking(); + changeView([&event](Document& view) { + if (std::abs(view.rotation()) < kRotationSnapRadians) view.setRotationAt(event.position(), 0); + }); + if (gesture->tapFingers > 0 && onFingerTap) onFingerTap(gesture->tapFingers); + return; + } + + changeView([&event, gesture](Document& view) { + view.pan(gesture->pan); + if (gesture->scale != 1) view.zoomAt(event.position(), gesture->scale); + if (gesture->rotation != 0) view.rotateAt(event.position(), gesture->rotation); + }); + } + + SpringParameters CanvasWidget::viewSpring(halnf stiffness) { + return SpringParameters::criticallyDamped(40.0f + 360.0f * (1.0f - std::clamp(stiffness, 0.0f, 1.0f))); + } + + void CanvasWidget::changeView(const std::function& change) { + if (mSettings.lockView) return; + const bool instant = mSettings.viewStiffness >= 1.0f; + if (mViewMoving && !showsSpringView()) mViewMoving = false; + if (!mViewMoving) mViewTarget = { mDocument.panWorld(), mDocument.zoom(), mDocument.rotation() }; + + const ViewTarget shown = { mDocument.panWorld(), mDocument.zoom(), mDocument.rotation() }; + mDocument.setPanWorld(mViewTarget.pan); + mDocument.setZoom(mViewTarget.zoom); + mDocument.setRotation(mViewTarget.rotation); + change(mDocument); + mViewTarget = { mDocument.panWorld(), mDocument.zoom(), mDocument.rotation() }; + if (instant) { + mViewMoving = false; + requestPaint(); + return; + } + + const SpringParameters spring = viewSpring(mSettings.viewStiffness); + if (!mViewMoving) { + mPanSpring.snapTo(shown.pan); + mZoomSpring.snapTo(std::log(shown.zoom)); + mRotationSpring.snapTo(shown.rotation); + } + mPanSpring.setParameters(spring); + mZoomSpring.setParameters(spring); + mRotationSpring.setParameters(spring); + mPanSpring.setTarget(mViewTarget.pan); + mZoomSpring.setTarget(std::log(mViewTarget.zoom)); + constexpr halnf kHalfTurn = 3.14159265f; + halnf turn = std::fmod(mViewTarget.rotation - mRotationSpring.value(), 2 * kHalfTurn); + if (turn > kHalfTurn) turn -= 2 * kHalfTurn; + if (turn < -kHalfTurn) turn += 2 * kHalfTurn; + mRotationSpring.setTarget(mRotationSpring.value() + turn); + + mDocument.setPanWorld(shown.pan); + mDocument.setZoom(shown.zoom); + mDocument.setRotation(shown.rotation); + mShownView = { mDocument.panWorld(), mDocument.zoom(), mDocument.rotation() }; + mViewMoving = true; + startTicking(); + requestPaint(); + } + + bool CanvasWidget::showsSpringView() const { + return mDocument.panWorld() == mShownView.pan && mDocument.zoom() == mShownView.zoom && + mDocument.rotation() == mShownView.rotation; + } + + void CanvasWidget::advanceView(halnf deltaMs) { + if (!mViewMoving) return; + if (!showsSpringView()) { + mViewMoving = false; + return; + } + mPanSpring.step(deltaMs); + mZoomSpring.step(deltaMs); + mRotationSpring.step(deltaMs); + + const halnf zoom = std::exp(mZoomSpring.value()); + const bool settled = + mPanSpring.isSettled(0.05f / zoom) && mZoomSpring.isSettled(0.0005f) && mRotationSpring.isSettled(0.0005f); + if (settled) { + mDocument.setPanWorld(mViewTarget.pan); + mDocument.setZoom(mViewTarget.zoom); + mDocument.setRotation(mViewTarget.rotation); + mViewMoving = false; + } else { + mDocument.setPanWorld(mPanSpring.value()); + mDocument.setZoom(zoom); + mDocument.setRotation(mRotationSpring.value()); + } + mShownView = { mDocument.panWorld(), mDocument.zoom(), mDocument.rotation() }; + requestPaint(); + } + + void CanvasWidget::discardStroke() { + mStabilizer.finish(); + mStabilizer.takeOutput(); + stopTicking(); + mDocument.discardStroke(); + mStroke = Stroke::None; + ui()->releasePointer(*this); + refreshCursor(); + } + + void CanvasWidget::cancelStroke() { + if (mStroke == Stroke::None) return; + endStroke(); + } + + void CanvasWidget::endStroke() { + if (mStroke == Stroke::Gradient) finishGradient(mPickLocal); + if (mStroke == Stroke::MovingSymmetry) mSymmetryMoveArmed = false; + if (mStroke == Stroke::MovingPerspective) mPerspectiveMoveArmed = false; + if (mStroke == Stroke::Selecting) finishSelection(mPickLocal); + if (mStroke == Stroke::Cropping) { + const Vec2F a = mDocument.screenToWorld(mToolOrigin); + const Vec2F b = mDocument.screenToWorld(mPickLocal); + CropResult result = CropResult::NoRegion; + if ((mPickLocal - mToolOrigin).length() >= kToolClickPixels) { + result = mDocument.cropTo( + (int4) std::lround(std::min(a.x, b.x)), + (int4) std::lround(std::min(a.y, b.y)), + (int4) std::lround(std::max(a.x, b.x)), + (int4) std::lround(std::max(a.y, b.y)) + ); + if (result == CropResult::Cropped) mDocument.commit("Crop pixels"); + } + if (onCrop) onCrop(result); + } + if (mStroke == Stroke::Moving) { + const bool moved = mDocument.finishMove(); + if (moved) mDocument.commit(mSettings.move.duplicate ? "Duplicate pixels" : "Move pixels"); + if (onMoveFinished) onMoveFinished(moved); + } + if (mStroke == Stroke::Zooming && !mToolMoved) { + const Vec2F origin = mToolOrigin; + const halnf factor = mZoomOut ? 1.0f / kZoomClickFactor : kZoomClickFactor; + changeView([origin, factor](Document& view) { view.zoomAt(origin, factor); }); + } + if (mStroke == Stroke::Painting) { + mStabilizer.finish(); + if (mLineLocked) mStabilizer.takeOutput(); + else { + paintStabilized(); + walkPath(true); + } + stopTicking(); + mDocument.finishStroke( + mEllipseStroke ? std::string("Ellipse") + : mLineLocked ? std::string("Line") + : strokeLabel(mBrush) + ); + mLineLocked = false; + mEllipseStroke = false; + } + const bool painted = mStroke == Stroke::Painting; + if (mStroke == Stroke::Adjusting && mAdjustOnce) mAdjustTool.reset(); + mStroke = Stroke::None; + ui()->releasePointer(*this); + refreshCursor(); + if (painted && onStrokeFinished) onStrokeFinished(); + } + + void CanvasWidget::fillAt(Vec2F local) { + RGBA color = mBrush.color; + color.a = mBrush.opacity(); + const BucketResult result = mDocument.bucketFill(mDocument.screenToWorld(local), color, mSettings.bucket); + if (result == BucketResult::Filled) mDocument.commit("Paint bucket"); + if (onBucket) onBucket(result); + requestPaint(); + } + + void CanvasWidget::setTool(CanvasTool tool) { + if (tool == CanvasTool::Transform && mTool != CanvasTool::Transform) { + if (!startTransform()) { + if (onTransformRefused) onTransformRefused(); + return; + } + } else if (tool != CanvasTool::Transform && mTool == CanvasTool::Transform) { + applyTransform(); + } + mTool = tool; + refreshCursor(); + requestPaint(); + } + + bool CanvasWidget::startTransform() { + if (!mDocument.beginTransform()) return false; + int4 left = 0, top = 0, right = 0, bottom = 0; + mDocument.transformBounds(left, top, right, bottom); + mTransform = LayerTransform(); + mTransform.pivot = { (left + right) / 2.0f, (top + bottom) / 2.0f }; + if (onTransformActive) onTransformActive(true); + return true; + } + + void CanvasWidget::setPendingTransform(const LayerTransform& transform) { + mTransform = transform; + mDocument.previewTransform(mTransform); + requestPaint(); + } + + void CanvasWidget::applyTransform() { + if (!isTransforming()) return; + if (mStroke == Stroke::Transforming) endStroke(); + const bool changed = mDocument.finishTransform(); + if (changed) mDocument.commit("Transform"); + mTool = CanvasTool::Paint; + refreshCursor(); + requestPaint(); + if (onTransformActive) onTransformActive(false); + if (onTransformApplied) onTransformApplied(changed); + } + + void CanvasWidget::cancelTransform() { + if (!isTransforming()) return; + if (mStroke == Stroke::Transforming) endStroke(); + mDocument.cancelTransform(); + mTool = CanvasTool::Paint; + refreshCursor(); + requestPaint(); + if (onTransformActive) onTransformActive(false); + } + + LayerTransform CanvasWidget::rebased(const LayerTransform& transform, Vec2F pivot) { + LayerTransform result = transform; + const Vec2F image = transform.apply(pivot); + result.pivot = pivot; + result.offset = image - pivot; + return result; + } + + Vec2F CanvasWidget::transformCenterWorld(const LayerTransform& transform) const { + int4 left = 0, top = 0, right = 0, bottom = 0; + mDocument.transformBounds(left, top, right, bottom); + return transform.apply({ (left + right) / 2.0f, (top + bottom) / 2.0f }); + } + + void CanvasWidget::flipTransform(bool horizontal) { + if (!isTransforming()) return; + int4 left = 0, top = 0, right = 0, bottom = 0; + mDocument.transformBounds(left, top, right, bottom); + LayerTransform flipped = rebased(mTransform, { (left + right) / 2.0f, (top + bottom) / 2.0f }); + if (horizontal) flipped.scale.x = -flipped.scale.x; + else flipped.scale.y = -flipped.scale.y; + setPendingTransform(flipped); + } + + std::array CanvasWidget::transformCorners() const { + int4 left = 0, top = 0, right = 0, bottom = 0; + mDocument.transformBounds(left, top, right, bottom); + const Vec2F source[4] = { + { (halnf) left, (halnf) top }, + { (halnf) right, (halnf) top }, + { (halnf) right, (halnf) bottom }, + { (halnf) left, (halnf) bottom }, + }; + std::array corners; + for (int4 index = 0; index < 4; index++) + corners[(size_t) index] = mDocument.worldToScreen(mTransform.apply(source[index])); + return corners; + } + + void CanvasWidget::beginTransformDrag(Vec2F local, bool proportional) { + const std::array corners = transformCorners(); + mDragTransform = mTransform; + mDragStartWorld = mDocument.screenToWorld(local); + mScaleProportional = proportional; + for (int4 index = 0; index < 4; index++) { + if ((corners[(size_t) index] - local).length() <= kTransformHandleRadius) { + mTransformDrag = TransformDrag::Scale; + mScaleCorner = index; + return; + } + } + bool inside = true; + halnf sign = 0; + for (int4 index = 0; index < 4; index++) { + const Vec2F a = corners[(size_t) index]; + const Vec2F b = corners[(size_t) ((index + 1) % 4)]; + const halnf cross = (b.x - a.x) * (local.y - a.y) - (b.y - a.y) * (local.x - a.x); + if (cross == 0) continue; + if (sign == 0) sign = cross > 0 ? 1.0f : -1.0f; + else if ((cross > 0 ? 1.0f : -1.0f) != sign) inside = false; + } + mTransformDrag = inside ? TransformDrag::Move : TransformDrag::Rotate; + } + + void CanvasWidget::continueTransformDrag(Vec2F local) { + const Vec2F world = mDocument.screenToWorld(local); + LayerTransform next = mDragTransform; + if (mTransformDrag == TransformDrag::Move) { + next.offset = mDragTransform.offset + (world - mDragStartWorld); + } else if (mTransformDrag == TransformDrag::Rotate) { + const Vec2F center = transformCenterWorld(mDragTransform); + const Vec2F from = mDragStartWorld - center; + const Vec2F to = world - center; + const halnf delta = std::atan2(to.y, to.x) - std::atan2(from.y, from.x); + const halnf c = std::cos(delta); + const halnf s = std::sin(delta); + const Vec2F image = mDragTransform.pivot + mDragTransform.offset - center; + next.rotation = mDragTransform.rotation + delta; + next.offset = center + Vec2F{ image.x * c - image.y * s, image.x * s + image.y * c } - mDragTransform.pivot; + } else { + int4 left = 0, top = 0, right = 0, bottom = 0; + mDocument.transformBounds(left, top, right, bottom); + const Vec2F source[4] = { + { (halnf) left, (halnf) top }, + { (halnf) right, (halnf) top }, + { (halnf) right, (halnf) bottom }, + { (halnf) left, (halnf) bottom }, + }; + const Vec2F corner = source[mScaleCorner]; + const Vec2F opposite = source[(mScaleCorner + 2) % 4]; + next = rebased(mDragTransform, opposite); + const Vec2F anchor = next.apply(opposite); + const halnf c = std::cos(next.rotation); + const halnf s = std::sin(next.rotation); + const Vec2F reach = world - anchor; + const Vec2F turned = { reach.x * c + reach.y * s, -reach.x * s + reach.y * c }; + const Vec2F span = corner - opposite; + halnf sx = span.x != 0 ? turned.x / span.x : 1.0f; + halnf sy = span.y != 0 ? turned.y / span.y : 1.0f; + if (mScaleProportional) { + const halnf ratio = + std::abs(mDragTransform.scale.y) > 0 ? std::abs(mDragTransform.scale.x / mDragTransform.scale.y) : 1.0f; + const halnf uniform = (std::abs(sx) / std::max(ratio, kMinimumTransformScale) + std::abs(sy)) / 2.0f; + sx = (sx < 0 ? -1.0f : 1.0f) * uniform * ratio; + sy = (sy < 0 ? -1.0f : 1.0f) * uniform; + } + const auto limit = [](halnf value) { + return std::abs(value) < kMinimumTransformScale ? (value < 0 ? -kMinimumTransformScale : kMinimumTransformScale) + : value; + }; + next.scale = { limit(sx), limit(sy) }; + } + setPendingTransform(next); + } + + SelectionMode CanvasWidget::selectionMode(const KeyModifiers& modifiers) const { + if (modifiers.alt) return SelectionMode::Subtract; + if (modifiers.shift) return SelectionMode::Add; + return SelectionMode::Replace; + } + + void CanvasWidget::finishSelection(Vec2F local) { + Selection& selection = mDocument.editSelection(); + const Selection before = selection; + if ((local - mToolOrigin).length() < kToolClickPixels) { + if (mSelectMode == SelectionMode::Replace && mSettings.selection.clickToDeselect) selection.clear(); + } else if (mTool == CanvasTool::Lasso) { + std::vector world; + world.reserve(mLassoPath.size()); + for (const Vec2F& point : mLassoPath) + world.push_back(mDocument.screenToWorld(point)); + selection.addPolygon(world, mSelectMode); + } else { + const Vec2F a = mDocument.screenToWorld(mToolOrigin); + const Vec2F b = mDocument.screenToWorld(local); + const Vec2F low = { std::min(a.x, b.x), std::min(a.y, b.y) }; + const Vec2F high = { std::max(a.x, b.x), std::max(a.y, b.y) }; + if (mSettings.selection.marquee == MarqueeShape::Ellipse) + selection.addEllipse((low + high) / 2.0f, (high - low) / 2.0f, mSelectMode); + else + selection.addRectangle( + (int4) std::lround(low.x), + (int4) std::lround(low.y), + (int4) std::lround(high.x), + (int4) std::lround(high.y), + mSelectMode + ); + } + mLassoPath.clear(); + if (!(before == selection)) { + if (mSettings.selection.autoFill && selection.isActive() && mSelectMode != SelectionMode::Subtract) { + RGBA color = mBrush.color; + color.a = 1; + mDocument.fillSelection(color); + } + mDocument.commit(selection.isActive() ? "Select" : "Deselect"); + if (onSelectionChanged) onSelectionChanged(); + } + requestPaint(); + } + + void CanvasWidget::finishPolygon() { + if (mPolygon.size() >= 3) { + Selection& selection = mDocument.editSelection(); + const Selection before = selection; + std::vector world; + for (const Vec2F& point : mPolygon) + world.push_back(mDocument.screenToWorld(point)); + selection.addPolygon(world, mSelectMode); + if (!(before == selection)) { + if (mSettings.selection.autoFill && selection.isActive() && mSelectMode != SelectionMode::Subtract) { + RGBA color = mBrush.color; + color.a = 1; + mDocument.fillSelection(color); + } + mDocument.commit("Select"); + if (onSelectionChanged) onSelectionChanged(); + } + } + mPolygon.clear(); + requestPaint(); + } + + void CanvasWidget::cancelPolygon() { + mPolygon.clear(); + requestPaint(); + } + + void CanvasWidget::paintSelection(tp::Canvas& painter) const { + const Selection& selection = mDocument.selection(); + if (selection.isActive()) { + const RGBA dark = { 0, 0, 0, 0.9f }; + const RGBA light = { 1, 1, 1, 0.9f }; + for (const auto& [from, to] : selection.outline()) { + const Vec2F a = mDocument.worldToScreen(from); + const Vec2F b = mDocument.worldToScreen(to); + painter.line(a, b, dark, 1.0f); + const halnf phase = std::fmod(from.x + from.y + mAntPhase, kAntDashPixels * 2); + if (phase < kAntDashPixels) painter.line(a, b, light, 1.0f); + } + } + if (mStroke == Stroke::Selecting) { + if (mTool == CanvasTool::Lasso) { + painter.polyline(mLassoPath, kContourOuter, 3.0f); + painter.polyline(mLassoPath, kContourInner, 1.0f); + } else { + const RectF area = cropRect(); + if (mSettings.selection.marquee == MarqueeShape::Ellipse) { + std::vector points; + for (int4 step = 0; step <= 48; step++) { + const halnf angle = 2.0f * 3.14159265f * (halnf) step / 48.0f; + points.push_back( + area.pos + area.size / 2.0f + + Vec2F{ std::cos(angle) * area.size.x / 2.0f, std::sin(angle) * area.size.y / 2.0f } + ); + } + painter.polyline(points, kContourOuter, 3.0f); + painter.polyline(points, kContourInner, 1.0f); + } else { + painter.outline(area, kContourOuter, 0, 3.0f); + painter.outline(area, kContourInner, 0, 1.0f); + } + } + } + if (!mPolygon.empty()) { + std::vector points = mPolygon; + points.push_back(mPickLocal); + painter.polyline(points, kContourOuter, 3.0f); + painter.polyline(points, kContourInner, 1.0f); + painter.circleOutline(mPolygon.front(), kPolygonCloseRadius, kContourInner, 1.0f); + } + } + + RectF CanvasWidget::cropRect() const { + const Vec2F low = { std::min(mToolOrigin.x, mPickLocal.x), std::min(mToolOrigin.y, mPickLocal.y) }; + const Vec2F high = { std::max(mToolOrigin.x, mPickLocal.x), std::max(mToolOrigin.y, mPickLocal.y) }; + return ui::makeRect(low, high - low); + } + + void CanvasWidget::paintTransform(tp::Canvas& painter) const { + if (mStroke == Stroke::Cropping) { + const RectF area = cropRect(); + const RGBA shade = { 0, 0, 0, 0.45f }; + painter.rect(ui::makeRect(0, 0, size().x, area.pos.y), shade); + painter.rect(ui::makeRect(0, area.pos.y + area.size.y, size().x, size().y - area.pos.y - area.size.y), shade); + painter.rect(ui::makeRect(0, area.pos.y, area.pos.x, area.size.y), shade); + painter.rect( + ui::makeRect(area.pos.x + area.size.x, area.pos.y, size().x - area.pos.x - area.size.x, area.size.y), shade + ); + painter.outline(area, kContourInner, 0, 1.5f); + } + if (!isTransforming()) return; + const std::array corners = transformCorners(); + std::vector outline(corners.begin(), corners.end()); + outline.push_back(corners[0]); + painter.polyline(outline, kContourOuter, 3.0f); + painter.polyline(outline, kContourInner, 1.0f); + for (const Vec2F& corner : corners) { + painter.circle(corner, 7, kContourOuter); + painter.circle(corner, 5, kContourInner); + } + } + + void CanvasWidget::finishGradient(Vec2F local) { + if ((local - mToolOrigin).length() < kToolClickPixels) return; + RGBA start = mBrush.color; + start.a = mBrush.opacity(); + RGBA end = gradientEndColor ? gradientEndColor() : RGBA(1, 1, 1, 1); + end.a = mBrush.opacity(); + const GradientResult result = mDocument.drawGradient( + mDocument.screenToWorld(mToolOrigin), mDocument.screenToWorld(local), start, end, mSettings.gradient + ); + if (result == GradientResult::Drawn) mDocument.commit("Gradient"); + if (onGradient) onGradient(result); + } + + bool CanvasWidget::wantsPicker() const { return mTool == CanvasTool::Picker || (heldPicker && heldPicker()); } + + RGBA CanvasWidget::sampleAt(Vec2F local) const { + RGBA color = mDocument.sampleColor(mDocument.screenToWorld(local), mSettings.picker.sampleAllLayers); + if (!mSettings.picker.sampleOpacity) color.a = color.a > 0 ? 1.0f : 0.0f; + return color; + } + + void CanvasWidget::pickAt(Vec2F local) { + mPickLocal = local; + const RGBA color = sampleAt(local); + if (color.a <= 0) { + requestPaint(); + return; + } + mPicked = color; + if (onColorPicked) onColorPicked(color); + requestPaint(); + } + + void CanvasWidget::paintPicker(tp::Canvas& painter) const { + if (mStroke == Stroke::Gradient) { + painter.line(mToolOrigin, mPickLocal, kContourOuter, 3.0f); + painter.line(mToolOrigin, mPickLocal, kContourInner, 1.0f); + if (mSettings.gradient.shape == GradientShape::Radial) { + const halnf radius = (mPickLocal - mToolOrigin).length(); + painter.circleOutline(mToolOrigin, radius, kContourOuter, 3.0f); + painter.circleOutline(mToolOrigin, radius, kContourInner, 1.0f); + } + painter.circle(mToolOrigin, 4, kContourInner); + painter.circle(mPickLocal, 4, kContourInner); + return; + } + if (mStroke != Stroke::Picking) return; + const Vec2F at = mPickLocal; + const RGBA now = { mPicked.r, mPicked.g, mPicked.b, 1 }; + const RGBA before = { mPickStart.r, mPickStart.g, mPickStart.b, 1 }; + switch (mSettings.picker.style) { + case PickerStyle::Cursor: break; + case PickerStyle::Ring: + { + constexpr halnf kRadius = 56; + constexpr halnf kWidth = 18; + painter.circleOutline(at, kRadius, RGBA(0, 0, 0, 0.5f), kWidth + 4); + std::vector top; + std::vector bottom; + for (int4 step = 0; step <= 24; step++) { + const halnf angle = 3.14159265f * (halnf) step / 24.0f; + top.push_back(at + Vec2F{ -std::cos(angle) * kRadius, -std::sin(angle) * kRadius }); + bottom.push_back(at + Vec2F{ std::cos(angle) * kRadius, std::sin(angle) * kRadius }); + } + painter.polyline(top, now, kWidth); + painter.polyline(bottom, before, kWidth); + break; + } + case PickerStyle::Balloon: + { + const Vec2F center = at + Vec2F{ 0, -70 }; + painter.polygon({ at, center + Vec2F{ -12, 22 }, center + Vec2F{ 12, 22 } }, RGBA(0, 0, 0, 0.5f)); + painter.circle(center, 34, RGBA(0, 0, 0, 0.5f)); + painter.circle(center, 31, before); + painter.circle(center, 22, now); + break; + } + case PickerStyle::Magnifier: + { + const Vec2F center = at + Vec2F{ 0, -90 }; + constexpr halnf kCell = 8; + constexpr int4 kSpan = 4; + const halnf half = kCell * (kSpan + 0.5f); + painter.rect( + ui::makeRect(center - Vec2F{ half + 3, half + 3 }, Vec2F{ (half + 3) * 2, (half + 3) * 2 }), + RGBA(0, 0, 0, 0.6f), + 6 + ); + for (int4 y = -kSpan; y <= kSpan; y++) { + for (int4 x = -kSpan; x <= kSpan; x++) { + const RGBA sample = sampleAt(at + Vec2F{ (halnf) x, (halnf) y }); + const RGBA shown = sample.a > 0 ? RGBA(sample.r, sample.g, sample.b, 1) : RGBA(0.5f, 0.5f, 0.5f, 1); + painter.rect( + ui::makeRect( + center + Vec2F{ (halnf) x * kCell - kCell / 2, (halnf) y * kCell - kCell / 2 }, Vec2F{ kCell, kCell } + ), + shown + ); + } + } + painter.outline( + ui::makeRect(center - Vec2F{ kCell / 2, kCell / 2 }, Vec2F{ kCell, kCell }), RGBA(1, 1, 1, 1), 0, 1.5f + ); + painter.rect(ui::makeRect(center + Vec2F{ -half, half + 6.0f }, Vec2F{ half * 2.0f, 14.0f }), now, 3); + break; + } + } + } + + void CanvasWidget::paintSegment(Vec2F fromWorld, Vec2F toWorld, halnf fromPressure, halnf toPressure) { + const Vec2F delta = toWorld - fromWorld; + const halnf length = (halnf) delta.length(); + if (length <= 0.0f) return; + + halnf position = 0; + halnf sinceDab = mDabCarry; + while (true) { + const halnf pressure = fromPressure + (toPressure - fromPressure) * (position / length); + const halnf needed = mBrush.dabDistance(pressure) - sinceDab; + if (position + needed > length) { + mDabCarry = sinceDab + (length - position); + return; + } + position += needed > 0.0f ? needed : 0.0f; + sinceDab = 0; + const halnf at = position / length; + stamp(fromWorld + delta * at, fromPressure + (toPressure - fromPressure) * at); + } + } +} diff --git a/Sketch2D/src/libs/ui/private/CommandRegistry.cpp b/Sketch2D/src/libs/ui/private/CommandRegistry.cpp new file mode 100644 index 00000000..30c34fe2 --- /dev/null +++ b/Sketch2D/src/libs/ui/private/CommandRegistry.cpp @@ -0,0 +1,341 @@ +#include "CommandRegistry.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace tp::sketch2d { + + const CommandInfo& commandInfo(Command command) { + static const std::map infos = { + { Command::NewInfiniteCanvas, { "Infinite canvas", "" } }, + { Command::NewCustomSize, { "Custom size", "" } }, + { Command::NewFromClipboard, { "Clipboard", "" } }, + { Command::Open, { "Open", "" } }, + { Command::Save, { "Save", "" } }, + { Command::SaveAs, { "Save as", "" } }, + { Command::Import, { "Import", "Import" } }, + { Command::Export, { "Export", "" } }, + { Command::Close, { "Close", "" } }, + { Command::Cut, { "Cut", "Clear pixels" } }, + { Command::Copy, { "Copy", "" } }, + { Command::Paste, { "Paste", "Paste image" } }, + { Command::Transform, { "Transform", "Transform" } }, + { Command::Clear, { "Clear", "Clear pixels" } }, + { Command::Crop, { "Crop", "Crop pixels" } }, + { Command::FlipHorizontal, { "Flip horizontal", "Transform" } }, + { Command::FlipVertical, { "Flip vertical", "Transform" } }, + { Command::Invert, { "Invert", "Invert" } }, + { Command::Desaturate, { "Desaturate", "Desaturate" } }, + { Command::ValueToAlpha, { "Copy value to alpha", "Value to alpha" } }, + { Command::ValueToAlphaInverted, { "Copy value to alpha inverted", "Value to alpha inverted" } }, + { Command::LockView, { "Lock view", "" } }, + { Command::FlipViewHorizontal, { "Flip horizontal", "" } }, + { Command::FlipViewVertical, { "Flip vertical", "" } }, + { Command::ResetZoom, { "Zoom to 100%", "" } }, + { Command::ZoomIn, { "Zoom in", "" } }, + { Command::ZoomOut, { "Zoom out", "" } }, + { Command::ResetRotation, { "Reset rotation", "" } }, + { Command::RotateLeft, { "Rotate left", "" } }, + { Command::RotateRight, { "Rotate right", "" } }, + { Command::FitOnScreen, { "Fit on screen", "" } }, + { Command::ShowChannel, { "Show channel", "" } }, + { Command::ShowGrid, { "Show grid", "Show grid" } }, + { Command::LockGuides, { "Lock guides", "Lock guides" } }, + { Command::DeleteAllGuides, { "Delete all guides", "Delete all guides" } }, + { Command::FullScreen, { "Full screen", "" } }, + { Command::SelectAllAsRectangle, { "Select all as rectangle", "Select all as rectangle" } }, + { Command::SelectAllAsLasso, { "Select all as lasso", "Select all as lasso" } }, + { Command::SelectCanvasFrame, { "Select canvas frame", "Select all canvas" } }, + { Command::Deselect, { "Deselect", "Deselect" } }, + { Command::InvertSelection, { "Invert selection", "Invert selection" } }, + { Command::CanvasBackground, { "Canvas background", "" } }, + { Command::CanvasAppearance, { "Canvas appearance", "" } }, + { Command::CanvasUnitAndResolution, { "Canvas unit & resolution", "" } }, + { Command::InfiniteCanvas, { "Infinite canvas", "Infinite canvas" } }, + { Command::ClearCanvas, { "Clear canvas", "Clear canvas" } }, + { Command::NewLayer, { "New layer above current", "Create layer" } }, + { Command::MoveLayerUp, { "Ascend", "Move layer up/down" } }, + { Command::MoveLayerDown, { "Descend", "Move layer up/down" } }, + { Command::RenameLayer, { "Rename", "Rename layer" } }, + { Command::SelectLayerAbove, { "Select layer above current", "Select layer" } }, + { Command::SelectLayerBelow, { "Select layer below current", "Select layer" } }, + { Command::SelectAllLayers, { "Select all layers", "Select layer" } }, + { Command::DuplicateLayer, { "Duplicate", "Duplicate layer" } }, + { Command::DeleteLayer, { "Delete", "Delete layer" } }, + { Command::ClearLayer, { "Clear", "Clear layer" } }, + { Command::MergeLayers, { "Merge", "Merge layers" } }, + { Command::MergeAllLayers, { "Merge all layers", "Merge all layers" } }, + { Command::ToggleLayerVisible, { "Visible", "Show/hide layer" } }, + { Command::ToggleLayerClip, { "Clipping mask", "Lock/unlock layer" } }, + { Command::ToggleLayerLock, { "Locked", "Lock/unlock layer" } }, + { Command::ToggleLayerAlphaLock, { "Locked alpha", "Lock/unlock layer" } }, + { Command::BakeLayerOpacity, { "Bake opacity", "Bake layer opacity" } }, + { Command::PreviousLayerBlend, { "Previous blend mode", "Layer blend mode" } }, + { Command::NextLayerBlend, { "Next blend mode", "Layer blend mode" } }, + { Command::NormalLayerBlend, { "Normal", "Layer blend mode" } }, + { Command::LayerOpacityDecrease, { "Opacity decrease", "Layer opacity" } }, + { Command::LayerOpacityIncrease, { "Opacity increase", "Layer opacity" } }, + { Command::LayerOpacity0, { "Opacity 0%", "Layer opacity" } }, + { Command::LayerOpacity10, { "Opacity 10%", "Layer opacity" } }, + { Command::LayerOpacity20, { "Opacity 20%", "Layer opacity" } }, + { Command::LayerOpacity30, { "Opacity 30%", "Layer opacity" } }, + { Command::LayerOpacity40, { "Opacity 40%", "Layer opacity" } }, + { Command::LayerOpacity50, { "Opacity 50%", "Layer opacity" } }, + { Command::LayerOpacity60, { "Opacity 60%", "Layer opacity" } }, + { Command::LayerOpacity70, { "Opacity 70%", "Layer opacity" } }, + { Command::LayerOpacity80, { "Opacity 80%", "Layer opacity" } }, + { Command::LayerOpacity90, { "Opacity 90%", "Layer opacity" } }, + { Command::LayerOpacity100, { "Opacity 100%", "Layer opacity" } }, + { Command::Undo, { "Undo", "" } }, + { Command::Redo, { "Redo", "" } }, + { Command::ClearHistory, { "Clear history", "" } }, + { Command::SetHistoryLimit, { "Set history limit", "" } }, + { Command::ShowHidePanels, { "Show/hide panels", "" } }, + { Command::CloseAllPanels, { "Close all panels", "" } }, + { Command::ToggleConsole, { "Show Console", "" } }, + { Command::ToggleConsoleFaint, { "Show Console", "" } }, + { Command::ToggleConsoleOpaque, { "Show Console", "" } }, + { Command::ShowToolPanel, { "Show Tool", "" } }, + { Command::ShowLinePanel, { "Show Line", "" } }, + { Command::ShowBrushListPanel, { "Show Brush list", "" } }, + { Command::ShowBrushOptionsPanel, { "Show Brush options", "" } }, + { Command::ShowColorPanel, { "Show Color", "" } }, + { Command::ShowLayersPanel, { "Show Layers", "" } }, + { Command::ShowRulers, { "Show rulers", "" } }, + { Command::ResetAllBrushes, { "Reset all brushes", "" } }, + { Command::ToolPaint, { "Paint tool", "" } }, + { Command::ToolPixel, { "Pixel tool", "" } }, + { Command::ToolEraser, { "Eraser tool", "" } }, + { Command::ToolSmudge, { "Smudge tool", "" } }, + { Command::ToolBucket, { "Paint bucket tool", "" } }, + { Command::ToolPicker, { "Color picker tool", "" } }, + { Command::ToolRuler, { "Ruler tool", "" } }, + { Command::ToolMove, { "Move tool", "" } }, + { Command::ToolMagicWand, { "Magic wand tool", "" } }, + { Command::ToolLasso, { "Lasso selection tool", "" } }, + { Command::ToolRectangleSelect, { "Rectangle selection tool", "" } }, + { Command::ToolGradient, { "Gradient tool", "" } }, + { Command::ToolSlice, { "Slice tool", "" } }, + { Command::ToolZoom, { "Zoom tool", "" } }, + { Command::ToolRotate, { "Rotate tool", "" } }, + { Command::ToolHand, { "Hand tool", "" } }, + { Command::ToolExport, { "Export tool", "" } }, + { Command::ToolExpose, { "Expose tool", "" } }, + { Command::ToolBackground, { "Background tool", "" } }, + { Command::CanvasFrameSize, { "Canvas frame size", "" } }, + { Command::AdjustValue, { "Brightness & exposure", "Adjust value" } }, + { Command::AdjustColorBalance, { "Color balance", "Color balance" } }, + { Command::AdjustHueSaturation, { "Hue & saturation", "Hue saturation" } }, + { Command::AdjustColorize, { "Colorize", "Colorize" } }, + { Command::AdjustAlpha, { "Alpha", "Alpha" } }, + { Command::FreeTransform, { "Free Transform", "" } }, + { Command::HoldHand, { "Hand tool", "" } }, + { Command::HoldZoom, { "Zoom tool", "" } }, + { Command::HoldBrushSize, { "Brush size tool", "" } }, + { Command::HoldBrushAngle, { "Brush angle tool", "" } }, + { Command::HoldBrushFlow, { "Brush flow tool", "" } }, + { Command::HoldBrushOpacity, { "Brush opacity tool", "" } }, + { Command::ToolBrushAngle, { "Brush angle", "" } }, + { Command::ToolBrushSize, { "Brush size", "" } }, + { Command::SetBrush, { "Set brush", "" } }, + { Command::ResetBrush, { "Reset brush", "" } }, + { Command::BrushFlowDecrease, { "Flow decrease", "" } }, + { Command::BrushFlowIncrease, { "Flow increase", "" } }, + { Command::BrushOpacityDecrease, { "Opacity decrease", "" } }, + { Command::BrushOpacityIncrease, { "Opacity increase", "" } }, + { Command::BrushAngleDecrease, { "Angle decrease", "" } }, + { Command::BrushAngleIncrease, { "Angle increase", "" } }, + { Command::LineNormal, { "Line", "" } }, + { Command::LineSmooth, { "Smooth", "" } }, + { Command::LineRuler, { "Ruler", "" } }, + { Command::LineEllipse, { "Ellipse", "" } }, + { Command::LinePerspective, { "Perspective", "" } }, + { Command::LineSymmetry, { "Symmetry", "" } }, + { Command::LineIsometric, { "Isometric", "" } }, + { Command::SmallerBrush, { "Smaller brush", "" } }, + { Command::BiggerBrush, { "Bigger brush", "" } }, + { Command::StabilizerDecrease, { "Stabilizer decrease", "" } }, + { Command::StabilizerIncrease, { "Stabilizer increase", "" } }, + { Command::RecordSession, { "Record session", "" } }, + { Command::AddSwatch, { "Add color to swatches", "" } }, + { Command::ClearColorHistory, { "Clear color history", "" } }, + { Command::SpreadSelection, { "Spread selection", "Spread selection" } }, + { Command::BlurSelection, { "Blur selection", "Blur selection" } }, + { Command::FillSelection, { "Fill color", "Fill" } }, + { Command::FillUnderneath, { "Fill to new layer underneath", "Fill" } }, + { Command::CopyToNewLayer, { "Copy to new layer", "Copy to new layer" } }, + { Command::ShowSymmetryLine, { "Show symmetry line", "" } }, + { Command::MoveSymmetryLine, { "Move symmetry line", "Move symmetry line" } }, + { Command::LockSymmetryLine, { "Lock symmetry line", "Lock symmetry line" } }, + { Command::ResetSymmetryLine, { "Reset symmetry line", "Reset symmetry line" } }, + { Command::MovePerspectivePoint, { "Move perspective point", "Move perspective point" } }, + { Command::LockPerspective, { "Lock perspective", "Lock perspective" } }, + { Command::ResetPerspective, { "Reset vanishing points", "Create perspective" } }, + { Command::StopSessionRecording, { "Stop recording and save", "" } }, + { Command::BrushOpacity10, { "Brush opacity 10%", "" } }, + { Command::BrushOpacity20, { "Brush opacity 20%", "" } }, + { Command::BrushOpacity30, { "Brush opacity 30%", "" } }, + { Command::BrushOpacity40, { "Brush opacity 40%", "" } }, + { Command::BrushOpacity50, { "Brush opacity 50%", "" } }, + { Command::BrushOpacity60, { "Brush opacity 60%", "" } }, + { Command::BrushOpacity70, { "Brush opacity 70%", "" } }, + { Command::BrushOpacity80, { "Brush opacity 80%", "" } }, + { Command::BrushOpacity90, { "Brush opacity 90%", "" } }, + { Command::BrushOpacity100, { "Brush opacity 100%", "" } }, + { Command::DefaultColors, { "Default colors", "" } }, + { Command::SwapColors, { "Swap colors", "" } }, + { Command::CopyColor, { "Copy", "" } }, + { Command::PasteColor, { "Paste", "" } }, + { Command::Cooler, { "Cooler", "" } }, + { Command::Warmer, { "Warmer", "" } }, + { Command::HueComplement, { "Hue compliment", "" } }, + { Command::HueRotateLeft, { "Hue rotate left", "" } }, + { Command::HueRotateRight, { "Hue rotate right", "" } }, + { Command::Grayer, { "Grayer", "" } }, + { Command::Purer, { "Purer", "" } }, + { Command::Darker, { "Darker", "" } }, + { Command::Brighter, { "Brighter", "" } }, + { Command::Lightness0, { "Lightness 0%", "" } }, + { Command::Lightness10, { "Lightness 10%", "" } }, + { Command::Lightness20, { "Lightness 20%", "" } }, + { Command::Lightness30, { "Lightness 30%", "" } }, + { Command::Lightness40, { "Lightness 40%", "" } }, + { Command::Lightness50, { "Lightness 50%", "" } }, + { Command::Lightness60, { "Lightness 60%", "" } }, + { Command::Lightness70, { "Lightness 70%", "" } }, + { Command::Lightness80, { "Lightness 80%", "" } }, + { Command::Lightness90, { "Lightness 90%", "" } }, + { Command::Lightness100, { "Lightness 100%", "" } }, + }; + static const CommandInfo unknown; + + const auto found = infos.find(command); + return found == infos.end() ? unknown : found->second; + } + + void bindDefaultShortcuts(ShortcutMap& shortcuts) { + shortcuts.bind(ctrl(Key::N), Command::NewInfiniteCanvas); + shortcuts.bind(ctrl(Key::O), Command::Open); + shortcuts.bind(ctrl(Key::S), Command::Save); + shortcuts.bind(ctrlShift(Key::S), Command::SaveAs); + shortcuts.bind(ctrl(Key::X), Command::Cut); + shortcuts.bind(ctrl(Key::C), Command::Copy); + shortcuts.bind(ctrl(Key::V), Command::Paste); + shortcuts.bind(ctrl(Key::F), Command::FlipHorizontal); + shortcuts.bind(ctrlShift(Key::F), Command::FlipVertical); + shortcuts.bind(plain(Key::Insert), Command::Transform); + shortcuts.bind(plain(Key::Delete), Command::Clear); + shortcuts.bind(ctrl(Key::Delete), Command::Crop); + shortcuts.bind(plain(Key::F), Command::FlipViewHorizontal); + shortcuts.bind(shift(Key::F), Command::FlipViewVertical); + shortcuts.bind(ctrl(Key::G), Command::ShowGrid); + shortcuts.bind(ctrl(Key::R), Command::ShowRulers); + shortcuts.bind(ctrl(Key::P), Command::ShowChannel); + shortcuts.bind(ctrl(Key::Minus), Command::ZoomOut); + shortcuts.bind(ctrl(Key::Equal), Command::ZoomIn); + shortcuts.bind(alt(Key::Minus), Command::RotateLeft); + shortcuts.bind(alt(Key::Equal), Command::RotateRight); + shortcuts.bind(ctrlShift(Key::R), Command::ResetRotation); + shortcuts.bind(ctrl(Key::KeypadSubtract), Command::ZoomOut); + shortcuts.bind(ctrl(Key::KeypadAdd), Command::ZoomIn); + shortcuts.bind(ctrl(Key::Digit0), Command::FitOnScreen); + shortcuts.bind(ctrl(Key::Keypad0), Command::FitOnScreen); + shortcuts.bind(ctrlShift(Key::Digit0), Command::ResetZoom); + shortcuts.bind(ctrlShift(Key::Keypad0), Command::ResetZoom); + shortcuts.bind(plain(Key::F11), Command::FullScreen); + shortcuts.bind(ctrlShift(Key::N), Command::NewLayer); + shortcuts.bind(ctrl(Key::E), Command::MergeLayers); + shortcuts.bind(ctrl(Key::K), Command::ClearLayer); + shortcuts.bind(ctrl(Key::J), Command::DuplicateLayer); + shortcuts.bind(ctrlShift(Key::Tab), Command::SelectLayerAbove); + shortcuts.bind(ctrl(Key::Tab), Command::SelectLayerBelow); + shortcuts.bind(ctrlAlt(Key::A), Command::SelectAllLayers); + shortcuts.bind(ctrl(Key::A), Command::SelectAllAsRectangle); + shortcuts.bind(ctrlShift(Key::A), Command::SelectAllAsLasso); + shortcuts.bind(ctrl(Key::D), Command::Deselect); + shortcuts.bind(ctrl(Key::I), Command::InvertSelection); + shortcuts.bind(ctrl(Key::Z), Command::Undo, true); + shortcuts.bind(ctrl(Key::Y), Command::Undo, true); + shortcuts.bind(ctrlShift(Key::Z), Command::Redo, true); + shortcuts.bind(ctrlShift(Key::Y), Command::Redo, true); + shortcuts.bind(ctrl(Key::GraveAccent), Command::ToggleConsole); + shortcuts.bind(ctrlShift(Key::GraveAccent), Command::ToggleConsoleFaint); + shortcuts.bind(ctrlAlt(Key::GraveAccent), Command::ToggleConsoleOpaque); + shortcuts.bind(plain(Key::F2), Command::ShowToolPanel); + shortcuts.bind(plain(Key::F3), Command::ShowLinePanel); + shortcuts.bind(plain(Key::F4), Command::ShowBrushListPanel); + shortcuts.bind(plain(Key::F5), Command::ShowBrushOptionsPanel); + shortcuts.bind(plain(Key::F6), Command::ShowColorPanel); + shortcuts.bind(plain(Key::F7), Command::ShowLayersPanel); + shortcuts.bind(plain(Key::P), Command::ToolPixel); + shortcuts.bind(plain(Key::B), Command::ToolPaint); + shortcuts.bind(plain(Key::E), Command::ToolEraser); + shortcuts.bind(plain(Key::S), Command::ToolSmudge); + shortcuts.bind(plain(Key::U), Command::ToolBucket); + shortcuts.bind(plain(Key::I), Command::ToolPicker); + shortcuts.bind(plain(Key::R), Command::ToolRuler); + shortcuts.bind(plain(Key::V), Command::ToolMove); + shortcuts.bind(plain(Key::W), Command::ToolMagicWand); + shortcuts.bind(plain(Key::L), Command::ToolLasso); + shortcuts.bind(plain(Key::K), Command::ToolRectangleSelect); + shortcuts.bind(plain(Key::G), Command::ToolGradient); + shortcuts.bind(plain(Key::O), Command::ToolRotate); + shortcuts.bind(plain(Key::H), Command::ToolHand); + shortcuts.bind(plain(Key::X), Command::ToolExpose); + shortcuts.bind(ctrl(Key::T), Command::FreeTransform); + shortcuts.bind(ctrl(Key::L), Command::AdjustValue); + shortcuts.bind(ctrl(Key::B), Command::AdjustColorBalance); + shortcuts.bind(ctrl(Key::U), Command::AdjustHueSaturation); + shortcuts.bind(plain(Key::Space), Command::HoldHand); + shortcuts.bind(alt(Key::Space), Command::HoldHand); + shortcuts.bind(ctrl(Key::Space), Command::HoldHand); + shortcuts.bind(shift(Key::Space), Command::HoldHand); + shortcuts.bind(plain(Key::Z), Command::HoldZoom); + shortcuts.bind(plain(Key::D), Command::HoldBrushSize); + shortcuts.bind(plain(Key::A), Command::HoldBrushAngle); + shortcuts.bind(ctrl(Key::Digit1), Command::LineNormal); + shortcuts.bind(ctrl(Key::Digit2), Command::LineSmooth); + shortcuts.bind(ctrl(Key::Digit3), Command::LineRuler); + shortcuts.bind(ctrl(Key::Digit4), Command::LineEllipse); + shortcuts.bind(ctrl(Key::Digit5), Command::LinePerspective); + shortcuts.bind(ctrl(Key::Digit6), Command::LineSymmetry); + shortcuts.bind(ctrl(Key::Digit7), Command::LineIsometric); + shortcuts.bind(plain(Key::LeftBracket), Command::SmallerBrush, true); + shortcuts.bind(plain(Key::RightBracket), Command::BiggerBrush, true); + shortcuts.bind(plain(Key::Digit1), Command::BrushOpacity10); + shortcuts.bind(plain(Key::Digit2), Command::BrushOpacity20); + shortcuts.bind(plain(Key::Digit3), Command::BrushOpacity30); + shortcuts.bind(plain(Key::Digit4), Command::BrushOpacity40); + shortcuts.bind(plain(Key::Digit5), Command::BrushOpacity50); + shortcuts.bind(plain(Key::Digit6), Command::BrushOpacity60); + shortcuts.bind(plain(Key::Digit7), Command::BrushOpacity70); + shortcuts.bind(plain(Key::Digit8), Command::BrushOpacity80); + shortcuts.bind(plain(Key::Digit9), Command::BrushOpacity90); + shortcuts.bind(plain(Key::Digit0), Command::BrushOpacity100); + shortcuts.bind(alt(Key::Q), Command::HueRotateLeft); + shortcuts.bind(alt(Key::W), Command::HueRotateRight); + shortcuts.bind(alt(Key::A), Command::Grayer); + shortcuts.bind(alt(Key::S), Command::Purer); + shortcuts.bind(alt(Key::Z), Command::Darker); + shortcuts.bind(alt(Key::X), Command::Brighter); + shortcuts.bind(alt(Key::GraveAccent), Command::Lightness0); + shortcuts.bind(alt(Key::Digit1), Command::Lightness10); + shortcuts.bind(alt(Key::Digit2), Command::Lightness20); + shortcuts.bind(alt(Key::Digit3), Command::Lightness30); + shortcuts.bind(alt(Key::Digit4), Command::Lightness40); + shortcuts.bind(alt(Key::Digit5), Command::Lightness50); + shortcuts.bind(alt(Key::Digit6), Command::Lightness60); + shortcuts.bind(alt(Key::Digit7), Command::Lightness70); + shortcuts.bind(alt(Key::Digit8), Command::Lightness80); + shortcuts.bind(alt(Key::Digit9), Command::Lightness90); + shortcuts.bind(alt(Key::Digit0), Command::Lightness100); + } + + CommandRegistry::CommandRegistry() { bindDefaultShortcuts(mCommands.shortcuts()); } + + void CommandRegistry::define(Command command, Action action, Predicate enabled, Predicate checked) { + mCommands.define(command, { commandInfo(command).label, std::move(action), std::move(enabled), std::move(checked) }); + } + + bool CommandRegistry::run(Command command) { return mCommands.run(command); } +} diff --git a/Sketch2D/src/libs/ui/private/LibraryView.cpp b/Sketch2D/src/libs/ui/private/LibraryView.cpp new file mode 100644 index 00000000..3cbb8e8d --- /dev/null +++ b/Sketch2D/src/libs/ui/private/LibraryView.cpp @@ -0,0 +1,779 @@ +#include "LibraryView.hpp" + +#include "BoxLayout.hpp" +#include "Canvas.hpp" +#include "Ui.hpp" + +#include +#include + +namespace tp::sketch2d { + + using namespace tp::ui; + + namespace { + constexpr halnf kRowPadding = 10; + + std::string modifiedText(int8 seconds) { + if (seconds <= 0) return {}; + const std::time_t time = (std::time_t) seconds; + char text[64]; + std::strftime(text, sizeof(text), "%Y-%m-%d %H:%M", std::localtime(&time)); + return text; + } + + std::string subtitleOf(const LibraryItem& item, bool showFolder) { + if (item.kind == LibraryItem::Kind::Folder) return "Folder"; + const std::string modified = modifiedText(item.modifiedSeconds); + const std::string folder = SketchLibrary::parentOf(item.path); + return showFolder && !folder.empty() ? folder + " · " + modified : modified; + } + } + + SketchRow::SketchRow(LibraryItem item, ImageHandle preview) : + mItem(std::move(item)), + mPreview(preview) { + setDebugName("sketch row: " + mItem.path); + setSizing(AxisSizing::fill(), AxisSizing::fit()); + } + + void SketchRow::setCurrent(bool current) { + mCurrent = current; + requestPaint(); + } + + void SketchRow::setSelected(bool selected) { + mSelected = selected; + requestPaint(); + } + + Extent SketchRow::measureContent(Axis axis, halnf) const { + if (axis == Axis::Vertical) return Extent::rigid(kPreviewSize + 2 * kRowPadding); + return Extent::natural(kPreviewSize + 2 * kRowPadding + textWidth(mItem.name, theme().metrics.fontSize)); + } + + void SketchRow::paintFolderIcon(Canvas& painter, const RectF& box) const { + const RGBA color = theme().palette.mutedText; + const halnf unit = box.size.x / 8; + const RectF body = makeRect(box.pos.x + unit, box.pos.y + unit * 2.5f, unit * 6, unit * 4.2f); + const RectF tab = makeRect(box.pos.x + unit, box.pos.y + unit * 1.8f, unit * 2.4f, unit * 1.2f); + painter.rect(tab, color, unit * 0.4f); + painter.rect(body, color, unit * 0.5f); + } + + void SketchRow::paint(Canvas& painter) { + const Theme& style = theme(); + painter.rect(localRect(), background(), style.metrics.cornerRadius); + if (mCurrent || mSelected) painter.outline(localRect(), style.palette.accent, style.metrics.cornerRadius, 2); + if (mSelected) { + const Vec2F mark = { size().x - kRowPadding - 14, size().y / 2 }; + painter.circle(mark, 12, style.palette.accent); + painter.polyline( + { mark + Vec2F(-5.0f, 0.0f), mark + Vec2F(-1.5f, 4.0f), mark + Vec2F(5.5f, -4.0f) }, + style.palette.onAccent, + 2.5f + ); + } + + const RectF preview = makeRect(kRowPadding, kRowPadding, kPreviewSize, kPreviewSize); + if (isFolder()) { + paintFolderIcon(painter, preview); + } else { + painter.rect(preview, style.palette.field, style.metrics.indicatorCornerRadius); + if (mPreview.isValid()) painter.drawImage(preview, mPreview, 0, 1, style.metrics.indicatorCornerRadius); + } + + const halnf textLeft = preview.pos.x + preview.size.x + kRowPadding * 1.5f; + const halnf width = size().x - textLeft - kRowPadding; + const halnf middle = size().y / 2; + const halnf line = style.metrics.lineHeight; + painter.text( + mItem.name, + makeRect(textLeft, middle - line, width, line), + TextStyle{ style.metrics.fontSize, style.palette.text, TextAlign::leftCenter() } + ); + painter.text( + subtitleOf(mItem, mShowsFolder), + makeRect(textLeft, middle, width, line), + TextStyle{ style.metrics.smallFontSize, style.palette.mutedText, TextAlign::leftCenter() } + ); + } + + MoreButton::MoreButton() { + setDebugName("more actions"); + setTooltip("More"); + setSizing(AxisSizing::fixed(48), AxisSizing::fixed(40)); + } + + void MoreButton::paintIcon(Canvas& painter, const RectF&) { + const Vec2F center = localRect().center(); + const RGBA color = theme().palette.text; + for (halnf offset : { -8.0f, 0.0f, 8.0f }) + painter.circle(center + Vec2F(offset, 0.0f), 2.5f, color); + } + + ModeBanner::ModeBanner() : + Panel(Surface::None) { + setDebugName("mode banner"); + setSizing(AxisSizing::fill(), AxisSizing::fit()); + RowLayout& row = emplaceLayout(10); + row.setCrossAlign(CrossAlign::Center); + row.setPadding(Insets::symmetric(14, 10)); + message.setSizing(AxisSizing::fill(), AxisSizing::fit()); + addChild(message); + addChild(cancel); + } + + void ModeBanner::paint(Canvas& painter) { + const Theme& style = theme(); + const RGBA accent = style.palette.accent; + painter.rect(localRect(), { accent.r, accent.g, accent.b, 0.18f }, style.metrics.cornerRadius); + painter.outline(localRect(), accent, style.metrics.cornerRadius, 2); + } + + LibraryList::LibraryList() : + list(6) { + setDebugName("library list"); + setSizing(AxisSizing::fill(), AxisSizing::fit()); + emplaceLayout(10); + list.setDragMode(ReorderableList::DragMode::LongPress); + empty.setTone(Label::Tone::Muted); + toolbar.setSizing(AxisSizing::fill(), AxisSizing::fit()); + toolbar.emplaceLayout(8).setCrossAlign(CrossAlign::Center); + path.setSizing(AxisSizing::fit(), AxisSizing::fit()); + path.emplaceLayout(6).setCrossAlign(CrossAlign::Center); + pathScroll.setSizing(AxisSizing::fill(), AxisSizing::fixed(40)); + pathScroll.setContent(&path); + toolbar.addChild(pathScroll); + addChild(toolbar); + addChild(empty); + addChild(list); + } + + SketchRow* LibraryList::row(const std::string& name) const { + for (const auto& candidate : rows) + if (candidate->item().name == name) return candidate.get(); + return nullptr; + } + + LibraryView::LibraryView(SketchLibrary& library, Document& document) : + mLibrary(library), + mDocument(document) { + setDebugName("sketch library"); + saveDialog.input().setMaximumLength(64); + folderDialog.input().setMaximumLength(64); + renameDialog.input().setMaximumLength(64); + + mTabs.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mTabs.emplaceLayout(8).setCrossAlign(CrossAlign::Center); + mTabButtons.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mTabButtons.emplaceLayout(6); + for (Button* tabButton : { &latestTab, &collectionsTab }) { + tabButton->setStyle(Button::Style::Tab); + tabButton->setSizing(AxisSizing::fill(), AxisSizing::fit()); + mTabButtons.addChild(*tabButton); + } + mTabs.addChild(mTabButtons); + clearScratch.setStyle(Button::Style::Flat); + clearScratch.setTooltip("Delete every sketch in the Scratch folder"); + clearScratch.setOnActivate([this]() { confirmClearScratch(); }); + mTabs.addChild(clearScratch); + actionsButton.setPopupContent(&actionsMenu); + actionsButton.popupOptions().placement = Placement::Below; + actionsButton.popupOptions().align = PlacementAlign::End; + actionsButton.setOnOpening([this]() { rebuildActions(); }); + actionsMenu.setOnItemActivated([this]() { actionsButton.closePopup(); }); + latestTab.setOnActivate([this]() { showTab(Tab::Latest); }); + collectionsTab.setOnActivate([this]() { showTab(Tab::Collections); }); + + mRootPage.setDebugName("library root"); + mRootPage.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mRootPage.emplaceLayout(12); + mRootPage.addChild(mTabs); + mRootPage.addChild(mLatest); + mRootPage.addChild(mCollections); + + mLatest.empty.setText("No sketches yet. Draw something - it is saved to Scratch automatically."); + mCollections.empty.setText("No folders yet."); + mLatest.toolbar.setVisible(false); + wire(mCollections); + wire(mLatest); + + confirmDialog.addButton( + "Delete", + [this]() { + const std::vector paths = std::move(mPendingRemovals); + mPendingRemovals.clear(); + mSelection.clear(); + removeAll(paths); + }, + true + ); + confirmDialog.addCancelButton(); + + setRootContent("Sketches", mRootPage); + setOnPageShown([this](SettingsPage& page) { + const bool leftPicker = isPickingDestination() && depth() <= mPickStartDepth; + if (leftPicker) mPickStartDepth = 0; + if (!isPickingDestination() && !leftPicker) mSelection.clear(); + while (!mFolders.empty() && mFolders.back().list.get() != ¤tList() && depth() <= mFolders.size()) + mFolders.pop_back(); + refreshList(currentList()); + placeActions(); + }); + } + + void LibraryView::wire(LibraryList& list) { + list.list.setOnHeld([this, &list](size_t index) { + if (!list.picksDestination) toggleSelected(list.rows[index]->item().path); + }); + list.list.setCanAcceptDrop([&list](size_t, size_t target) { return list.rows[target]->isFolder(); }); + list.list.setOnDroppedOnto([this, &list](size_t dragged, size_t target) { + moveInto(list.rows[dragged]->item().path, list.rows[target]->item().path); + }); + list.list.setOnMoved([this, &list](size_t from, size_t to) { + std::unique_ptr moved = std::move(list.rows[from]); + list.rows.erase(list.rows.begin() + (std::ptrdiff_t) from); + list.rows.insert(list.rows.begin() + (std::ptrdiff_t) to, std::move(moved)); + std::vector names; + for (const auto& row : list.rows) + names.push_back(row->item().name); + mLibrary.reorder(list.folder, names); + }); + } + + void LibraryView::open(Ui& ui) { + mLibrary.ensureScratch(); + mFolders.clear(); + mSelection.clear(); + mPickStartDepth = 0; + showTab(mTab); + SettingsView::open(ui); + placeActions(); + } + + void LibraryView::showTab(Tab tab) { + if (tab != mTab) mSelection.clear(); + mTab = tab; + latestTab.setSelected(tab == Tab::Latest); + collectionsTab.setSelected(tab == Tab::Collections); + mLatest.setVisible(tab == Tab::Latest); + mCollections.setVisible(tab == Tab::Collections); + refreshList(tab == Tab::Latest ? mLatest : mCollections); + if (isOpen() && depth() == 1) placeActions(); + } + + LibraryList& LibraryView::currentList() { + if (depth() > 1 && depth() - 1 <= mFolders.size()) return *mFolders[depth() - 2].list; + return mTab == Tab::Latest ? mLatest : mCollections; + } + + std::string LibraryView::currentFolder() const { + if (depth() > 1 && depth() - 1 <= mFolders.size()) return mFolders[depth() - 2].folder; + return SketchLibrary::kScratchFolder; + } + + void LibraryView::openFolder(const std::string& folder) { + mSelection.clear(); + FolderPage page{ folder, std::make_unique() }; + page.list->folder = folder; + page.list->empty.setText("This folder is empty."); + wire(*page.list); + LibraryList& list = *page.list; + mFolders.push_back(std::move(page)); + refreshList(list); + openContent(SketchLibrary::nameOf(folder), list); + placeActions(); + } + + void LibraryView::placeActions() { + actionsButton.closePopup(); + if (actionsButton.parent()) actionsButton.parent()->removeChild(actionsButton); + if (depth() > 1) currentList().toolbar.addChild(actionsButton); + else mTabs.addChild(actionsButton); + fillPath(currentList()); + } + + void LibraryView::fillPath(LibraryList& list) { + list.path.removeAllChildren(); + list.crumbs.clear(); + const auto addCrumb = [this, &list](const std::string& text, size_t targetDepth, bool current) { + auto crumb = std::make_unique