From dacb5f3a1cae25d511428af93ee50889b395cbd2 Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 31 Oct 2025 01:20:54 -0400 Subject: [PATCH 01/15] Create website README file to help people get started --- website/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 website/README.md diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000000..2f6db3fc71 --- /dev/null +++ b/website/README.md @@ -0,0 +1,10 @@ +# Graphite Website + +Graphite's website uses the [Zola](https://www.getzola.org/) static site generator and [NPM](https://github.com/npm/cli) for dependency resolution. You will need to install both to build the site. + +## Building for Development + +1. Switch to this directory from the repo root: `cd website` +2. Install the fonts: `npm run install-fonts` +3. Run `zola serve` +4. Open in your browser and start coding! From 61804f58b421ec44c916b77dd09d94e4662df1a1 Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 31 Oct 2025 01:33:03 -0400 Subject: [PATCH 02/15] Add better RSS feed template - Include full text of posts --- website/config.toml | 1 + website/templates/rss.xml | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 website/templates/rss.xml diff --git a/website/config.toml b/website/config.toml index 3234f68f98..4f063e14b9 100644 --- a/website/config.toml +++ b/website/config.toml @@ -5,6 +5,7 @@ feed_filenames = ["rss.xml"] compile_sass = true minify_html = false +generate_feeds = false [markdown] highlight_code = true diff --git a/website/templates/rss.xml b/website/templates/rss.xml new file mode 100644 index 0000000000..65e699dc3c --- /dev/null +++ b/website/templates/rss.xml @@ -0,0 +1,21 @@ + + + + {{ config.title }} + {{ config.base_url | safe }} + {{ config.description }} + {{ now() | date(format="%a, %d %b %Y %H:%M:%S %z") }} + + {% for page in section.pages %} + + {{ page.title }} + {{ page.permalink | safe }} + {{ page.permalink | safe }} + {{ page.date | date(format="%a, %d %b %Y %H:%M:%S %z") }} + + + {% endfor %} + + From d3938c514ae0cb1b38c4b18c0204174cafcb3e96 Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 31 Oct 2025 01:41:21 -0400 Subject: [PATCH 03/15] Switch to atom feed --- website/templates/rss.xml | 41 ++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/website/templates/rss.xml b/website/templates/rss.xml index 65e699dc3c..f926b7a14b 100644 --- a/website/templates/rss.xml +++ b/website/templates/rss.xml @@ -1,21 +1,34 @@ - - + {{ config.title }} - {{ config.base_url | safe }} - {{ config.description }} - {{ now() | date(format="%a, %d %b %Y %H:%M:%S %z") }} + + {{ now() | date(format="%Y-%m-%dT%H:%M:%S%:z") }} + {{ config.base_url | safe }} + {{ config.description }} {% for page in section.pages %} - + {{ page.title }} - {{ page.permalink | safe }} - {{ page.permalink | safe }} - {{ page.date | date(format="%a, %d %b %Y %H:%M:%S %z") }} - + {{ page.permalink | safe }} + {{ page.updated | default(value=page.date) | date(format="%Y-%m-%dT%H:%M:%S%:z") }} + {{ page.date | date(format="%Y-%m-%dT%H:%M:%S%:z") }} + + {% if page.extra.authors %} + {% for author in page.extra.authors %} + + {{ author }} + + {% endfor %} + {% endif %} + + {% if page.extra.summary %} + {{ page.extra.summary }} + {% endif %} + + - + ]]> + {% endfor %} - - + From f8d0831c4e7ff82bc180f3b89d916236b9eb0f3a Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 31 Oct 2025 02:01:29 -0400 Subject: [PATCH 04/15] refactor: use default Zola `authors` array Storing `authors` as an array lets us loop over multiple authors instead of adding them all as a string! Better for sorting! --- .../content/blog/2022-02-12-announcing-graphite-alpha.md | 2 +- ...phite-a-vision-for-the-future-of-2d-content-creation.md | 2 +- ...-05-12-distributed-computing-in-the-graphene-runtime.md | 2 +- .../2024-01-01-looking-back-on-2023-and-what's-next.md | 2 +- ...te-internships-announcing-participation-in-gsoc-2024.md | 3 ++- .../blog/2024-05-09-graphite-progress-report-q1-2024.md | 3 ++- .../blog/2024-07-31-graphite-progress-report-q2-2024.md | 3 ++- .../blog/2024-10-15-graphite-progress-report-q3-2024.md | 3 ++- ...16-year-in-review-2024-highlights-and-a-peek-at-2025.md | 2 +- .../blog/2025-03-31-graphite-progress-report-q4-2024.md | 3 ++- ...-02-internships-for-a-rust-graphics-engine-gsoc-2025.md | 3 ++- .../2025-09-19-graphite-community-meetup-in-germany.md | 3 ++- website/templates/article.html | 7 ++++++- website/templates/blog.html | 7 ++++++- website/templates/rss.xml | 4 ++-- 15 files changed, 33 insertions(+), 16 deletions(-) diff --git a/website/content/blog/2022-02-12-announcing-graphite-alpha.md b/website/content/blog/2022-02-12-announcing-graphite-alpha.md index 8395644ed7..64a709d40f 100644 --- a/website/content/blog/2022-02-12-announcing-graphite-alpha.md +++ b/website/content/blog/2022-02-12-announcing-graphite-alpha.md @@ -1,11 +1,11 @@ +++ title = "Announcing Graphite alpha" date = 2022-02-12 +authors = ["Keavon Chambers"] [extra] banner = "https://static.graphite.rs/content/blog/2022-02-12-announcing-graphite-alpha.avif" banner_png = "https://static.graphite.rs/content/blog/2022-02-12-announcing-graphite-alpha.png" -author = "Keavon Chambers" summary = "The Graphite open source team announces the alpha release of their next-generation graphics editor, a web-based SVG editor with vector-based tools. Future plans include a node-based procedural workflow, a raster graphics compositing engine, and a native desktop client." reddit = "https://www.reddit.com/r/graphite/comments/unw3hi/blog_post_announcing_graphite_alpha/" twitter = "https://twitter.com/GraphiteEditor/status/1524663930697568256" diff --git a/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md b/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md index c3d7d77261..bcf5e13201 100644 --- a/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md +++ b/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md @@ -1,11 +1,11 @@ +++ title = "Graphite: a vision for the future of 2D content creation" date = 2022-03-12 +authors = ["Keavon Chambers"] [extra] banner = "https://static.graphite.rs/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.avif" banner_png = "https://static.graphite.rs/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.png" -author = "Keavon Chambers" summary = "Graphite is an open-source application for 2D graphics editing and digital content creation, offering a nondestructive, node-based workflow. It combines intuitive UI with powerful procedural image generators to revolutionize 2D content creation." reddit = "https://www.reddit.com/r/graphite/comments/unw3va/blog_post_graphite_a_vision_for_the_future_of_2d/" twitter = "https://twitter.com/GraphiteEditor/status/1524664010091556864" diff --git a/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md b/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md index 5a96b2b7b1..490d5a7b01 100644 --- a/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md +++ b/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md @@ -1,11 +1,11 @@ +++ title = "Distributed computing in the Graphene runtime" date = 2022-05-12 +authors = ["Keavon Chambers"] [extra] banner = "https://static.graphite.rs/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime__2.avif" banner_png = "https://static.graphite.rs/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime__2.png" -author = "Keavon Chambers" summary = "Graphite's 2D editor is built upon Graphene, a node-based editing system for nondestructive design across various data types designed to render artwork faster using multiple machines. The system optimizes execution paths, minimizes latency, and uses a distributed runtime for quick data processing." reddit = "https://www.reddit.com/r/graphite/comments/unw45k/blog_post_distributed_computing_in_the_graphene/" twitter = "https://twitter.com/GraphiteEditor/status/1524664083554791424" diff --git a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md index c430a2fc75..2b8e761db0 100644 --- a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md +++ b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md @@ -1,11 +1,11 @@ +++ title = "Looking back on 2023 and what's next" date = 2024-01-01 +authors = ["Keavon Chambers"] [extra] banner = "https://static.graphite.rs/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.avif" banner_png = "https://static.graphite.rs/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.png" -author = "Keavon Chambers" summary = "Looking back on 2023, we reflect on our significant achievements and milestones. As we move forward, we're excited to share what's next, promising a year filled with innovation and progress." reddit = "https://www.reddit.com/r/graphite/comments/18xmoti/blog_post_looking_back_on_2023_and_whats_next/" twitter = "https://twitter.com/GraphiteEditor/status/1742576805532577937" diff --git a/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md b/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md index f9fe300a5c..991f1ce403 100644 --- a/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md +++ b/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md @@ -1,10 +1,11 @@ +++ title = "Graphite internships: announcing participation in GSoC 2024" date = 2024-02-22 +authors = ["Keavon Chambers"] + [extra] banner = "https://static.graphite.rs/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.avif" banner_png = "https://static.graphite.rs/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.png" -author = "Keavon Chambers" summary = "Join Graphite in Google Summer of Code 2024 for a unique opportunity to contribute to open-source software development in Rust and computer graphics. Get paid while learning, working on self-contained projects under experienced mentors, and help Graphite grow." reddit = "https://www.reddit.com/r/graphite/comments/1ax3l8z/blog_post_graphite_internships_announcing/" twitter = "https://twitter.com/GraphiteEditor/status/1760619083396165703" diff --git a/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md b/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md index bac4e44959..b8e9ba17eb 100644 --- a/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md +++ b/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md @@ -1,10 +1,11 @@ +++ title = "Graphite progress report (Q1 2024)" date = 2024-05-09 +authors = ["Keavon Chambers", "Hypercube"] + [extra] banner = "https://static.graphite.rs/content/blog/2024-05-09-graphite-progress-report-q1-2024__2.avif" banner_png = "https://static.graphite.rs/content/blog/2024-05-09-graphite-progress-report-q1-2024__2.png" -author = "Keavon Chambers & Hypercube" summary = "Graphite's Q1 2024 update introduces a precise snapping system and a customizable grid for enhanced design control. The update also includes improved procedural scattering with the 'Copy to Points' node, demonstrated in new demo artwork." reddit = "https://www.reddit.com/r/graphite/comments/1coa0if/blog_post_graphite_progress_report_q1_2024/" twitter = "https://twitter.com/GraphiteEditor/status/1788698448348266946" diff --git a/website/content/blog/2024-07-31-graphite-progress-report-q2-2024.md b/website/content/blog/2024-07-31-graphite-progress-report-q2-2024.md index 72b73f0db3..3f97138984 100644 --- a/website/content/blog/2024-07-31-graphite-progress-report-q2-2024.md +++ b/website/content/blog/2024-07-31-graphite-progress-report-q2-2024.md @@ -1,10 +1,11 @@ +++ title = "Graphite progress report (Q2 2024)" date = 2024-07-31 +authors = ["Keavon Chambers", "Hypercube"] + [extra] banner = "https://static.graphite.rs/content/blog/2024-07-31-graphite-progress-report-q2-2024.avif" banner_png = "https://static.graphite.rs/content/blog/2024-07-31-graphite-progress-report-q2-2024.png" -author = "Keavon Chambers & Hypercube" summary = "Graphite's Q2 2024 update introduces boolean path operations, a new gradient picker, layer locking, and more improvements." reddit = "https://www.reddit.com/r/graphite/comments/1ei9ps2/blog_post_graphite_progress_report_q2_2024/" twitter = "https://x.com/GraphiteEditor/status/1819360794028462569" diff --git a/website/content/blog/2024-10-15-graphite-progress-report-q3-2024.md b/website/content/blog/2024-10-15-graphite-progress-report-q3-2024.md index 149e0c3c6d..8354da528e 100644 --- a/website/content/blog/2024-10-15-graphite-progress-report-q3-2024.md +++ b/website/content/blog/2024-10-15-graphite-progress-report-q3-2024.md @@ -1,10 +1,11 @@ +++ title = "Graphite progress report (Q3 2024)" date = 2024-10-15 +authors = ["Keavon Chambers", "Hypercube"] + [extra] banner = "https://static.graphite.rs/content/blog/2024-10-15-graphite-progress-report-q3-2024.avif" banner_png = "https://static.graphite.rs/content/blog/2024-10-15-graphite-progress-report-q3-2024.png" -author = "Keavon Chambers & Hypercube" summary = "Graphite's Q3 2024 update introduces improvements to performance, node graph organization, nondestructive path editing, a new render engine, and more helpful nodes." reddit = "https://www.reddit.com/r/graphite/comments/1g4h6ya/blog_post_graphite_progress_report_q3_2024/" twitter = "https://x.com/GraphiteEditor/status/1846283664562573344" diff --git a/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md b/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md index 283b590853..5fee75a279 100644 --- a/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md +++ b/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md @@ -1,11 +1,11 @@ +++ title = "Year in review: 2024 highlights and a peek at 2025" date = 2025-01-16 +authors = ["Keavon Chambers"] [extra] banner = "https://static.graphite.rs/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.avif" banner_png = "https://static.graphite.rs/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.png" -author = "Keavon Chambers" summary = "Graphite has come a long way in 2024. Read about the progress made and the plans for the upcoming year." reddit = "https://www.reddit.com/r/graphite/comments/1i3umnl/blog_post_year_in_review_2024_highlights_and_a/" twitter = "https://x.com/GraphiteEditor/status/1880404337345851612" diff --git a/website/content/blog/2025-03-31-graphite-progress-report-q4-2024.md b/website/content/blog/2025-03-31-graphite-progress-report-q4-2024.md index 732ff42e6a..26fcbe8cf6 100644 --- a/website/content/blog/2025-03-31-graphite-progress-report-q4-2024.md +++ b/website/content/blog/2025-03-31-graphite-progress-report-q4-2024.md @@ -1,10 +1,11 @@ +++ title = "Graphite progress report (Q4 2024)" date = 2025-03-31 +authors = ["Keavon Chambers", "Hypercube"] + [extra] banner = "https://static.graphite.rs/content/blog/2025-03-31-graphite-progress-report-q4-2024.avif" banner_png = "https://static.graphite.rs/content/blog/2025-03-31-graphite-progress-report-q4-2024.png" -author = "Keavon Chambers & Hypercube" summary = "Graphite's Q4 2024 update introduces quality of life features across drawing tools and procedural editing." css = ["/component/demo-artwork.css"] reddit = "https://www.reddit.com/r/graphite/comments/1jpjqcs/blog_post_graphite_progress_report_q4_2024/" diff --git a/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md b/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md index 1c8a54a583..60949d11a1 100644 --- a/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md +++ b/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md @@ -1,10 +1,11 @@ +++ title = "Internships for a Rust graphics engine: GSoC 2025" date = 2025-04-02 +authors = ["Keavon Chambers"] + [extra] banner = "https://static.graphite.rs/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.avif" banner_png = "https://static.graphite.rs/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.png" -author = "Keavon Chambers" summary = "Join Graphite in Google Summer of Code 2025 for a unique opportunity to contribute to open-source software development in Rust and computer graphics. Get paid while learning, working on self-contained projects under experienced mentors, and help Graphite grow." reddit = "https://www.reddit.com/r/graphite/comments/1jplm6t/internships_for_a_rust_graphics_engine_gsoc_2025/" twitter = "https://x.com/GraphiteEditor/status/1907384498389651663" diff --git a/website/content/blog/2025-09-19-graphite-community-meetup-in-germany.md b/website/content/blog/2025-09-19-graphite-community-meetup-in-germany.md index e672d60f42..95465b75fa 100644 --- a/website/content/blog/2025-09-19-graphite-community-meetup-in-germany.md +++ b/website/content/blog/2025-09-19-graphite-community-meetup-in-germany.md @@ -1,10 +1,11 @@ +++ title = "Graphite community meetup in Germany" date = 2025-09-19 +authors = ["Keavon Chambers"] + [extra] banner = "https://static.graphite.rs/content/blog/2025-09-19-graphite-community-meetup-in-germany.avif" banner_png = "https://static.graphite.rs/content/blog/2025-09-19-graphite-community-meetup-in-germany.png" -author = "Keavon Chambers" summary = "Join us for a Graphite community meetup on October 10th, 2025 in Karlsruhe, Germany. Meet the core team and connect with fellow enthusiasts." reddit = "https://www.reddit.com/r/graphite/comments/1nlt64g/graphite_community_meetup_in_germany_october_10/" twitter = "https://x.com/GraphiteEditor/status/1969324821205925934" diff --git a/website/templates/article.html b/website/templates/article.html index 3e1f202c66..b1577c0a9a 100644 --- a/website/templates/article.html +++ b/website/templates/article.html @@ -14,7 +14,12 @@

{{ page.title }}

- By {{ page.extra.author }}. {{ page.date | date(format = "%B %d, %Y", timezone="America/Los_Angeles") }}. + + {% if page.authors %} + By {{ page.authors | join(sep=", ") }}. + {% endif %} + . +
diff --git a/website/templates/blog.html b/website/templates/blog.html index 4581be356e..f42d2950af 100644 --- a/website/templates/blog.html +++ b/website/templates/blog.html @@ -19,7 +19,12 @@ - By {{ page.extra.author }}. {{ page.date | date(format = "%B %d, %Y", timezone = "America/Los_Angeles") }}. + + {% if page.authors %} + By {{ page.authors | join(sep=", ") }}. + {% endif %} + . +

{{ page.summary | striptags | safe }}

diff --git a/website/templates/rss.xml b/website/templates/rss.xml index f926b7a14b..b60d33a415 100644 --- a/website/templates/rss.xml +++ b/website/templates/rss.xml @@ -14,8 +14,8 @@ {{ page.updated | default(value=page.date) | date(format="%Y-%m-%dT%H:%M:%S%:z") }} {{ page.date | date(format="%Y-%m-%dT%H:%M:%S%:z") }} - {% if page.extra.authors %} - {% for author in page.extra.authors %} + {% if page.authors %} + {% for author in page.authors %} {{ author }} From 3cb1717de3c439853bc19e98e2b2d1fb67ef3555 Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 31 Oct 2025 02:09:46 -0400 Subject: [PATCH 05/15] Add blog to title --- website/templates/rss.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/templates/rss.xml b/website/templates/rss.xml index b60d33a415..366376cb30 100644 --- a/website/templates/rss.xml +++ b/website/templates/rss.xml @@ -1,6 +1,6 @@ - {{ config.title }} + {{ config.title }} Blog {{ now() | date(format="%Y-%m-%dT%H:%M:%S%:z") }} {{ config.base_url | safe }} From 03f63e316ccfa7fda9776fe1f80b13eaeee541ff Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Fri, 31 Oct 2025 02:18:51 -0400 Subject: [PATCH 06/15] refactor post summaries to use default zola `description` tag --- website/content/blog/2022-02-12-announcing-graphite-alpha.md | 2 +- ...graphite-a-vision-for-the-future-of-2d-content-creation.md | 2 +- ...022-05-12-distributed-computing-in-the-graphene-runtime.md | 2 +- .../blog/2024-01-01-looking-back-on-2023-and-what's-next.md | 2 +- ...phite-internships-announcing-participation-in-gsoc-2024.md | 2 +- .../blog/2024-05-09-graphite-progress-report-q1-2024.md | 3 ++- .../blog/2024-07-31-graphite-progress-report-q2-2024.md | 3 ++- .../blog/2024-10-15-graphite-progress-report-q3-2024.md | 3 ++- ...01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md | 3 ++- .../blog/2025-03-31-graphite-progress-report-q4-2024.md | 3 ++- ...-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md | 3 ++- .../blog/2025-09-19-graphite-community-meetup-in-germany.md | 3 ++- website/templates/article.html | 4 ++-- website/templates/blog.html | 2 +- website/templates/book.html | 2 +- website/templates/macros/replacements.html | 2 +- website/templates/rss.xml | 4 ++-- 17 files changed, 26 insertions(+), 19 deletions(-) diff --git a/website/content/blog/2022-02-12-announcing-graphite-alpha.md b/website/content/blog/2022-02-12-announcing-graphite-alpha.md index 64a709d40f..a2fd4844af 100644 --- a/website/content/blog/2022-02-12-announcing-graphite-alpha.md +++ b/website/content/blog/2022-02-12-announcing-graphite-alpha.md @@ -2,11 +2,11 @@ title = "Announcing Graphite alpha" date = 2022-02-12 authors = ["Keavon Chambers"] +description = "The Graphite open source team announces the alpha release of their next-generation graphics editor, a web-based SVG editor with vector-based tools. Future plans include a node-based procedural workflow, a raster graphics compositing engine, and a native desktop client." [extra] banner = "https://static.graphite.rs/content/blog/2022-02-12-announcing-graphite-alpha.avif" banner_png = "https://static.graphite.rs/content/blog/2022-02-12-announcing-graphite-alpha.png" -summary = "The Graphite open source team announces the alpha release of their next-generation graphics editor, a web-based SVG editor with vector-based tools. Future plans include a node-based procedural workflow, a raster graphics compositing engine, and a native desktop client." reddit = "https://www.reddit.com/r/graphite/comments/unw3hi/blog_post_announcing_graphite_alpha/" twitter = "https://twitter.com/GraphiteEditor/status/1524663930697568256" +++ diff --git a/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md b/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md index bcf5e13201..06d49aae4d 100644 --- a/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md +++ b/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md @@ -2,11 +2,11 @@ title = "Graphite: a vision for the future of 2D content creation" date = 2022-03-12 authors = ["Keavon Chambers"] +description = "Graphite is an open-source application for 2D graphics editing and digital content creation, offering a nondestructive, node-based workflow. It combines intuitive UI with powerful procedural image generators to revolutionize 2D content creation." [extra] banner = "https://static.graphite.rs/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.avif" banner_png = "https://static.graphite.rs/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.png" -summary = "Graphite is an open-source application for 2D graphics editing and digital content creation, offering a nondestructive, node-based workflow. It combines intuitive UI with powerful procedural image generators to revolutionize 2D content creation." reddit = "https://www.reddit.com/r/graphite/comments/unw3va/blog_post_graphite_a_vision_for_the_future_of_2d/" twitter = "https://twitter.com/GraphiteEditor/status/1524664010091556864" +++ diff --git a/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md b/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md index 490d5a7b01..5a35c5a9a1 100644 --- a/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md +++ b/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md @@ -2,11 +2,11 @@ title = "Distributed computing in the Graphene runtime" date = 2022-05-12 authors = ["Keavon Chambers"] +description = "Graphite's 2D editor is built upon Graphene, a node-based editing system for nondestructive design across various data types designed to render artwork faster using multiple machines. The system optimizes execution paths, minimizes latency, and uses a distributed runtime for quick data processing." [extra] banner = "https://static.graphite.rs/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime__2.avif" banner_png = "https://static.graphite.rs/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime__2.png" -summary = "Graphite's 2D editor is built upon Graphene, a node-based editing system for nondestructive design across various data types designed to render artwork faster using multiple machines. The system optimizes execution paths, minimizes latency, and uses a distributed runtime for quick data processing." reddit = "https://www.reddit.com/r/graphite/comments/unw45k/blog_post_distributed_computing_in_the_graphene/" twitter = "https://twitter.com/GraphiteEditor/status/1524664083554791424" +++ diff --git a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md index 2b8e761db0..b3a1ca1986 100644 --- a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md +++ b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md @@ -2,11 +2,11 @@ title = "Looking back on 2023 and what's next" date = 2024-01-01 authors = ["Keavon Chambers"] +description = "Looking back on 2023, we reflect on our significant achievements and milestones. As we move forward, we're excited to share what's next, promising a year filled with innovation and progress." [extra] banner = "https://static.graphite.rs/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.avif" banner_png = "https://static.graphite.rs/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.png" -summary = "Looking back on 2023, we reflect on our significant achievements and milestones. As we move forward, we're excited to share what's next, promising a year filled with innovation and progress." reddit = "https://www.reddit.com/r/graphite/comments/18xmoti/blog_post_looking_back_on_2023_and_whats_next/" twitter = "https://twitter.com/GraphiteEditor/status/1742576805532577937" diff --git a/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md b/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md index 991f1ce403..7e5107497f 100644 --- a/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md +++ b/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md @@ -2,11 +2,11 @@ title = "Graphite internships: announcing participation in GSoC 2024" date = 2024-02-22 authors = ["Keavon Chambers"] +description = "Join Graphite in Google Summer of Code 2024 for a unique opportunity to contribute to open-source software development in Rust and computer graphics. Get paid while learning, working on self-contained projects under experienced mentors, and help Graphite grow." [extra] banner = "https://static.graphite.rs/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.avif" banner_png = "https://static.graphite.rs/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.png" -summary = "Join Graphite in Google Summer of Code 2024 for a unique opportunity to contribute to open-source software development in Rust and computer graphics. Get paid while learning, working on self-contained projects under experienced mentors, and help Graphite grow." reddit = "https://www.reddit.com/r/graphite/comments/1ax3l8z/blog_post_graphite_internships_announcing/" twitter = "https://twitter.com/GraphiteEditor/status/1760619083396165703" +++ diff --git a/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md b/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md index b8e9ba17eb..bc13adcd20 100644 --- a/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md +++ b/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md @@ -2,11 +2,12 @@ title = "Graphite progress report (Q1 2024)" date = 2024-05-09 authors = ["Keavon Chambers", "Hypercube"] +description = "Graphite's Q1 2024 update introduces a precise snapping system and a customizable grid for enhanced design control. The update also includes improved procedural scattering with the 'Copy to Points' node, demonstrated in new demo artwork." + [extra] banner = "https://static.graphite.rs/content/blog/2024-05-09-graphite-progress-report-q1-2024__2.avif" banner_png = "https://static.graphite.rs/content/blog/2024-05-09-graphite-progress-report-q1-2024__2.png" -summary = "Graphite's Q1 2024 update introduces a precise snapping system and a customizable grid for enhanced design control. The update also includes improved procedural scattering with the 'Copy to Points' node, demonstrated in new demo artwork." reddit = "https://www.reddit.com/r/graphite/comments/1coa0if/blog_post_graphite_progress_report_q1_2024/" twitter = "https://twitter.com/GraphiteEditor/status/1788698448348266946" css = ["/component/demo-artwork.css"] diff --git a/website/content/blog/2024-07-31-graphite-progress-report-q2-2024.md b/website/content/blog/2024-07-31-graphite-progress-report-q2-2024.md index 3f97138984..a3b269d0ba 100644 --- a/website/content/blog/2024-07-31-graphite-progress-report-q2-2024.md +++ b/website/content/blog/2024-07-31-graphite-progress-report-q2-2024.md @@ -2,11 +2,12 @@ title = "Graphite progress report (Q2 2024)" date = 2024-07-31 authors = ["Keavon Chambers", "Hypercube"] +description = "Graphite's Q2 2024 update introduces boolean path operations, a new gradient picker, layer locking, and more improvements." + [extra] banner = "https://static.graphite.rs/content/blog/2024-07-31-graphite-progress-report-q2-2024.avif" banner_png = "https://static.graphite.rs/content/blog/2024-07-31-graphite-progress-report-q2-2024.png" -summary = "Graphite's Q2 2024 update introduces boolean path operations, a new gradient picker, layer locking, and more improvements." reddit = "https://www.reddit.com/r/graphite/comments/1ei9ps2/blog_post_graphite_progress_report_q2_2024/" twitter = "https://x.com/GraphiteEditor/status/1819360794028462569" css = ["/component/demo-artwork.css"] diff --git a/website/content/blog/2024-10-15-graphite-progress-report-q3-2024.md b/website/content/blog/2024-10-15-graphite-progress-report-q3-2024.md index 8354da528e..9febc5f64a 100644 --- a/website/content/blog/2024-10-15-graphite-progress-report-q3-2024.md +++ b/website/content/blog/2024-10-15-graphite-progress-report-q3-2024.md @@ -2,11 +2,12 @@ title = "Graphite progress report (Q3 2024)" date = 2024-10-15 authors = ["Keavon Chambers", "Hypercube"] +description = "Graphite's Q3 2024 update introduces improvements to performance, node graph organization, nondestructive path editing, a new render engine, and more helpful nodes." + [extra] banner = "https://static.graphite.rs/content/blog/2024-10-15-graphite-progress-report-q3-2024.avif" banner_png = "https://static.graphite.rs/content/blog/2024-10-15-graphite-progress-report-q3-2024.png" -summary = "Graphite's Q3 2024 update introduces improvements to performance, node graph organization, nondestructive path editing, a new render engine, and more helpful nodes." reddit = "https://www.reddit.com/r/graphite/comments/1g4h6ya/blog_post_graphite_progress_report_q3_2024/" twitter = "https://x.com/GraphiteEditor/status/1846283664562573344" css = ["/component/demo-artwork.css"] diff --git a/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md b/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md index 5fee75a279..bcf00fefa8 100644 --- a/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md +++ b/website/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.md @@ -2,11 +2,12 @@ title = "Year in review: 2024 highlights and a peek at 2025" date = 2025-01-16 authors = ["Keavon Chambers"] +description = "Graphite has come a long way in 2024. Read about the progress made and the plans for the upcoming year." + [extra] banner = "https://static.graphite.rs/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.avif" banner_png = "https://static.graphite.rs/content/blog/2025-01-16-year-in-review-2024-highlights-and-a-peek-at-2025.png" -summary = "Graphite has come a long way in 2024. Read about the progress made and the plans for the upcoming year." reddit = "https://www.reddit.com/r/graphite/comments/1i3umnl/blog_post_year_in_review_2024_highlights_and_a/" twitter = "https://x.com/GraphiteEditor/status/1880404337345851612" bluesky = "https://bsky.app/profile/graphiteeditor.bsky.social/post/3lfxysayh622g" diff --git a/website/content/blog/2025-03-31-graphite-progress-report-q4-2024.md b/website/content/blog/2025-03-31-graphite-progress-report-q4-2024.md index 26fcbe8cf6..abebd46854 100644 --- a/website/content/blog/2025-03-31-graphite-progress-report-q4-2024.md +++ b/website/content/blog/2025-03-31-graphite-progress-report-q4-2024.md @@ -2,11 +2,12 @@ title = "Graphite progress report (Q4 2024)" date = 2025-03-31 authors = ["Keavon Chambers", "Hypercube"] +description = "Graphite's Q4 2024 update introduces quality of life features across drawing tools and procedural editing." + [extra] banner = "https://static.graphite.rs/content/blog/2025-03-31-graphite-progress-report-q4-2024.avif" banner_png = "https://static.graphite.rs/content/blog/2025-03-31-graphite-progress-report-q4-2024.png" -summary = "Graphite's Q4 2024 update introduces quality of life features across drawing tools and procedural editing." css = ["/component/demo-artwork.css"] reddit = "https://www.reddit.com/r/graphite/comments/1jpjqcs/blog_post_graphite_progress_report_q4_2024/" twitter = "https://x.com/GraphiteEditor/status/1907350199414206604" diff --git a/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md b/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md index 60949d11a1..8b60cde570 100644 --- a/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md +++ b/website/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.md @@ -2,11 +2,12 @@ title = "Internships for a Rust graphics engine: GSoC 2025" date = 2025-04-02 authors = ["Keavon Chambers"] +description = "Join Graphite in Google Summer of Code 2025 for a unique opportunity to contribute to open-source software development in Rust and computer graphics. Get paid while learning, working on self-contained projects under experienced mentors, and help Graphite grow." + [extra] banner = "https://static.graphite.rs/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.avif" banner_png = "https://static.graphite.rs/content/blog/2025-04-02-internships-for-a-rust-graphics-engine-gsoc-2025.png" -summary = "Join Graphite in Google Summer of Code 2025 for a unique opportunity to contribute to open-source software development in Rust and computer graphics. Get paid while learning, working on self-contained projects under experienced mentors, and help Graphite grow." reddit = "https://www.reddit.com/r/graphite/comments/1jplm6t/internships_for_a_rust_graphics_engine_gsoc_2025/" twitter = "https://x.com/GraphiteEditor/status/1907384498389651663" bluesky = "https://bsky.app/profile/graphiteeditor.bsky.social/post/3llt7lbmm4s24" diff --git a/website/content/blog/2025-09-19-graphite-community-meetup-in-germany.md b/website/content/blog/2025-09-19-graphite-community-meetup-in-germany.md index 95465b75fa..452cf42378 100644 --- a/website/content/blog/2025-09-19-graphite-community-meetup-in-germany.md +++ b/website/content/blog/2025-09-19-graphite-community-meetup-in-germany.md @@ -2,11 +2,12 @@ title = "Graphite community meetup in Germany" date = 2025-09-19 authors = ["Keavon Chambers"] +description = "Join us for a Graphite community meetup on October 10th, 2025 in Karlsruhe, Germany. Meet the core team and connect with fellow enthusiasts." + [extra] banner = "https://static.graphite.rs/content/blog/2025-09-19-graphite-community-meetup-in-germany.avif" banner_png = "https://static.graphite.rs/content/blog/2025-09-19-graphite-community-meetup-in-germany.png" -summary = "Join us for a Graphite community meetup on October 10th, 2025 in Karlsruhe, Germany. Meet the core team and connect with fellow enthusiasts." reddit = "https://www.reddit.com/r/graphite/comments/1nlt64g/graphite_community_meetup_in_germany_october_10/" twitter = "https://x.com/GraphiteEditor/status/1969324821205925934" bluesky = "https://bsky.app/profile/graphiteeditor.bsky.social/post/3lzaz3uizkc2j" diff --git a/website/templates/article.html b/website/templates/article.html index b1577c0a9a..ad78a6df26 100644 --- a/website/templates/article.html +++ b/website/templates/article.html @@ -5,7 +5,7 @@ {%- set meta_title = page.title -%} {%- set meta_image = page.extra.banner_png | safe -%} {%- set meta_article_type = true -%} -{%- set meta_description = page.extra.summary | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "
", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%} +{%- set meta_description = page.description | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "
", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%} {%- set css = ["/template/article.css", "/layout/reading-material.css"] -%} {%- endblock head -%} @@ -47,7 +47,7 @@

{{ page.title }}

{% endif %}
-{%- if not page.summary -%} +{%- if not page.description -%} {{ throw(message = "------------------------------------------------------------> ARTICLE HAS NO SUMMARY! After the first paragraph (or two short ones), a `` comment must be inserted in the markdown. Otherwise the blog page would be missing its preview text." | safe) }} {%- endif -%} {%- endblock content -%} diff --git a/website/templates/blog.html b/website/templates/blog.html index f42d2950af..a1637cbdf9 100644 --- a/website/templates/blog.html +++ b/website/templates/blog.html @@ -26,7 +26,7 @@

{{ page.title }}

.
-

{{ page.summary | striptags | safe }}

+

{{ page.description | striptags | safe }}

Keep Reading diff --git a/website/templates/book.html b/website/templates/book.html index 12c54c7ec3..8daa32f617 100644 --- a/website/templates/book.html +++ b/website/templates/book.html @@ -3,7 +3,7 @@ {%- block head -%}{%- set page = page | default(value = section) -%} {%- set title = page.title -%} {%- set meta_article_type = true -%} -{%- set meta_description = page.extra.summary | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "
", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%} +{%- set meta_description = page.description | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "
", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%} {%- set linked_css = ["/syntax-highlighting.css"] -%} {%- set css = ["/template/book.css", "/layout/reading-material.css", "/component/code-snippet.css"] -%} {%- set js = ["/js/book.js"] -%} diff --git a/website/templates/macros/replacements.html b/website/templates/macros/replacements.html index f34f76ca82..7407aa9b67 100644 --- a/website/templates/macros/replacements.html +++ b/website/templates/macros/replacements.html @@ -6,7 +6,7 @@

{{ article.title }}

-

{{ article.summary | striptags | safe }}

+

{{ article.description | striptags | safe }}

Keep reading
diff --git a/website/templates/rss.xml b/website/templates/rss.xml index 366376cb30..4bdca83585 100644 --- a/website/templates/rss.xml +++ b/website/templates/rss.xml @@ -22,8 +22,8 @@ {% endfor %} {% endif %} - {% if page.extra.summary %} - {{ page.extra.summary }} + {% if page.description %} + {{ page.description }} {% endif %} Date: Sun, 2 Nov 2025 13:04:29 -0500 Subject: [PATCH 07/15] feat: add tag --- website/templates/rss.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/website/templates/rss.xml b/website/templates/rss.xml index 4bdca83585..4d3f91fcc5 100644 --- a/website/templates/rss.xml +++ b/website/templates/rss.xml @@ -5,6 +5,7 @@ {{ now() | date(format="%Y-%m-%dT%H:%M:%S%:z") }} {{ config.base_url | safe }} {{ config.description }} + Zola {% for page in section.pages %} From 2a47f1be6ed2be17befc14d088006f1177c0709e Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Sun, 2 Nov 2025 13:07:32 -0500 Subject: [PATCH 08/15] feat: add favicon to feed --- website/templates/rss.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/website/templates/rss.xml b/website/templates/rss.xml index 4d3f91fcc5..1c01b6211e 100644 --- a/website/templates/rss.xml +++ b/website/templates/rss.xml @@ -6,6 +6,7 @@ {{ config.base_url | safe }} {{ config.description }} Zola + /favicon-32x32.png {% for page in section.pages %} From a8416aab8230bdeffa271e7a02ca95366509b632 Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Sun, 2 Nov 2025 13:07:59 -0500 Subject: [PATCH 09/15] feat: Add rights info from footer --- website/templates/rss.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/website/templates/rss.xml b/website/templates/rss.xml index 1c01b6211e..8b0b1ed914 100644 --- a/website/templates/rss.xml +++ b/website/templates/rss.xml @@ -7,6 +7,7 @@ {{ config.description }} Zola /favicon-32x32.png + © 2025 Graphite Labs, LLC {% for page in section.pages %} From 69177b3253ead77e8249b59efadcfe041d433222 Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Sun, 2 Nov 2025 13:08:48 -0500 Subject: [PATCH 10/15] fix: change description to blog page description --- website/templates/rss.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/templates/rss.xml b/website/templates/rss.xml index 8b0b1ed914..2d77259f6b 100644 --- a/website/templates/rss.xml +++ b/website/templates/rss.xml @@ -4,7 +4,7 @@ {{ now() | date(format="%Y-%m-%dT%H:%M:%S%:z") }} {{ config.base_url | safe }} - {{ config.description }} + Latest news and articles from the Graphite team. Zola /favicon-32x32.png © 2025 Graphite Labs, LLC From 4488eb98f5dc884d813d228a13c61af7780d23ff Mon Sep 17 00:00:00 2001 From: Henry Wilkinson Date: Sun, 2 Nov 2025 13:40:35 -0500 Subject: [PATCH 11/15] refactor: remove un-needed config change --- website/config.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/website/config.toml b/website/config.toml index 4f063e14b9..3234f68f98 100644 --- a/website/config.toml +++ b/website/config.toml @@ -5,7 +5,6 @@ feed_filenames = ["rss.xml"] compile_sass = true minify_html = false -generate_feeds = false [markdown] highlight_code = true From e9f884c4b4816cf4e3f1d5ab6354211637bc6478 Mon Sep 17 00:00:00 2001 From: Kulcode <152772205+jsjgdh@users.noreply.github.com> Date: Fri, 1 May 2026 16:40:31 +0530 Subject: [PATCH 12/15] Improve viewport rulers by tilting tick marks to align with tilted documents (#3844) * Add support for tilted rulers * Fix Ruler Text * Address PR review * Fix per review Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Code review --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Keavon Chambers --- .../src/messages/frontend/frontend_message.rs | 1 + .../document/document_message_handler.rs | 1 + .../src/components/panels/Document.svelte | 28 +++- .../widgets/inputs/RulerInput.svelte | 124 +++++++++++++----- 4 files changed, 118 insertions(+), 36 deletions(-) diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index f8039baede..7150f13b9a 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -244,6 +244,7 @@ pub enum FrontendMessage { spacing: f64, interval: f64, visible: bool, + tilt: f64, }, UpdateDocumentScrollbars { position: (f64, f64), diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 229fbf1d4e..422f21880a 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -843,6 +843,7 @@ impl MessageHandler> for DocumentMes spacing: ruler_spacing, interval: ruler_interval, visible: self.rulers_visible, + tilt: if self.graph_view_overlay_open { 0. } else { current_ptz.tilt() }, }); } DocumentMessage::RenderScrollbars => { diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index aad6865bd1..51f771f25e 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -44,6 +44,7 @@ let rulerSpacing = 100; let rulerInterval = 100; let rulersVisible = true; + let rulerTilt = 0; // Rendered SVG viewport data let artworkSvg = ""; @@ -287,11 +288,12 @@ scrollbarMultiplier = { x: multiplier[0], y: multiplier[1] }; } - export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean) { + export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean, tilt: number) { rulerOrigin = { x: origin[0], y: origin[1] }; rulerSpacing = spacing; rulerInterval = interval; rulersVisible = visible; + rulerTilt = tilt; } // Update mouse cursor icon @@ -487,8 +489,8 @@ subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", async (data) => { await tick(); - const { origin, spacing, interval, visible } = data; - updateDocumentRulers(origin, spacing, interval, visible); + const { origin, spacing, interval, visible, tilt } = data; + updateDocumentRulers(origin, spacing, interval, visible, tilt); }); // Update mouse cursor icon @@ -595,13 +597,29 @@ {#if rulersVisible} - + {/if} {#if rulersVisible} - + {/if} diff --git a/frontend/src/components/widgets/inputs/RulerInput.svelte b/frontend/src/components/widgets/inputs/RulerInput.svelte index 1c8511ace7..8a90c5c2c7 100644 --- a/frontend/src/components/widgets/inputs/RulerInput.svelte +++ b/frontend/src/components/widgets/inputs/RulerInput.svelte @@ -5,11 +5,14 @@ const MAJOR_MARK_THICKNESS = 16; const MINOR_MARK_THICKNESS = 6; const MICRO_MARK_THICKNESS = 3; + const TAU = 2 * Math.PI; type RulerDirection = "Horizontal" | "Vertical"; export let direction: RulerDirection = "Vertical"; - export let origin: number; + export let originX: number; + export let originY: number; + export let tilt: number; export let numberInterval: number; export let majorMarkSpacing: number; export let minorDivisions = 5; @@ -19,62 +22,121 @@ let rulerLength = 0; let svgBounds = { width: "0px", height: "0px" }; - $: svgPath = computeSvgPath(direction, origin, majorMarkSpacing, minorDivisions, microDivisions, rulerLength); - $: svgTexts = computeSvgTexts(direction, origin, majorMarkSpacing, numberInterval, rulerLength); - - function computeSvgPath(direction: RulerDirection, origin: number, majorMarkSpacing: number, minorDivisions: number, microDivisions: number, rulerLength: number): string { - const isVertical = direction === "Vertical"; - const lineDirection = isVertical ? "H" : "V"; - - const offsetStart = mod(origin, majorMarkSpacing); - const shiftedOffsetStart = offsetStart - majorMarkSpacing; + type Axis = { sign: number; vec: [number, number] }; + + $: axes = computeAxes(tilt); + $: isHorizontal = direction === "Horizontal"; + $: trackedAxis = isHorizontal ? axes.horiz : axes.vert; + $: otherAxis = isHorizontal ? axes.vert : axes.horiz; + $: stretchFactor = 1 / Math.max(Math.abs(isHorizontal ? trackedAxis.vec[0] : trackedAxis.vec[1]), 1e-10); + $: stretchedSpacing = majorMarkSpacing * stretchFactor; + $: effectiveOrigin = computeEffectiveOrigin(direction, originX, originY, otherAxis); + $: svgPath = computeSvgPath(direction, effectiveOrigin, stretchedSpacing, stretchFactor, minorDivisions, microDivisions, rulerLength, otherAxis); + $: svgTexts = computeSvgTexts(direction, effectiveOrigin, stretchedSpacing, numberInterval, rulerLength, trackedAxis, otherAxis, tilt); + + function computeAxes(tilt: number): { horiz: Axis; vert: Axis } { + const normTilt = ((tilt % TAU) + TAU) % TAU; + const octant = Math.floor((normTilt + Math.PI / 4) / (Math.PI / 2)) % 4; + + const [c, s] = [Math.cos(tilt), Math.sin(tilt)]; + const posX: Axis = { sign: 1, vec: [c, s] }; + const posY: Axis = { sign: 1, vec: [-s, c] }; + const negX: Axis = { sign: -1, vec: [-c, -s] }; + const negY: Axis = { sign: -1, vec: [s, -c] }; + + if (octant === 0) return { horiz: posX, vert: posY }; + if (octant === 1) return { horiz: negY, vert: posX }; + if (octant === 2) return { horiz: negX, vert: negY }; + return { horiz: posY, vert: negX }; + } - const divisions = majorMarkSpacing / minorDivisions / microDivisions; - const majorMarksFrequency = minorDivisions * microDivisions; + function computeEffectiveOrigin(direction: RulerDirection, ox: number, oy: number, otherAxis: Axis): number { + const [vx, vy] = otherAxis.vec; + if (direction === "Horizontal") { + return Math.abs(vy) < 1e-10 ? ox : ox - oy * (vx / vy); + } else { + return Math.abs(vx) < 1e-10 ? oy : oy - ox * (vy / vx); + } + } - let dPathAttribute = ""; + function computeSvgPath( + direction: RulerDirection, + effectiveOrigin: number, + stretchedSpacing: number, + stretchFactor: number, + minorDivisions: number, + microDivisions: number, + rulerLength: number, + otherAxis: Axis, + ): string { + const adaptive = stretchFactor > 1.3 ? { minor: minorDivisions, micro: 1 } : { minor: minorDivisions, micro: microDivisions }; + const divisions = stretchedSpacing / adaptive.minor / adaptive.micro; + const majorMarksFrequency = adaptive.minor * adaptive.micro; + const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing; + + const [vx, vy] = otherAxis.vec; + const flip = direction === "Horizontal" ? (vy > 0 ? -1 : 1) : vx > 0 ? -1 : 1; + const [dx, dy] = [vx * flip, vy * flip]; + const [sxBase, syBase] = direction === "Horizontal" ? [0, RULER_THICKNESS] : [RULER_THICKNESS, 0]; + + let path = ""; let i = 0; - for (let location = shiftedOffsetStart; location < rulerLength; location += divisions) { + for (let location = shiftedOffsetStart; location < rulerLength + RULER_THICKNESS; location += divisions) { let length; if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS; - else if (i % microDivisions === 0) length = MINOR_MARK_THICKNESS; + else if (i % adaptive.micro === 0) length = MINOR_MARK_THICKNESS; else length = MICRO_MARK_THICKNESS; i += 1; const destination = Math.round(location) + 0.5; - const startPoint = isVertical ? `${RULER_THICKNESS - length},${destination}` : `${destination},${RULER_THICKNESS - length}`; - dPathAttribute += `M${startPoint}${lineDirection}${RULER_THICKNESS} `; + const [sx, sy] = direction === "Horizontal" ? [destination, syBase] : [sxBase, destination]; + path += `M${sx},${sy}l${dx * length},${dy * length} `; } - return dPathAttribute; + return path; } - function computeSvgTexts(direction: RulerDirection, origin: number, majorMarkSpacing: number, numberInterval: number, rulerLength: number): { transform: string; text: string }[] { + function computeSvgTexts( + direction: RulerDirection, + effectiveOrigin: number, + stretchedSpacing: number, + numberInterval: number, + rulerLength: number, + trackedAxis: Axis, + otherAxis: Axis, + tilt: number, + ): { transform: string; text: string }[] { const isVertical = direction === "Vertical"; - const offsetStart = mod(origin, majorMarkSpacing); - const shiftedOffsetStart = offsetStart - majorMarkSpacing; + const [vx, vy] = otherAxis.vec; + const flip = isVertical ? (vx > 0 ? -1 : 1) : vy > 0 ? -1 : 1; + const tiltScale = tilt >= 0 ? 1 : 0.5; + const tipOffsetX = vx * flip * MAJOR_MARK_THICKNESS * tiltScale; + const tipOffsetY = vy * flip * MAJOR_MARK_THICKNESS * tiltScale; - const svgTextCoordinates = []; + const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing; + const increments = Math.round((shiftedOffsetStart - effectiveOrigin) / stretchedSpacing); + let labelNumber = increments * numberInterval * trackedAxis.sign; - let labelNumber = (Math.ceil(-origin / majorMarkSpacing) - 1) * numberInterval; + const results: { transform: string; text: string }[] = []; - for (let location = shiftedOffsetStart; location < rulerLength; location += majorMarkSpacing) { + for (let location = shiftedOffsetStart; location < rulerLength; location += stretchedSpacing) { const destination = Math.round(location); - const x = isVertical ? 9 : destination + 2; - const y = isVertical ? destination + 1 : 9; + const x = isVertical ? 9 : destination + 2 + tipOffsetX; + const y = isVertical ? destination + 1 + tipOffsetY : 9; let transform = `translate(${x} ${y})`; if (isVertical) transform += " rotate(270)"; - const text = numberInterval >= 1 ? `${labelNumber}` : labelNumber.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, ""); + const num = Math.abs(labelNumber) < 1e-9 ? 0 : labelNumber; + const text = numberInterval >= 1 ? `${num}` : num.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, ""); - svgTextCoordinates.push({ transform, text }); + results.push({ transform, text }); - labelNumber += numberInterval; + labelNumber += numberInterval * trackedAxis.sign; } - return svgTextCoordinates; + return results; } export function resize() { @@ -83,7 +145,7 @@ const isVertical = direction === "Vertical"; const newLength = isVertical ? rulerInput.clientHeight : rulerInput.clientWidth; - const roundedUp = (Math.floor(newLength / majorMarkSpacing) + 1) * majorMarkSpacing; + const roundedUp = (Math.floor(newLength / stretchedSpacing) + 2) * stretchedSpacing; if (roundedUp !== rulerLength) { rulerLength = roundedUp; From 35dcf2559edf91781de296bf68481acc3ec1158f Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 1 May 2026 04:32:11 -0700 Subject: [PATCH 13/15] Fix panel docking bugs and polish its behavior (#4087) * Fix panel docking bugs and polish its behavior * Fix bug --- .../new_document_dialog_message_handler.rs | 8 +- .../overlays/overlays_message_handler.rs | 6 + .../messages/portfolio/portfolio_message.rs | 4 - .../portfolio/portfolio_message_handler.rs | 33 +-- .../src/messages/portfolio/utility_types.rs | 235 ++++++++++++++---- .../tool/tool_messages/select_tool.rs | 12 +- frontend/src/components/panels/Data.svelte | 23 +- frontend/src/components/panels/Layers.svelte | 72 +----- .../src/components/panels/Properties.svelte | 23 +- frontend/src/components/panels/Welcome.svelte | 24 +- frontend/src/components/window/Panel.svelte | 71 +++--- .../components/window/PanelSubdivision.svelte | 58 +++-- frontend/src/stores/portfolio.ts | 92 ++++++- frontend/wrapper/src/editor_wrapper.rs | 7 - 14 files changed, 415 insertions(+), 253 deletions(-) diff --git a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs index ea321efd4c..cbfacb1715 100644 --- a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs +++ b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs @@ -58,12 +58,10 @@ impl MessageHandler for NewDocumentDialogMessageHa responses.add(NodeGraphMessage::RunDocumentGraph); responses.add(ViewportMessage::RepropagateUpdate); + responses.add(DocumentMessage::DeselectAllLayers); + responses.add(DeferMessage::AfterNavigationReady { - messages: vec![ - DocumentMessage::ZoomCanvasToFitAll.into(), - DocumentMessage::DeselectAllLayers.into(), - PortfolioMessage::AutoSaveActiveDocument.into(), - ], + messages: vec![DocumentMessage::ZoomCanvasToFitAll.into(), PortfolioMessage::AutoSaveActiveDocument.into()], }); responses.add(DocumentMessage::MarkAsSaved); diff --git a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs index 3bf436251c..c7acb50c59 100644 --- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs +++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs @@ -29,6 +29,12 @@ impl MessageHandler> for OverlaysMes use crate::messages::viewport::{Position, ToPhysical}; use wasm_bindgen::JsCast; + // Discard detached canvas after a panel reorganization remounts the DOM + if self.canvas.as_ref().is_some_and(|canvas| !canvas.is_connected()) { + self.canvas = None; + self.context = None; + } + let canvas = match &self.canvas { Some(canvas) => canvas, None => { diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 979fae2291..e188278989 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -195,10 +195,6 @@ pub enum PortfolioMessage { UpdateOpenDocumentsList, UpdateWorkspacePanelLayout, ResetWorkspaceLayout, - ResetPanelGroupSizes { - /// Path of child indices from the root to the split node whose children's sizes should be reset to defaults. - split_path: Vec, - }, SetPanelGroupSizes { /// Path of child indices from the root to the split node whose children's sizes are being set. split_path: Vec, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index b8e3c847a1..9530536279 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -453,6 +453,13 @@ impl MessageHandler> for Portfolio if let Some(layout) = state.workspace_layout { self.workspace_panel_layout = layout; responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + + // Refill panels whose content was lost when the layout load remounted their frontend components + for group_id in self.workspace_panel_layout.root.all_group_ids() { + if let Some(panel_type) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) { + self.refresh_panel_content(panel_type, responses); + } + } } let PersistedState { @@ -1330,13 +1337,16 @@ impl MessageHandler> for Portfolio Self::destroy_panel_layouts(target_active, responses); } + // Preserve the source panel's visual weight at its new location + let source_slot_size = self.workspace_panel_layout.find_source_slot_size(&tabs); + // Remove the dragged tabs from their current panel groups (without pruning, so the target group survives) for &panel_type in &tabs { self.remove_panel_from_layout(panel_type); } // Create the new panel group adjacent to the target, then prune empty groups - let Some(new_id) = self.workspace_panel_layout.split_panel_group(target_group, direction, tabs.clone(), active_tab_index) else { + let Some(new_id) = self.workspace_panel_layout.split_panel_group(target_group, direction, tabs.clone(), active_tab_index, source_slot_size) else { log::error!("Failed to insert split adjacent to panel group {target_group:?}"); return; }; @@ -1611,20 +1621,6 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); responses.add(MenuBarMessage::SendLayout); } - PortfolioMessage::ResetPanelGroupSizes { split_path } => { - // Walk the tree to the target split node using the path - let mut node = &mut self.workspace_panel_layout.root; - for &index in &split_path { - let PanelLayoutSubdivision::Split { children } = node else { return }; - let Some(child) = children.get_mut(index) else { return }; - node = &mut child.subdivision; - } - - // Recalculate default sizes for this split node - node.recalculate_default_sizes(); - - responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); - } PortfolioMessage::SetPanelGroupSizes { split_path, sizes } => { // Walk the tree to the target split node using the path let mut node = &mut self.workspace_panel_layout.root; @@ -1977,7 +1973,12 @@ impl PortfolioMessageHandler { PanelType::Data => { // The Data panel's content is populated automatically as a side effect of the graph run completing, so there's nothing to do here } - PanelType::Document | PanelType::Welcome => {} + PanelType::Document | PanelType::Welcome => { + // Re-send the welcome screen buttons layout to repopulate after a remount + if self.document_ids.is_empty() { + responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout); + } + } } } } diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index f7e0ebb386..905ed4d4d0 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -2,6 +2,11 @@ use graphene_std::Color; use graphene_std::raster::Image; use graphene_std::text::{Font, FontCache}; +/// Proportional share (0-1) for the document panel's side when splitting adjacent to non-document panels. +const DOCUMENT_PANEL_SHARE: f64 = 0.8; +/// Proportional share for each side when neither (or both) contain the document panel. +const EQUAL_PANEL_SHARE: f64 = 0.5; + #[derive(Debug, Default)] pub struct CachedData { pub font_cache: FontCache, @@ -244,25 +249,54 @@ impl WorkspacePanelLayout { /// The direction determines where the new group goes relative to the target. /// Left/Right creates a horizontal (row) split, Top/Bottom creates a vertical (column) split. /// Returns the ID of the newly created panel group, or `None` if insertion failed. - pub fn split_panel_group(&mut self, target_group_id: PanelGroupId, direction: DockingSplitDirection, tabs: Vec, active_tab_index: usize) -> Option { + /// + /// `source_slot_size` overrides the new panel's size (for moves where the old slot will be pruned). + /// If `None`, target's slot is split in the default ratio. + pub fn split_panel_group( + &mut self, + target_group_id: PanelGroupId, + direction: DockingSplitDirection, + tabs: Vec, + active_tab_index: usize, + source_slot_size: Option, + ) -> Option { let new_id = self.next_id(); let new_group = SplitChild { subdivision: PanelLayoutSubdivision::PanelGroup { id: new_id, state: PanelGroupState { tabs, active_tab_index }, }, - size: 50., + size: source_slot_size.unwrap_or(EQUAL_PANEL_SHARE), }; let insert_before = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Top); let needs_horizontal = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Right); - self.root.insert_split_adjacent(target_group_id, new_group, insert_before, needs_horizontal, 0).then_some(new_id) + self.root + .insert_split_adjacent(target_group_id, new_group, insert_before, needs_horizontal, 0, source_slot_size) + .then_some(new_id) + } + + /// Find the slot size of the panel group whose entire content is exactly the given tabs. + /// Returns `None` if the tabs span multiple groups or don't fill their group exactly. + pub fn find_source_slot_size(&self, tabs: &[PanelType]) -> Option { + if tabs.is_empty() { + return None; + } + let group_id = self.find_panel(tabs[0])?; + if !tabs.iter().all(|&t| self.find_panel(t) == Some(group_id)) { + return None; + } + let group = self.panel_group(group_id)?; + if group.tabs.len() != tabs.len() { + return None; + } + self.root.find_slot_size_by_group_id(group_id) } /// Recalculate the default sizes for all splits in the tree based on document panel proximity. pub fn recalculate_default_sizes(&mut self) { - self.root.recalculate_default_sizes(); + self.root.recalculate_default_sizes_recursive(); } /// Remember which panel group and tab index a panel was in before removal, so it can be restored there later. @@ -311,10 +345,10 @@ impl WorkspacePanelLayout { }, }, size: match panel_type { - PanelType::Data => 30., - PanelType::Properties => 45., - PanelType::Layers => 55., - _ => 50., + PanelType::Data => 0.3, + PanelType::Properties => 0.45, + PanelType::Layers => 0.55, + _ => EQUAL_PANEL_SHARE, }, }; @@ -330,7 +364,10 @@ impl WorkspacePanelLayout { if !matches!(&self.root, PanelLayoutSubdivision::Split { .. }) { let old_root = std::mem::replace(&mut self.root, PanelLayoutSubdivision::Split { children: vec![] }); if let PanelLayoutSubdivision::Split { children } = &mut self.root { - children.push(SplitChild { subdivision: old_root, size: 80. }); + children.push(SplitChild { + subdivision: old_root, + size: DOCUMENT_PANEL_SHARE, + }); } } @@ -340,7 +377,7 @@ impl WorkspacePanelLayout { while root_children.len() <= root_child_index { root_children.push(SplitChild { subdivision: PanelLayoutSubdivision::Split { children: vec![] }, - size: 20., + size: (1. - DOCUMENT_PANEL_SHARE), }); } @@ -351,7 +388,7 @@ impl WorkspacePanelLayout { if let PanelLayoutSubdivision::Split { children } = target { children.push(SplitChild { subdivision: old_subdivision, - size: 50., + size: EQUAL_PANEL_SHARE, }); } } @@ -386,10 +423,10 @@ impl Default for WorkspacePanelLayout { active_tab_index: 0, }, }, - size: 100., + size: 1., }], }, - size: 80., + size: DOCUMENT_PANEL_SHARE, }, SplitChild { subdivision: PanelLayoutSubdivision::Split { @@ -402,7 +439,7 @@ impl Default for WorkspacePanelLayout { active_tab_index: 0, }, }, - size: 50., + size: EQUAL_PANEL_SHARE, }, SplitChild { subdivision: PanelLayoutSubdivision::PanelGroup { @@ -412,11 +449,11 @@ impl Default for WorkspacePanelLayout { active_tab_index: 0, }, }, - size: 50., + size: EQUAL_PANEL_SHARE, }, ], }, - size: 20., + size: (1. - DOCUMENT_PANEL_SHARE), }, ], }, @@ -427,6 +464,15 @@ impl Default for WorkspacePanelLayout { } } +/// The share of the slot that should go to the old side when splitting it with a new side. +fn document_split_share(old_side: &PanelLayoutSubdivision, new_side: &PanelLayoutSubdivision) -> f64 { + match (old_side.contains_document(), new_side.contains_document()) { + (true, false) => DOCUMENT_PANEL_SHARE, + (false, true) => 1. - DOCUMENT_PANEL_SHARE, + _ => EQUAL_PANEL_SHARE, + } +} + impl PanelLayoutSubdivision { /// Find the panel group state for a given ID. pub fn find_group(&self, target_id: PanelGroupId) -> Option<&PanelGroupState> { @@ -463,9 +509,10 @@ impl PanelLayoutSubdivision { } } - /// Remove empty panel groups and collapse unnecessary nesting. - /// Does NOT collapse single-child splits into their child, as that would change subdivision depths - /// and break the direction-by-depth alternation system. + /// Remove empty groups/splits and flatten single-child `Split`-in-`Split` nesting (which docking sequences can create). + /// + /// Flattening preserves depth parity (and therefore direction). `PanelGroup`-only single-child splits are left + /// alone since collapsing would change the panel's depth and alter future wrap orientation. pub fn prune(&mut self) { let PanelLayoutSubdivision::Split { children } = self else { return }; @@ -477,6 +524,54 @@ impl PanelLayoutSubdivision { // Remove empty splits (splits that lost all their children after pruning) children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty())); + + // Flatten single-child `Split`-in-`Split` nesting, rescaling sizes to preserve visual proportions + let mut i = 0; + while i < children.len() { + // Must be a `Split`... + let PanelLayoutSubdivision::Split { children: outer } = &children[i].subdivision else { + i += 1; + continue; + }; + // ...with exactly one child... + let [only_child] = outer.as_slice() else { + i += 1; + continue; + }; + // ...that is itself a `Split` + let PanelLayoutSubdivision::Split { .. } = &only_child.subdivision else { + i += 1; + continue; + }; + + // Remove the redundant wrapper + let removed = children.remove(i); + let outer_size = removed.size; + + // Extract the inner grandchildren + let PanelLayoutSubdivision::Split { children: mut outer_children } = removed.subdivision else { + continue; + }; + let Some(inner_split) = outer_children.pop() else { continue }; + let PanelLayoutSubdivision::Split { children: inner_children } = inner_split.subdivision else { + continue; + }; + + // Splice grandchildren in at the same position, scaling their sizes to fill the removed slot + let inner_total: f64 = inner_children.iter().map(|c| c.size).sum(); + for (offset, mut grandchild) in inner_children.into_iter().enumerate() { + grandchild.size = if inner_total > 0. { grandchild.size / inner_total * outer_size } else { outer_size }; + children.insert(i + offset, grandchild); + } + } + + // Renormalize to sum=1 since dock/prune cycles can compound shrinkage + let total: f64 = children.iter().map(|c| c.size).sum(); + if total > 0. && (total - 1.).abs() > 0.001 { + for child in children.iter_mut() { + child.size /= total; + } + } } /// Remove all non-document/non-welcome tabs from panel groups, leaving only document-related panels. @@ -503,7 +598,9 @@ impl PanelLayoutSubdivision { /// Inserts a new split child adjacent to a target panel group and returns whether the insertion was successful. /// Recurses to the deepest split closest to the target that matches the requested split direction. /// If the target is a direct child of a mismatched-direction split, this wraps it in a new sub-split. - pub fn insert_split_adjacent(&mut self, target_id: PanelGroupId, new_child: SplitChild, insert_before: bool, needs_horizontal: bool, depth: usize) -> bool { + /// + /// `source_slot_size` preserves the moved panel's visual weight. If `None`, uses the default split ratio. + pub fn insert_split_adjacent(&mut self, target_id: PanelGroupId, new_child: SplitChild, insert_before: bool, needs_horizontal: bool, depth: usize, source_slot_size: Option) -> bool { let PanelLayoutSubdivision::Split { children } = self else { return false }; let is_horizontal = depth.is_multiple_of(2); @@ -517,18 +614,35 @@ impl PanelLayoutSubdivision { // If the target is a direct child: we can certainly insert the new split, either as a sibling (if direction matches) or wrapping the target in a new split (if direction is mismatched) let target_is_direct_child = matches!(&children[containing_index].subdivision, PanelLayoutSubdivision::PanelGroup { id, .. } if *id == target_id); if target_is_direct_child { - // Direction matches and target is right here: insert as a sibling + // Direction matches: insert as sibling, sizing based on whether target will be pruned, source size hint, or default ratio if direction_matches { + let mut new_child = new_child; + let target_will_be_pruned = matches!(&children[containing_index].subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty()); + if target_will_be_pruned { + new_child.size = children[containing_index].size; + } else if let Some(hint) = source_slot_size { + new_child.size = hint; + } else { + let target_share = document_split_share(&children[containing_index].subdivision, &new_child.subdivision); + let total = children[containing_index].size; + children[containing_index].size = total * target_share; + new_child.size = total * (1. - target_share); + } + let insert_index = if insert_before { containing_index } else { containing_index + 1 }; children.insert(insert_index, new_child); } - // Direction mismatch: wrap the target in a new sub-split (at depth+1, which has the opposite direction of this and thus is the requested direction) + // Direction mismatch: wrap target in a sub-split at depth+1, sharing the slot in the default ratio else { let old_child_subdivision = std::mem::replace(&mut children[containing_index].subdivision, PanelLayoutSubdivision::Split { children: vec![] }); + + let old_share = document_split_share(&old_child_subdivision, &new_child.subdivision); let old_child = SplitChild { subdivision: old_child_subdivision, - size: 50., + size: old_share, }; + let mut new_child = new_child; + new_child.size = 1. - old_share; if let PanelLayoutSubdivision::Split { children: sub_children } = &mut children[containing_index].subdivision { if insert_before { @@ -547,7 +661,25 @@ impl PanelLayoutSubdivision { // The target is deeper, so recurse into the containing child's subtree and return its insertion outcome children[containing_index] .subdivision - .insert_split_adjacent(target_id, new_child.clone(), insert_before, needs_horizontal, depth + 1) + .insert_split_adjacent(target_id, new_child.clone(), insert_before, needs_horizontal, depth + 1, source_slot_size) + } + + /// Find the size of the `SplitChild` slot whose subdivision is the panel group with the given ID, if it exists. + pub fn find_slot_size_by_group_id(&self, group_id: PanelGroupId) -> Option { + let PanelLayoutSubdivision::Split { children } = self else { return None }; + for child in children { + if let PanelLayoutSubdivision::PanelGroup { id, .. } = &child.subdivision + && *id == group_id + { + return Some(child.size); + } + } + for child in children { + if let Some(size) = child.subdivision.find_slot_size_by_group_id(group_id) { + return Some(size); + } + } + None } /// Check if this subtree contains the document panel. @@ -558,39 +690,46 @@ impl PanelLayoutSubdivision { } } - /// Recalculate the default sizes for this subdivision's children based on proximity to the document panel. + /// Recalculate the default sizes for this subdivision's direct children based on proximity to the document panel. /// Splits directly surrounding the document panel use 80-20 weighting. /// All other splits use equal division. + /// Does not recurse into descendants: use [`Self::recalculate_default_sizes_recursive`] for that. pub fn recalculate_default_sizes(&mut self) { - if let PanelLayoutSubdivision::Split { children } = self { - let child_count = children.len(); - if child_count == 0 { - return; - } + let PanelLayoutSubdivision::Split { children } = self else { return }; - // Check if any child directly contains (or is) the document panel - let document_child_index = children.iter().position(|child| child.subdivision.contains_document()); + let child_count = children.len(); + if child_count == 0 { + return; + } - if let Some(document_index) = document_child_index { - // This split directly surrounds the document panel, so use 80-20 weighting - let non_document_count = child_count - 1; - let document_share = if non_document_count > 0 { 80. } else { 100. }; - let other_share = if non_document_count > 0 { 20. / non_document_count as f64 } else { 0. }; + // Check if any child directly contains (or is) the document panel + let document_child_index = children.iter().position(|child| child.subdivision.contains_document()); - for (i, child) in children.iter_mut().enumerate() { - child.size = if i == document_index { document_share } else { other_share }; - } - } else { - // This split doesn't directly contain the document, use equal division - let equal_share = 100. / child_count as f64; - for child in children.iter_mut() { - child.size = equal_share; - } + if let Some(document_index) = document_child_index { + // This split directly surrounds the document panel + let non_document_count = child_count - 1; + let document_share = if non_document_count > 0 { DOCUMENT_PANEL_SHARE } else { 1. }; + let other_share = if non_document_count > 0 { (1. - DOCUMENT_PANEL_SHARE) / non_document_count as f64 } else { 0. }; + + for (i, child) in children.iter_mut().enumerate() { + child.size = if i == document_index { document_share } else { other_share }; + } + } else { + // This split doesn't directly contain the document, use equal division + let equal_share = 1. / child_count as f64; + for child in children.iter_mut() { + child.size = equal_share; } + } + } - // Recurse into children + /// Recalculate the default sizes for this subdivision and all its descendant splits. + pub fn recalculate_default_sizes_recursive(&mut self) { + self.recalculate_default_sizes(); + + if let PanelLayoutSubdivision::Split { children } = self { for child in children.iter_mut() { - child.subdivision.recalculate_default_sizes(); + child.subdivision.recalculate_default_sizes_recursive(); } } } diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 0eb1518f46..4202e374b0 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -1430,9 +1430,15 @@ impl Fsm for SelectToolFsmState { NestedSelectionBehavior::Deepest if remove => drag_deepest_manipulation(responses, selected, tool_data, document, true), NestedSelectionBehavior::Shallowest if !deepest => drag_shallowest_manipulation(responses, selected, tool_data, document, false, true), _ => { - responses.add(DocumentMessage::DeselectAllLayers); - tool_data.layers_dragging.clear(); - drag_deepest_manipulation(responses, selected, tool_data, document, false) + // Narrow multi-selection to just the clicked layer (no-op if it's already the sole selection) + let currently_selected = document.network_interface.selected_nodes().selected_layers(document.metadata()).collect::>(); + let already_only_selection = currently_selected.as_slice() == [intersection]; + + if !already_only_selection { + responses.add(DocumentMessage::DeselectAllLayers); + tool_data.layers_dragging.clear(); + drag_deepest_manipulation(responses, selected, tool_data, document, false) + } } } diff --git a/frontend/src/components/panels/Data.svelte b/frontend/src/components/panels/Data.svelte index b524ecefa8..400e556f5b 100644 --- a/frontend/src/components/panels/Data.svelte +++ b/frontend/src/components/panels/Data.svelte @@ -1,30 +1,15 @@ - + diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index c2ac6f93fa..e5d91fc88b 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -1,6 +1,5 @@ (dragInPanel = false)}> - - {#if layersPanelControlBarLeftLayout?.length > 0 && layersPanelControlBarRightLayout?.length > 0} + + {#if $portfolio.layersPanelControlBarLeftLayout?.length > 0 && $portfolio.layersPanelControlBarRightLayout?.length > 0} {/if} - + - + diff --git a/frontend/src/components/panels/Properties.svelte b/frontend/src/components/panels/Properties.svelte index 31e96629c9..fee12bd76a 100644 --- a/frontend/src/components/panels/Properties.svelte +++ b/frontend/src/components/panels/Properties.svelte @@ -1,30 +1,15 @@ - + diff --git a/frontend/src/components/panels/Welcome.svelte b/frontend/src/components/panels/Welcome.svelte index c21b57c389..a38d724ee5 100644 --- a/frontend/src/components/panels/Welcome.svelte +++ b/frontend/src/components/panels/Welcome.svelte @@ -1,30 +1,16 @@ {#if subdivision && "PanelGroup" in subdivision} diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 8ce87951ac..4944d74c86 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -1,9 +1,12 @@ +import { tick } from "svelte"; +import { SvelteMap } from "svelte/reactivity"; import { writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files"; import { rasterizeSVG } from "/src/utility-functions/rasterization"; -import type { EditorWrapper, DocumentInfo, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper"; +import { patchLayout } from "/src/utility-functions/widgets"; +import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper"; export type PortfolioStore = ReturnType; @@ -12,12 +15,28 @@ type PortfolioStoreState = { documents: DocumentInfo[]; activeDocumentIndex: number; panelLayout: WorkspacePanelLayout; + welcomeScreenButtonsLayout: Layout; + propertiesPanelLayout: Layout; + dataPanelLayout: Layout; + layersPanelControlBarLeftLayout: Layout; + layersPanelControlBarRightLayout: Layout; + layersPanelBottomBarLayout: Layout; + layerCache: SvelteMap; + layerStructure: LayerStructureEntry[]; }; const initialState: PortfolioStoreState = { unsaved: false, documents: [], activeDocumentIndex: 0, panelLayout: {}, + welcomeScreenButtonsLayout: [], + propertiesPanelLayout: [], + dataPanelLayout: [], + layersPanelControlBarLeftLayout: [], + layersPanelControlBarRightLayout: [], + layersPanelBottomBarLayout: [], + layerCache: new SvelteMap(), + layerStructure: [], }; let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; @@ -104,6 +123,69 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: }); }); + // All panel layouts below live in this store so panels that remount during a panel-tree change keep their contents + subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", async (data) => { + await tick(); + update((state) => { + patchLayout(state.welcomeScreenButtonsLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("PropertiesPanel", async (data) => { + await tick(); + update((state) => { + patchLayout(state.propertiesPanelLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("DataPanel", async (data) => { + await tick(); + update((state) => { + patchLayout(state.dataPanelLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", async (data) => { + await tick(); + update((state) => { + patchLayout(state.layersPanelControlBarLeftLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("LayersPanelControlRightBar", async (data) => { + await tick(); + update((state) => { + patchLayout(state.layersPanelControlBarRightLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("LayersPanelBottomBar", async (data) => { + await tick(); + update((state) => { + patchLayout(state.layersPanelBottomBarLayout, data); + return state; + }); + }); + + subscriptions.subscribeFrontendMessage("UpdateDocumentLayerStructure", (data) => { + update((state) => { + state.layerStructure = data.layerStructure; + return state; + }); + }); + + subscriptions.subscribeFrontendMessage("UpdateDocumentLayerDetails", (data) => { + update((state) => { + state.layerCache.set(String(data.data.id), data.data); + return state; + }); + }); + return { subscribe }; } @@ -120,4 +202,12 @@ export function destroyPortfolioStore() { subscriptions.unsubscribeFrontendMessage("TriggerSaveFile"); subscriptions.unsubscribeFrontendMessage("TriggerExportImage"); subscriptions.unsubscribeFrontendMessage("UpdateWorkspacePanelLayout"); + subscriptions.unsubscribeLayoutUpdate("WelcomeScreenButtons"); + subscriptions.unsubscribeLayoutUpdate("PropertiesPanel"); + subscriptions.unsubscribeLayoutUpdate("DataPanel"); + subscriptions.unsubscribeLayoutUpdate("LayersPanelControlLeftBar"); + subscriptions.unsubscribeLayoutUpdate("LayersPanelControlRightBar"); + subscriptions.unsubscribeLayoutUpdate("LayersPanelBottomBar"); + subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerStructure"); + subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerDetails"); } diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 6eb1a89511..6335113daf 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -498,13 +498,6 @@ impl EditorWrapper { self.dispatch(message); } - #[wasm_bindgen(js_name = resetPanelGroupSizes)] - pub fn reset_panel_group_sizes(&self, split_path: JsValue) { - let split_path: Vec = serde_wasm_bindgen::from_value(split_path).unwrap(); - let message = PortfolioMessage::ResetPanelGroupSizes { split_path }; - self.dispatch(message); - } - #[wasm_bindgen(js_name = setPanelGroupSizes)] pub fn set_panel_group_sizes(&self, split_path: JsValue, sizes: JsValue) { let split_path: Vec = serde_wasm_bindgen::from_value(split_path).unwrap(); From 9c1310d67373fda85af92c96ef18f9a2f912ae85 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 1 May 2026 05:24:59 -0700 Subject: [PATCH 14/15] Optimize the node graph panel while panning --- frontend/src/components/views/Graph.svelte | 140 +++++++++++---------- frontend/src/stores/node-graph.ts | 75 ++++++----- 2 files changed, 116 insertions(+), 99 deletions(-) diff --git a/frontend/src/components/views/Graph.svelte b/frontend/src/components/views/Graph.svelte index 5a410106c5..c34d06a0cb 100644 --- a/frontend/src/components/views/Graph.svelte +++ b/frontend/src/components/views/Graph.svelte @@ -20,13 +20,17 @@ const editor = getContext("editor"); const nodeGraph = getContext("nodeGraph"); + const nodeGraphTransform = nodeGraph.transformStore; + const nodeGraphImportsExports = nodeGraph.importsExportsStore; + const visibleNodes = nodeGraph.visibleNodesStore; + const nodeGraphWires = nodeGraph.wiresStore; const documentState = getContext("document"); const subscriptions = getContext("subscriptions"); let graph: HTMLDivElement | undefined; - $: gridSpacing = calculateGridSpacing($nodeGraph.transform.scale); - $: gridDotRadius = 1 + Math.floor($nodeGraph.transform.scale - 0.5 + 0.001) / 2; + $: gridSpacing = calculateGridSpacing($nodeGraphTransform.scale); + $: gridDotRadius = 1 + Math.floor($nodeGraphTransform.scale - 0.5 + 0.001) / 2; // Close the context menu when the graph view overlay is closed $: if (!$documentState.graphViewOverlayOpen) closeContextMenu(); @@ -215,23 +219,22 @@ } -
+
+
{#if $nodeGraph.contextMenuInformation} +
{$nodeGraph.error.error} @@ -295,7 +298,7 @@ {#if $nodeGraph.clickTargets} -
+
{#each $nodeGraph.clickTargets.nodeClickTargets as pathString} @@ -318,9 +321,9 @@ {/if} -
+
- {#each $nodeGraph.wires.values() as map} + {#each $nodeGraphWires.values() as map} {#each map.values() as { pathString, dataType, thick, dashed }} {#if thick} -
- {#if $nodeGraph.updateImportsExports} - {#each $nodeGraph.updateImportsExports.imports as frontendOutput, index} +
+ {#if $nodeGraphImportsExports} + {#each $nodeGraphImportsExports.imports as frontendOutput, index} {#if frontendOutput} {#if frontendOutput.connectedTo.length > 0} @@ -365,10 +368,10 @@ on:pointerenter={() => (hoveringImportIndex = index)} on:pointerleave={() => (hoveringImportIndex = undefined)} class="edit-import-export import" - class:separator-bottom={index === 0 && $nodeGraph.updateImportsExports.addImportExport} - class:separator-top={index === 1 && $nodeGraph.updateImportsExports.addImportExport} - style:--offset-left={($nodeGraph.updateImportsExports.importPosition[0] - 8) / 24} - style:--offset-top={($nodeGraph.updateImportsExports.importPosition[1] - 8) / 24 + index} + class:separator-bottom={index === 0 && $nodeGraphImportsExports.addImportExport} + class:separator-top={index === 1 && $nodeGraphImportsExports.addImportExport} + style:--offset-left={($nodeGraphImportsExports.importPosition[0] - 8) / 24} + style:--offset-top={($nodeGraphImportsExports.importPosition[1] - 8) / 24 + index} > {#if editingNameImportIndex === index}
+
editor.addPrimaryImport()} />
{/if} {/each} - {#each $nodeGraph.updateImportsExports.exports as frontendInput, index} + {#each $nodeGraphImportsExports.exports as frontendInput, index} {#if frontendInput} {#if frontendInput.connectedTo !== "Connected to nothing."} @@ -437,12 +436,12 @@ on:pointerenter={() => (hoveringExportIndex = index)} on:pointerleave={() => (hoveringExportIndex = undefined)} class="edit-import-export export" - class:separator-bottom={index === 0 && $nodeGraph.updateImportsExports.addImportExport} - class:separator-top={index === 1 && $nodeGraph.updateImportsExports.addImportExport} - style:--offset-left={($nodeGraph.updateImportsExports.exportPosition[0] - 8) / 24} - style:--offset-top={($nodeGraph.updateImportsExports.exportPosition[1] - 8) / 24 + index} + class:separator-bottom={index === 0 && $nodeGraphImportsExports.addImportExport} + class:separator-top={index === 1 && $nodeGraphImportsExports.addImportExport} + style:--offset-left={($nodeGraphImportsExports.exportPosition[0] - 8) / 24} + style:--offset-top={($nodeGraphImportsExports.exportPosition[1] - 8) / 24 + index} > - {#if (hoveringExportIndex === index || editingNameExportIndex === index) && $nodeGraph.updateImportsExports.addImportExport} + {#if (hoveringExportIndex === index || editingNameExportIndex === index) && $nodeGraphImportsExports.addImportExport} {#if index > 0}
{/if} @@ -473,28 +472,24 @@ {/if}
{:else} -
+
editor.addPrimaryExport()} />
{/if} {/each} - {#if $nodeGraph.updateImportsExports.addImportExport} + {#if $nodeGraphImportsExports.addImportExport}
editor.addSecondaryImport()} />
editor.addSecondaryExport()} />
@@ -502,16 +497,16 @@ {#if $nodeGraph.reorderImportIndex !== undefined} {@const position = { - x: Number($nodeGraph.updateImportsExports.importPosition[0]), - y: Number($nodeGraph.updateImportsExports.importPosition[1]) + Number($nodeGraph.reorderImportIndex) * 24, + x: Number($nodeGraphImportsExports.importPosition[0]), + y: Number($nodeGraphImportsExports.importPosition[1]) + Number($nodeGraph.reorderImportIndex) * 24, }}
{/if} {#if $nodeGraph.reorderExportIndex !== undefined} {@const position = { - x: Number($nodeGraph.updateImportsExports.exportPosition[0]), - y: Number($nodeGraph.updateImportsExports.exportPosition[1]) + Number($nodeGraph.reorderExportIndex) * 24, + x: Number($nodeGraphImportsExports.exportPosition[0]), + y: Number($nodeGraphImportsExports.exportPosition[1]) + Number($nodeGraph.reorderExportIndex) * 24, }}
{/if} @@ -519,11 +514,11 @@
-
+
{#each Array.from($nodeGraph.nodes) - .filter(([nodeId, node]) => node.isLayer && $nodeGraph.visibleNodes.has(nodeId)) - .map(([_, node], nodeIndex) => ({ node, nodeIndex })) as { node, nodeIndex } (nodeIndex)} + .filter(([nodeId, node]) => node.isLayer && $visibleNodes.has(nodeId)) + .map(([_, node]) => node) as node (node.id)} {@const clipPathId = String(Math.random()).substring(2)} {@const stackDataInput = node.exposedInputs[0]} {@const layerAreaWidth = $nodeGraph.layerWidths.get(node.id) || 8} @@ -687,7 +682,7 @@
- {#each $nodeGraph.wires.values() as map} + {#each $nodeGraphWires.values() as map} {#each map.values() as { pathString, dataType, thick, dashed }} {#if !thick} {#each Array.from($nodeGraph.nodes) - .filter(([nodeId, node]) => !node.isLayer && $nodeGraph.visibleNodes.has(nodeId)) - .map(([_, node], nodeIndex) => ({ node, nodeIndex })) as { node, nodeIndex } (nodeIndex)} + .filter(([nodeId, node]) => !node.isLayer && $visibleNodes.has(nodeId)) + .map(([_, node]) => node) as node (node.id)} {@const exposedInputsOutputs = zipWithUndefined(node.exposedInputs, node.exposedOutputs)} {@const clipPathId = String(Math.random()).substring(2)} {@const description = node.reference ? $nodeGraph.nodeDescriptions.get(node.reference) : undefined} @@ -870,18 +865,25 @@ flex-direction: row; flex-grow: 1; - // We're displaying the dotted grid in a pseudo-element because `image-rendering` is an inherited property and we don't want it to apply to child elements - &::before { - content: ""; + .grid-background { position: absolute; width: 100%; height: 100%; - background-size: var(--grid-spacing) var(--grid-spacing); - background-position: calc(var(--grid-offset-x) - var(--grid-dot-radius)) calc(var(--grid-offset-y) - var(--grid-dot-radius)); - background-image: radial-gradient(circle at var(--grid-dot-radius) var(--grid-dot-radius), var(--color-3-darkgray) var(--grid-dot-radius), transparent 0); - background-repeat: repeat; - image-rendering: pixelated; - mix-blend-mode: screen; + pointer-events: none; + + // We're displaying the dotted grid in a pseudo-element because `image-rendering` is an inherited property and we don't want it to apply to child elements + &::before { + content: ""; + position: absolute; + width: 100%; + height: 100%; + background-size: var(--grid-spacing) var(--grid-spacing); + background-position: calc(var(--grid-offset-x) - var(--grid-dot-radius)) calc(var(--grid-offset-y) - var(--grid-dot-radius)); + background-image: radial-gradient(circle at var(--grid-dot-radius) var(--grid-dot-radius), var(--color-3-darkgray) var(--grid-dot-radius), transparent 0); + background-repeat: repeat; + image-rendering: pixelated; + mix-blend-mode: screen; + } } > img { diff --git a/frontend/src/stores/node-graph.ts b/frontend/src/stores/node-graph.ts index 4a91484717..24d7c7e796 100644 --- a/frontend/src/stores/node-graph.ts +++ b/frontend/src/stores/node-graph.ts @@ -6,6 +6,8 @@ import type { NodeGraphErrorDiagnostic, BoxSelection, FrontendClickTargets, Cont export type NodeGraphStore = ReturnType; +export type NodeGraphTransform = { scale: number; x: number; y: number }; + type NodeGraphStoreState = { box: BoxSelection | undefined; clickTargets: FrontendClickTargets | undefined; @@ -14,17 +16,12 @@ type NodeGraphStoreState = { layerWidths: Map; chainWidths: Map; hasLeftInputWire: Map; - updateImportsExports: MessageBody<"UpdateImportsExports"> | undefined; nodes: Map; - visibleNodes: Set; - /// The index is the exposed input index. The exports have a first key value of u32::MAX. - wires: Map>; wirePathInProgress: WirePath | undefined; nodeDescriptions: Map; nodeTypes: FrontendNodeType[]; thumbnails: Map; selected: bigint[]; - transform: { scale: number; x: number; y: number }; inSelectedNetwork: boolean; reorderImportIndex: number | undefined; reorderExportIndex: number | undefined; @@ -37,16 +34,12 @@ const initialState: NodeGraphStoreState = { layerWidths: new Map(), chainWidths: new Map(), hasLeftInputWire: new Map(), - updateImportsExports: undefined, nodes: new Map(), - visibleNodes: new Set(), - wires: new Map(), wirePathInProgress: undefined, nodeDescriptions: new Map(), nodeTypes: [], thumbnails: new Map(), selected: [], - transform: { scale: 1, x: 0, y: 0 }, inSelectedNetwork: true, reorderImportIndex: undefined, reorderExportIndex: undefined, @@ -59,6 +52,22 @@ const store: Writable = import.meta.hot?.data?.store || wri if (import.meta.hot) import.meta.hot.data.store = store; const { subscribe, update } = store; +// Separate transform store so pan/zoom updates don't trigger re-rendering the entire node graph +const transformStore: Writable = import.meta.hot?.data?.transformStore || writable({ scale: 1, x: 0, y: 0 }); +if (import.meta.hot) import.meta.hot.data.transformStore = transformStore; + +// Separate imports/exports store so viewport-anchored position updates don't trigger node re-renders +const importsExportsStore: Writable | undefined> = import.meta.hot?.data?.importsExportsStore || writable(undefined); +if (import.meta.hot) import.meta.hot.data.importsExportsStore = importsExportsStore; + +// Separate visible nodes store so viewport culling changes don't trigger full node re-renders +const visibleNodesStore: Writable> = import.meta.hot?.data?.visibleNodesStore || writable(new Set()); +if (import.meta.hot) import.meta.hot.data.visibleNodesStore = visibleNodesStore; + +// Separate wires store so wire path updates (e.g. export connector movement during pan) don't trigger node re-renders +const wiresStore: Writable>> = import.meta.hot?.data?.wiresStore || writable(new Map()); +if (import.meta.hot) import.meta.hot.data.wiresStore = wiresStore; + export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { destroyNodeGraphStore(); @@ -108,10 +117,7 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); subscriptions.subscribeFrontendMessage("UpdateImportsExports", (data) => { - update((state) => { - state.updateImportsExports = data; - return state; - }); + importsExportsStore.set(data); }); subscriptions.subscribeFrontendMessage("UpdateInSelectedNetwork", (data) => { @@ -148,20 +154,35 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); subscriptions.subscribeFrontendMessage("UpdateVisibleNodes", (data) => { - update((state) => { - state.visibleNodes = new Set(data.nodes); - return state; + const newNodes = new Set(data.nodes); + + // Short-circuit when the visible set hasn't changed to avoid unnecessary re-renders + let changed = false; + const unsubscribe = visibleNodesStore.subscribe((current) => { + if (current.size !== newNodes.size) { + changed = true; + } else { + newNodes.forEach((node) => { + if (!current.has(node)) changed = true; + }); + } }); + unsubscribe(); + + if (!changed) return; + + visibleNodesStore.set(newNodes); }); subscriptions.subscribeFrontendMessage("UpdateNodeGraphWires", (data) => { - update((state) => { + if (data.wires.length === 0) return; + + wiresStore.update((wires) => { data.wires.forEach((wireUpdate) => { - let inputMap = state.wires.get(wireUpdate.id); - // If it doesn't exist, create it and set it in the outer map + let inputMap = wires.get(wireUpdate.id); if (!inputMap) { inputMap = new Map(); - state.wires.set(wireUpdate.id, inputMap); + wires.set(wireUpdate.id, inputMap); } if (wireUpdate.wirePathUpdate !== undefined) { inputMap.set(wireUpdate.inputIndex, wireUpdate.wirePathUpdate); @@ -169,15 +190,12 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { inputMap.delete(wireUpdate.inputIndex); } }); - return state; + return wires; }); }); subscriptions.subscribeFrontendMessage("ClearAllNodeGraphWires", () => { - update((state) => { - state.wires.clear(); - return state; - }); + wiresStore.set(new Map()); }); subscriptions.subscribeFrontendMessage("UpdateNodeGraphSelection", (data) => { @@ -188,10 +206,7 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); subscriptions.subscribeFrontendMessage("UpdateNodeGraphTransform", (data) => { - update((state) => { - state.transform = { scale: data.scale, x: data.translation[0], y: data.translation[1] }; - return state; - }); + transformStore.set({ scale: data.scale, x: data.translation[0], y: data.translation[1] }); }); subscriptions.subscribeFrontendMessage("UpdateNodeThumbnail", (data) => { @@ -208,7 +223,7 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); }); - return { subscribe }; + return { subscribe, transformStore, importsExportsStore, visibleNodesStore, wiresStore }; } export function destroyNodeGraphStore() { From be2ade0db8583295786405c9605b47f039c921e3 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 1 May 2026 18:45:32 +0200 Subject: [PATCH 15/15] Reimplement checkered background rendering (#4034) * Reimplement background checkerboard rendering --- desktop/src/render/state.rs | 27 +- desktop/wrapper/src/lib.rs | 1 - .../graph_operation_message.rs | 2 +- editor/src/node_graph_executor.rs | 1 + node-graph/interpreted-executor/src/util.rs | 15 +- .../libraries/core-types/src/transform.rs | 2 +- .../libraries/rendering/src/renderer.rs | 175 ++++----- .../src/background/checker_rect.wgsl | 49 +++ .../src/background/checker_viewport.wgsl | 45 +++ .../src/background/fullscreen.wgsl | 35 ++ .../wgpu-executor/src/background/mod.rs | 344 ++++++++++++++++++ node-graph/libraries/wgpu-executor/src/lib.rs | 155 ++++---- .../libraries/wgpu-executor/src/resample.rs | 36 +- .../wgpu-executor/src/texture_cache.rs | 95 +++++ node-graph/nodes/gstd/src/pixel_preview.rs | 2 +- node-graph/nodes/gstd/src/render_cache.rs | 89 +---- node-graph/nodes/gstd/src/render_node.rs | 292 ++++++++------- 17 files changed, 921 insertions(+), 444 deletions(-) create mode 100644 node-graph/libraries/wgpu-executor/src/background/checker_rect.wgsl create mode 100644 node-graph/libraries/wgpu-executor/src/background/checker_viewport.wgsl create mode 100644 node-graph/libraries/wgpu-executor/src/background/fullscreen.wgsl create mode 100644 node-graph/libraries/wgpu-executor/src/background/mod.rs create mode 100644 node-graph/libraries/wgpu-executor/src/texture_cache.rs diff --git a/desktop/src/render/state.rs b/desktop/src/render/state.rs index 7481cdd916..3cae549e54 100644 --- a/desktop/src/render/state.rs +++ b/desktop/src/render/state.rs @@ -1,8 +1,7 @@ -use std::borrow::Cow; use wgpu::PresentMode; use crate::window::Window; -use crate::wrapper::{TargetTexture, WgpuContext, WgpuExecutor}; +use crate::wrapper::{WgpuContext, WgpuExecutor}; #[derive(derivative::Derivative)] #[derivative(Debug)] @@ -19,7 +18,7 @@ pub(crate) struct RenderState { viewport_scale: [f32; 2], viewport_offset: [f32; 2], viewport_texture: Option>, - overlays_texture: Option, + overlays_texture: Option>, ui_texture: Option, bind_group: Option, #[derivative(Debug = "ignore")] @@ -233,11 +232,17 @@ impl RenderState { return; }; let size = glam::UVec2::new(viewport_texture.width(), viewport_texture.height()); - let result = futures::executor::block_on(self.executor.render_vello_scene_to_target_texture(&scene, size, &Default::default(), &mut self.overlays_texture)); - if let Err(e) = result { - tracing::error!("Error rendering overlays: {:?}", e); - return; + let result = futures::executor::block_on(self.executor.render_vello_scene(&scene, size, &Default::default(), None)); + match result { + Ok(texture) => { + self.overlays_texture = Some(texture); + } + Err(e) => { + self.overlays_texture = None; + tracing::error!("Error rendering overlays: {:?}", e); + } } + self.update_bindgroup(); } @@ -314,11 +319,7 @@ impl RenderState { fn update_bindgroup(&mut self) { self.surface_outdated = true; let viewport_texture_view = self.viewport_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default()); - let overlays_texture_view = self - .overlays_texture - .as_ref() - .map(|target| Cow::Borrowed(target.view())) - .unwrap_or_else(|| Cow::Owned(self.transparent_texture.create_view(&wgpu::TextureViewDescriptor::default()))); + let overlays_texture_view = self.overlays_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default()); let ui_texture_view = self.ui_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = self.context.device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -330,7 +331,7 @@ impl RenderState { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView(overlays_texture_view.as_ref()), + resource: wgpu::BindingResource::TextureView(&overlays_texture_view), }, wgpu::BindGroupEntry { binding: 2, diff --git a/desktop/wrapper/src/lib.rs b/desktop/wrapper/src/lib.rs index 2f8c4c49f4..ad83ca4154 100644 --- a/desktop/wrapper/src/lib.rs +++ b/desktop/wrapper/src/lib.rs @@ -5,7 +5,6 @@ use message_dispatcher::DesktopWrapperMessageDispatcher; use messages::{DesktopFrontendMessage, DesktopWrapperMessage}; pub use graphite_editor::consts::{DOUBLE_CLICK_MILLISECONDS, FILE_EXTENSION}; -pub use wgpu_executor::TargetTexture; pub use wgpu_executor::WgpuContext; pub use wgpu_executor::WgpuContextBuilder; pub use wgpu_executor::WgpuExecutor; diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index aa1628eae4..3ce54d16b1 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -4,8 +4,8 @@ use crate::messages::portfolio::document::utility_types::network_interface::Node use crate::messages::prelude::*; use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; +use graphene_std::Color; use graphene_std::brush::brush_stroke::BrushStroke; -use graphene_std::color::Color; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 4252bd38ac..0fe3984254 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -417,6 +417,7 @@ impl NodeGraphExecutor { click_targets, clip_targets, vector_data, + backgrounds: _, } = render_output.metadata; // Run these update state messages immediately diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index ab3b8b4a58..e9877085fd 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -28,7 +28,7 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc>> = LazyLock::new(|| { - const SIZE: u32 = 16; - const HALF: u32 = 8; - - let mut data = vec![0_u8; (SIZE * SIZE * 4) as usize]; - for y in 0..SIZE { - for x in 0..SIZE { - let is_light = ((x / HALF) + (y / HALF)).is_multiple_of(2); - let value = if is_light { 0xff } else { 0xcc }; - let index = ((y * SIZE + x) * 4) as usize; - data[index] = value; - data[index + 1] = value; - data[index + 2] = value; - data[index + 3] = 0xff; - } - } - - Arc::new(data) -}); - -/// Creates a 16x16 tiling transparency checkerboard brush for Vello. -pub fn checkerboard_brush() -> peniko::Brush { - peniko::Brush::Image(peniko::ImageBrush { - image: peniko::ImageData { - data: peniko::Blob::new(CHECKERBOARD_IMAGE_DATA.clone()), - format: peniko::ImageFormat::Rgba8, - width: 16, - height: 16, - alpha_type: peniko::ImageAlphaType::Alpha, - }, - sampler: peniko::ImageSampler { - x_extend: peniko::Extend::Repeat, - y_extend: peniko::Extend::Repeat, - quality: peniko::ImageQuality::Low, // Nearest-neighbor sampling for crisp edges - alpha: 1., - }, - }) -} - #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] enum MaskType { @@ -125,15 +86,17 @@ impl SvgRender { pub fn format_svg(&mut self, bounds_min: DVec2, bounds_max: DVec2) { let (x, y) = bounds_min.into(); let (size_x, size_y) = (bounds_max - bounds_min).into(); - let defs = &self.svg_defs; - let svg_header = format!(r#"{defs}"#,); + let svg_header = format!( + r#"{defs}"#, + defs = &self.svg_defs + ); + self.svg_defs = String::new(); self.svg.insert(0, svg_header.into()); self.svg.push("".into()); } /// Wraps the SVG with `...`, which allows for rotation pub fn wrap_with_transform(&mut self, transform: DAffine2, size: Option) { - let defs = &self.svg_defs; let view_box = size .map(|size| format!("viewBox=\"0 0 {} {}\" width=\"{}\" height=\"{}\"", size.x, size.y, size.x, size.y)) .unwrap_or_default(); @@ -141,7 +104,11 @@ impl SvgRender { let matrix = format_transform_matrix(transform); let transform = if matrix.is_empty() { String::new() } else { format!(r#" transform="{matrix}""#) }; - let svg_header = format!(r#"{defs}"#); + let svg_header = format!( + r#"{defs}"#, + defs = &self.svg_defs + ); + self.svg_defs = String::new(); self.svg.insert(0, svg_header.into()); self.svg.push("".into()); } @@ -186,6 +153,34 @@ impl SvgRender { } } +pub struct SvgRenderOutput { + pub svg: String, + pub svg_defs: String, + pub image_data: HashMap>, u64>, +} + +impl From<&SvgRenderOutput> for SvgRender { + fn from(value: &SvgRenderOutput) -> Self { + Self { + svg: vec![value.svg.clone().into()], + svg_defs: value.svg_defs.clone(), + transform: DAffine2::IDENTITY, + image_data: value.image_data.clone(), + indent: 0, + } + } +} + +impl From for SvgRenderOutput { + fn from(val: SvgRender) -> Self { + Self { + svg: val.svg.to_svg_string(), + svg_defs: val.svg_defs, + image_data: val.image_data, + } + } +} + impl Default for SvgRender { fn default() -> Self { Self::new() @@ -215,8 +210,6 @@ pub struct RenderParams { pub scale: f64, pub render_output_type: RenderOutputType, pub thumbnail: bool, - /// Don't render the rectangle for an artboard to allow exporting with a transparent background. - pub hide_artboards: bool, /// Are we exporting pub for_export: bool, /// Are we generating a mask in this render pass? Used to see if fill should be multiplied with alpha. @@ -334,6 +327,7 @@ pub struct RenderMetadata { pub click_targets: HashMap>>, pub clip_targets: HashSet, pub vector_data: HashMap>, + pub backgrounds: Vec, } impl RenderMetadata { @@ -354,6 +348,7 @@ impl RenderMetadata { click_targets, clip_targets, vector_data, + backgrounds, } = self; upstream_footprints.extend(other.upstream_footprints.iter()); local_transforms.extend(other.local_transforms.iter()); @@ -361,9 +356,22 @@ impl RenderMetadata { click_targets.extend(other.click_targets.iter().map(|(k, v)| (*k, v.clone()))); clip_targets.extend(other.clip_targets.iter()); vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); + + // TODO: Find a better non O(n^2) way to merge backgrounds + for background in &other.backgrounds { + if !backgrounds.contains(background) { + backgrounds.push(background.clone()); + } + } } } +#[derive(Debug, Default, Clone, PartialEq, DynAny, serde::Serialize, serde::Deserialize)] +pub struct Background { + pub location: DVec2, + pub dimensions: DVec2, +} + // TODO: Rename to "Graphical" pub trait Render: BoundingBox + RenderComplexity { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams); @@ -526,42 +534,17 @@ impl Render for Table> { let width = dimensions.x.abs(); let height = dimensions.y.abs(); - // Rectangle for the artboard - if !render_params.hide_artboards { - // Transparency checkerboard behind the artboard background (viewport only) - let show_checkerboard = background.alpha() < 1. && render_params.to_canvas(); - if show_checkerboard && render_params.viewport_zoom > 0. { - let checker_id = format!("checkered-artboard-{}", generate_uuid()); - let cell_size = 8. / render_params.viewport_zoom; - let pattern_size = cell_size * 2.; - - // Anchor pattern at this artboard's top-left corner (x, y), not the document origin - let _ = write!( - &mut render.svg_defs, - r##""## - ); - - render.leaf_tag("rect", |attributes| { - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); + // Background + render.leaf_tag("rect", |attributes| { + attributes.push("fill", format!("#{}", background.to_rgb_hex_srgb_from_gamma())); + if background.a() < 1. { + attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); } - - // Background - render.leaf_tag("rect", |attributes| { - attributes.push("fill", format!("#{}", background.to_rgb_hex_srgb_from_gamma())); - if background.a() < 1. { - attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); - } - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - }); - } + attributes.push("x", x.to_string()); + attributes.push("y", y.to_string()); + attributes.push("width", width.to_string()); + attributes.push("height", height.to_string()); + }); // Artwork render.parent_tag( @@ -607,26 +590,12 @@ impl Render for Table> { let [a, b] = [location, location + dimensions]; let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); - // Render background - if !render_params.hide_artboards { - let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); - - // Transparency checkerboard behind the artboard background (viewport only) - let show_checkerboard = background.alpha() < 1. && render_params.to_canvas(); - if show_checkerboard && render_params.viewport_zoom > 0. { - // Anchor pattern at THIS artboard's top-left corner - // brush_transform is an image placement transform: it maps brush pixel coords → shape coords - // scale(1/zoom) sets each brush pixel to 1/zoom document units (constant CSS size after viewport transform) - // then_translate places the brush origin at the artboard corner - let brush_transform = kurbo::Affine::scale(1. / render_params.viewport_zoom).then_translate(kurbo::Vec2::new(rect.x0, rect.y0)); - scene.fill(peniko::Fill::NonZero, artboard_transform, &checkerboard_brush(), Some(brush_transform), &rect); - } + let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); - let color = peniko::Color::new([background.r(), background.g(), background.b(), background.a()]); - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); - scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); - scene.pop_layer(); - } + let color = peniko::Color::new([background.r(), background.g(), background.b(), background.a()]); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); + scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); + scene.pop_layer(); if clip { scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect); @@ -661,6 +630,8 @@ impl Render for Table> { } } + metadata.backgrounds.push(Background { location, dimensions }); + let mut child_footprint = footprint; child_footprint.transform *= DAffine2::from_translation(location); content.collect_metadata(metadata, child_footprint, None); diff --git a/node-graph/libraries/wgpu-executor/src/background/checker_rect.wgsl b/node-graph/libraries/wgpu-executor/src/background/checker_rect.wgsl new file mode 100644 index 0000000000..79f7163c22 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/background/checker_rect.wgsl @@ -0,0 +1,49 @@ +struct CompositeUniforms { + transform_x: vec2, + transform_y: vec2, + transform_translation: vec2, + rect_min: vec2, + rect_max: vec2, + viewport_size: vec2, + pattern_origin: vec2, + checker_size: f32, + _pad: f32, +}; + +@group(0) @binding(0) +var uniforms: CompositeUniforms; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) document_position: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let document_corners = array, 6>( + uniforms.rect_min, + vec2(uniforms.rect_max.x, uniforms.rect_min.y), + vec2(uniforms.rect_min.x, uniforms.rect_max.y), + vec2(uniforms.rect_min.x, uniforms.rect_max.y), + vec2(uniforms.rect_max.x, uniforms.rect_min.y), + uniforms.rect_max, + ); + let document_position = document_corners[vertex_index]; + + let transformed = uniforms.transform_x * document_position.x + uniforms.transform_y * document_position.y + uniforms.transform_translation; + let normalized = transformed / uniforms.viewport_size; + let clip = vec2(normalized.x * 2.0 - 1.0, 1.0 - normalized.y * 2.0); + + var out: VertexOutput; + out.position = vec4(clip, 0.0, 1.0); + out.document_position = document_position; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size); + let parity = i32(tile.x + tile.y) & 1; + let luminance = vec3(select(1.0, 0.8, parity == 1)); + return vec4(luminance, 1.0); +} diff --git a/node-graph/libraries/wgpu-executor/src/background/checker_viewport.wgsl b/node-graph/libraries/wgpu-executor/src/background/checker_viewport.wgsl new file mode 100644 index 0000000000..c583efcc38 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/background/checker_viewport.wgsl @@ -0,0 +1,45 @@ +struct CompositeUniforms { + transform_x: vec2, + transform_y: vec2, + transform_translation: vec2, + rect_min: vec2, + rect_max: vec2, + viewport_size: vec2, + pattern_origin: vec2, + checker_size: f32, + _pad: f32, +}; + +@group(0) @binding(0) +var uniforms: CompositeUniforms; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) document_position: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let positions = array, 3>( + vec2(-1.0, -1.0), + vec2(-1.0, 3.0), + vec2( 3.0, -1.0), + ); + let position = positions[vertex_index]; + + let screen_position = vec2((position.x + 1.0) * 0.5 * uniforms.viewport_size.x, (1.0 - position.y) * 0.5 * uniforms.viewport_size.y); + let document_position = uniforms.transform_x * screen_position.x + uniforms.transform_y * screen_position.y + uniforms.transform_translation; + + var out: VertexOutput; + out.position = vec4(position, 0.0, 1.0); + out.document_position = document_position; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size); + let parity = i32(tile.x + tile.y) & 1; + let luminance = vec3(select(1.0, 0.8, parity == 1)); + return vec4(luminance, 1.0); +} diff --git a/node-graph/libraries/wgpu-executor/src/background/fullscreen.wgsl b/node-graph/libraries/wgpu-executor/src/background/fullscreen.wgsl new file mode 100644 index 0000000000..fc760c927b --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/background/fullscreen.wgsl @@ -0,0 +1,35 @@ +@group(0) @binding(0) +var foreground_sampler: sampler; + +@group(0) @binding(1) +var foreground_texture: texture_2d; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) tex_coord: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let positions = array, 3>( + vec2(-1.0, -1.0), + vec2(-1.0, 3.0), + vec2( 3.0, -1.0), + ); + + let tex_coords = array, 3>( + vec2(0.0, 1.0), + vec2(0.0, -1.0), + vec2(2.0, 1.0), + ); + + var vertex_out: VertexOutput; + vertex_out.position = vec4(positions[vertex_index], 0.0, 1.0); + vertex_out.tex_coord = tex_coords[vertex_index]; + return vertex_out; +} + +@fragment +fn fs_main(fragment_in: VertexOutput) -> @location(0) vec4 { + return textureSample(foreground_texture, foreground_sampler, fragment_in.tex_coord); +} diff --git a/node-graph/libraries/wgpu-executor/src/background/mod.rs b/node-graph/libraries/wgpu-executor/src/background/mod.rs new file mode 100644 index 0000000000..dbc8fcf7ec --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/background/mod.rs @@ -0,0 +1,344 @@ +use glam::{Affine2, Vec2}; +use wgpu::util::DeviceExt; + +pub struct BackgroundCompositor { + checker_rect_pipeline: wgpu::RenderPipeline, + checker_viewport_pipeline: wgpu::RenderPipeline, + fullscreen_pipeline: wgpu::RenderPipeline, + checker_bind_group_layout: wgpu::BindGroupLayout, + fullscreen_bind_group_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, +} + +impl BackgroundCompositor { + pub fn new(device: &wgpu::Device) -> Self { + let format = wgpu::TextureFormat::Rgba8Unorm; + let checker_rect_shader = device.create_shader_module(wgpu::include_wgsl!("checker_rect.wgsl")); + let checker_viewport_shader = device.create_shader_module(wgpu::include_wgsl!("checker_viewport.wgsl")); + let fullscreen_shader = device.create_shader_module(wgpu::include_wgsl!("fullscreen.wgsl")); + + let checker_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("background_checker_bind_group_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let checker_rect_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("background_checker_rect_pipeline_layout"), + bind_group_layouts: &[&checker_bind_group_layout], + immediate_size: 0, + }); + + let checker_viewport_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("background_checker_viewport_pipeline_layout"), + bind_group_layouts: &[&checker_bind_group_layout], + immediate_size: 0, + }); + + let fullscreen_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("background_fullscreen_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + ], + }); + + let fullscreen_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("background_fullscreen_pipeline_layout"), + bind_group_layouts: &[&fullscreen_bind_group_layout], + immediate_size: 0, + }); + + let checker_rect_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("background_checker_rect_pipeline"), + layout: Some(&checker_rect_pipeline_layout), + vertex: wgpu::VertexState { + module: &checker_rect_shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &checker_rect_shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let checker_viewport_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("background_checker_viewport_pipeline"), + layout: Some(&checker_viewport_pipeline_layout), + vertex: wgpu::VertexState { + module: &checker_viewport_shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &checker_viewport_shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let fullscreen_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }; + + let fullscreen_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("background_fullscreen_pipeline"), + layout: Some(&fullscreen_pipeline_layout), + vertex: wgpu::VertexState { + module: &fullscreen_shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &fullscreen_shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(fullscreen_blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("background_fullscreen_sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + ..Default::default() + }); + + Self { + checker_rect_pipeline, + checker_viewport_pipeline, + fullscreen_pipeline, + checker_bind_group_layout, + fullscreen_bind_group_layout, + sampler, + } + } + + pub fn composite(&self, context: &crate::WgpuContext, foreground: &wgpu::Texture, output: &wgpu::Texture, backgrounds: &[rendering::Background], document_to_screen: Affine2, zoom: f32) { + if zoom <= 0.0 { + return; + } + + let device = &context.device; + let queue = &context.queue; + + let checker_size_doc = 8.0 / zoom; + let screen_to_document = document_to_screen.inverse(); + let viewport_size = output.size(); + let viewport_size = Vec2::new(viewport_size.width as f32, viewport_size.height as f32); + + let output_view = output.create_view(&wgpu::TextureViewDescriptor::default()); + let foreground_view = foreground.create_view(&wgpu::TextureViewDescriptor::default()); + + let checker_draws = if backgrounds.is_empty() { + vec![( + 3, + self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc)), + )] + } else { + backgrounds + .iter() + .filter_map(|background| { + let a = background.location.as_vec2(); + let b = (background.location + background.dimensions).as_vec2(); + + let min = a.min(b); + let max = a.max(b); + + if max.x <= min.x || max.y <= min.y { + return None; + } + + let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc); + Some((6, self.create_checker_bind_group(device, uniforms))) + }) + .collect() + }; + + let fullscreen_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("background_fullscreen_bind_group"), + layout: &self.fullscreen_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&foreground_view), + }, + ], + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("background_encoder") }); + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("background_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &output_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + if backgrounds.is_empty() { + pass.set_pipeline(&self.checker_viewport_pipeline); + for (vertex_count, bind_group) in &checker_draws { + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..*vertex_count, 0..1); + } + } else { + pass.set_pipeline(&self.checker_rect_pipeline); + for (vertex_count, bind_group) in &checker_draws { + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..*vertex_count, 0..1); + } + } + + pass.set_pipeline(&self.fullscreen_pipeline); + pass.set_bind_group(0, &fullscreen_bind_group, &[]); + pass.draw(0..3, 0..1); + } + + queue.submit(std::iter::once(encoder.finish())); + } + + fn create_checker_bind_group(&self, device: &wgpu::Device, uniforms: CompositeUniforms) -> wgpu::BindGroup { + let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("background_checker_uniforms"), + contents: bytemuck::bytes_of(&uniforms), + usage: wgpu::BufferUsages::UNIFORM, + }); + + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("background_checker_bind_group"), + layout: &self.checker_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buffer.as_entire_binding(), + }], + }) + } +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct CompositeUniforms { + transform_x: [f32; 2], + transform_y: [f32; 2], + transform_translation: [f32; 2], + rect_min: [f32; 2], + rect_max: [f32; 2], + viewport_size: [f32; 2], + pattern_origin: [f32; 2], + checker_size: f32, + _pad: f32, +} + +impl CompositeUniforms { + fn fullscreen(viewport_size: Vec2, screen_to_document: Affine2, checker_size_doc: f32) -> Self { + Self::new(screen_to_document, Vec2::ZERO, Vec2::ZERO, viewport_size, Vec2::ZERO, checker_size_doc) + } + + fn rect(rect_min: Vec2, rect_max: Vec2, document_to_screen: Affine2, viewport_size: Vec2, checker_size_doc: f32) -> Self { + Self::new(document_to_screen, rect_min, rect_max, viewport_size, rect_min, checker_size_doc) + } + + fn new(transform: Affine2, rect_min: Vec2, rect_max: Vec2, viewport_size: Vec2, pattern_origin: Vec2, checker_size: f32) -> Self { + Self { + transform_x: transform.matrix2.x_axis.to_array(), + transform_y: transform.matrix2.y_axis.to_array(), + transform_translation: transform.translation.to_array(), + rect_min: rect_min.to_array(), + rect_max: rect_max.to_array(), + viewport_size: viewport_size.to_array(), + pattern_origin: pattern_origin.to_array(), + checker_size, + _pad: 0., + } + } +} diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index 7cd413c1cb..9395ec1bf1 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -1,13 +1,20 @@ +mod background; // TODO: Think about where to place this. Likely inlined in the node. Requires refactor of wgpu pipline usage. mod context; mod resample; pub mod shader_runtime; +mod texture_cache; pub mod texture_conversion; +use std::sync::Arc; + +use crate::background::BackgroundCompositor; use crate::resample::Resampler; use crate::shader_runtime::ShaderRuntime; +use crate::texture_cache::TextureCache; use anyhow::Result; +use core_types::Color; use futures::lock::Mutex; -use glam::UVec2; +use glam::{Affine2, UVec2}; use graphene_application_io::{ApplicationIo, EditorApi}; use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene}; use wgpu::{Origin3d, TextureAspect}; @@ -18,11 +25,15 @@ pub use rendering::RenderContext; pub use wgpu::Backends as WgpuBackends; pub use wgpu::Features as WgpuFeatures; +const TEXTURE_CACHE_SIZE: u64 = 256 * 1024 * 1024; // 256 MiB + #[derive(dyn_any::DynAny)] pub struct WgpuExecutor { pub context: WgpuContext, + texture_cache: Mutex, vello_renderer: Mutex, resampler: Resampler, + background_compositor: BackgroundCompositor, pub shader_runtime: ShaderRuntime, } @@ -38,105 +49,55 @@ impl<'a, T: ApplicationIo> From<&'a EditorApi> for & } } -#[derive(Clone, Debug)] -pub struct TargetTexture { - texture: wgpu::Texture, - view: wgpu::TextureView, - size: UVec2, -} - -impl TargetTexture { - /// Creates a new TargetTexture with the specified size. - pub fn new(device: &wgpu::Device, size: UVec2) -> Self { - let size = size.max(UVec2::ONE); - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: None, - size: wgpu::Extent3d { - width: size.x, - height: size.y, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, - format: VELLO_SURFACE_FORMAT, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - - Self { texture, view, size } - } - - /// Ensures the texture has the specified size, creating a new one if needed. - /// This allows reusing the same texture across frames when the size hasn't changed. - pub fn ensure_size(&mut self, device: &wgpu::Device, size: UVec2) { - let size = size.max(UVec2::ONE); - if self.size == size { - return; +impl WgpuExecutor { + pub async fn render_vello_scene(&self, scene: &Scene, size: UVec2, context: &RenderContext, background: Option) -> Result> { + let texture = self.request_texture(size).await; + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let [r, g, b, a] = background.unwrap_or(Color::TRANSPARENT).to_rgba8(); + let render_params = RenderParams { + base_color: vello::peniko::Color::from_rgba8(r, g, b, a), + width: size.x, + height: size.y, + antialiasing_method: AaConfig::Msaa16, + }; + + { + let mut renderer = self.vello_renderer.lock().await; + for (image_brush, texture) in context.resource_overrides.iter() { + let texture_view = wgpu::TexelCopyTextureInfoBase { + texture: texture.clone(), + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }; + renderer.override_image(&image_brush.image, Some(texture_view)); + } + renderer.render_to_texture(&self.context.device, &self.context.queue, scene, &texture_view, &render_params)?; + for (image_brush, _) in context.resource_overrides.iter() { + renderer.override_image(&image_brush.image, None); + } } - *self = Self::new(device, size); - } - - /// Returns a reference to the texture view for rendering. - pub fn view(&self) -> &wgpu::TextureView { - &self.view + Ok(texture) } - /// Returns a reference to the underlying texture. - pub fn texture(&self) -> &wgpu::Texture { - &self.texture + pub async fn resample_texture(&self, source: &wgpu::Texture, size: UVec2, transform: &glam::DAffine2) -> Arc { + let out = self.request_texture(size).await; + self.resampler.resample(&self.context, source, transform, &out); + out } -} - -const VELLO_SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; - -impl WgpuExecutor { - pub async fn render_vello_scene_to_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext) -> Result { - let mut output = None; - self.render_vello_scene_to_target_texture(scene, size, context, &mut output).await?; - Ok(output.unwrap().texture) - } - - pub async fn render_vello_scene_to_target_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, output: &mut Option) -> Result<()> { - // Initialize (lazily) if this is the first call - if output.is_none() { - *output = Some(TargetTexture::new(&self.context.device, size)); - } - if let Some(target_texture) = output.as_mut() { - target_texture.ensure_size(&self.context.device, size); - - let render_params = RenderParams { - base_color: vello::peniko::Color::from_rgba8(0, 0, 0, 0), - width: size.x, - height: size.y, - antialiasing_method: AaConfig::Msaa16, - }; - - { - let mut renderer = self.vello_renderer.lock().await; - for (image_brush, texture) in context.resource_overrides.iter() { - let texture_view = wgpu::TexelCopyTextureInfoBase { - texture: texture.clone(), - mip_level: 0, - origin: Origin3d::ZERO, - aspect: TextureAspect::All, - }; - renderer.override_image(&image_brush.image, Some(texture_view)); - } - renderer.render_to_texture(&self.context.device, &self.context.queue, scene, target_texture.view(), &render_params)?; - for (image_brush, _) in context.resource_overrides.iter() { - renderer.override_image(&image_brush.image, None); - } - } - } - Ok(()) + pub async fn composite_background(&self, foreground: &wgpu::Texture, backgrounds: &[rendering::Background], document_to_screen: Affine2, zoom: f32) -> Arc { + let size = foreground.size(); + let output = self.request_texture(UVec2::new(size.width, size.height)).await; + self.background_compositor.composite(&self.context, foreground, &output, backgrounds, document_to_screen, zoom); + output } - pub fn resample_texture(&self, source: &wgpu::Texture, target_size: UVec2, transform: &glam::DAffine2) -> wgpu::Texture { - self.resampler.resample(&self.context, source, target_size, transform) + pub async fn request_texture(&self, size: UVec2) -> Arc { + self.texture_cache.lock().await.request_texture(&self.context.device, size) } } @@ -158,13 +119,19 @@ impl WgpuExecutor { .map_err(|e| anyhow::anyhow!("Failed to create Vello renderer: {:?}", e)) .ok()?; + let texture_cache = TextureCache::new(TEXTURE_CACHE_SIZE); + let resampler = Resampler::new(&context.device); + let background_compositor = BackgroundCompositor::new(&context.device); + let shader_runtime = ShaderRuntime::new(&context); Some(Self { - shader_runtime: ShaderRuntime::new(&context), context, - resampler, + texture_cache: texture_cache.into(), vello_renderer: vello_renderer.into(), + resampler, + background_compositor, + shader_runtime, }) } } diff --git a/node-graph/libraries/wgpu-executor/src/resample.rs b/node-graph/libraries/wgpu-executor/src/resample.rs index e91cf49e5a..fda60d8d5e 100644 --- a/node-graph/libraries/wgpu-executor/src/resample.rs +++ b/node-graph/libraries/wgpu-executor/src/resample.rs @@ -1,5 +1,5 @@ use crate::WgpuContext; -use glam::{DAffine2, UVec2, Vec2}; +use glam::{DAffine2, Vec2}; pub struct Resampler { pipeline: wgpu::RenderPipeline, @@ -74,29 +74,11 @@ impl Resampler { Resampler { pipeline, bind_group_layout } } - pub fn resample(&self, context: &WgpuContext, source: &wgpu::Texture, target_size: UVec2, transform: &DAffine2) -> wgpu::Texture { - let device = &context.device; - let queue = &context.queue; - - let output_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("resample_output"), - size: wgpu::Extent3d { - width: target_size.x.max(1), - height: target_size.y.max(1), - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - + pub fn resample(&self, context: &WgpuContext, source: &wgpu::Texture, transform: &DAffine2, output: &wgpu::Texture) { let source_view = source.create_view(&wgpu::TextureViewDescriptor::default()); - let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let output_view = output.create_view(&wgpu::TextureViewDescriptor::default()); - let params_buffer = device.create_buffer(&wgpu::BufferDescriptor { + let params_buffer = context.device.create_buffer(&wgpu::BufferDescriptor { label: Some("resample_params"), size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, @@ -104,9 +86,9 @@ impl Resampler { }); let params_data = [transform.matrix2.x_axis.as_vec2(), transform.matrix2.y_axis.as_vec2(), transform.translation.as_vec2(), Vec2::ZERO]; - queue.write_buffer(¶ms_buffer, 0, bytemuck::cast_slice(¶ms_data)); + context.queue.write_buffer(¶ms_buffer, 0, bytemuck::cast_slice(¶ms_data)); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + let bind_group = context.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("resample_bind_group"), layout: &self.bind_group_layout, entries: &[ @@ -121,7 +103,7 @@ impl Resampler { ], }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("resample_encoder") }); + let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("resample_encoder") }); { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -143,8 +125,6 @@ impl Resampler { render_pass.draw(0..3, 0..1); } - queue.submit([encoder.finish()]); - - output_texture + context.queue.submit([encoder.finish()]); } } diff --git a/node-graph/libraries/wgpu-executor/src/texture_cache.rs b/node-graph/libraries/wgpu-executor/src/texture_cache.rs new file mode 100644 index 0000000000..4e3fad2178 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/texture_cache.rs @@ -0,0 +1,95 @@ +use glam::UVec2; +use std::collections::VecDeque; +use std::sync::Arc; + +pub(crate) struct TextureCache { + /// Always sorted oldest-first by insertion/last-use order. + textures: VecDeque>, + max_free_bytes: u64, +} + +impl TextureCache { + pub fn new(max_free_bytes: u64) -> Self { + Self { + textures: VecDeque::new(), + max_free_bytes, + } + } + + pub fn request_texture(&mut self, device: &wgpu::Device, size: UVec2) -> Arc { + let size = size.max(UVec2::ONE); + + if let Some(pos) = self + .textures + .iter() + .position(|texture| UVec2::new(texture.width(), texture.height()) == size && Arc::strong_count(texture) == 1) + { + let entry = self.textures.remove(pos).unwrap(); + let texture = entry.clone(); + self.textures.push_back(entry); + return texture; + } + + let incoming_bytes = size.x as u64 * size.y as u64 * 4; + self.evict_until_fits(incoming_bytes); + + let texture = Arc::new(device.create_texture(&wgpu::TextureDescriptor { + label: Some(&format!("cached_texture_{}x{}", size.x, size.y)), + size: wgpu::Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + })); + + self.textures.push_back(texture.clone()); + + texture + } + + fn total_free_bytes(&self) -> u64 { + self.textures + .iter() + .filter(|texture| Arc::strong_count(texture) == 1) + .map(|texture| texture.memory_size_estimate()) + .sum() + } + + fn evict_until_fits(&mut self, incoming_bytes: u64) { + let mut free_bytes = self.total_free_bytes(); + let max_free_bytes = self.max_free_bytes; + + if free_bytes + incoming_bytes <= max_free_bytes { + return; + } + + self.textures.retain(|texture| { + if free_bytes + incoming_bytes <= max_free_bytes { + return true; + } + if Arc::strong_count(texture) == 1 { + free_bytes -= texture.memory_size_estimate(); + texture.destroy(); + false + } else { + true + } + }); + } +} + +trait TextureMemoryCostEstimateExt { + fn memory_size_estimate(&self) -> u64; +} + +impl TextureMemoryCostEstimateExt for wgpu::Texture { + fn memory_size_estimate(&self) -> u64 { + self.width() as u64 * self.height() as u64 * 4 + } +} diff --git a/node-graph/nodes/gstd/src/pixel_preview.rs b/node-graph/nodes/gstd/src/pixel_preview.rs index 266ff7de93..d27b0cb8a7 100644 --- a/node-graph/nodes/gstd/src/pixel_preview.rs +++ b/node-graph/nodes/gstd/src/pixel_preview.rs @@ -59,7 +59,7 @@ pub async fn pixel_preview<'a: 'n>( let transform = DAffine2::from_translation(-upstream_min) * footprint.transform.inverse() * DAffine2::from_scale(logical_resolution); let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); - let resampled = exec.resample_texture(source_texture.as_ref(), physical_resolution, &transform); + let resampled = exec.resample_texture(source_texture.as_ref(), physical_resolution, &transform).await; result.data = RenderOutputType::Texture(resampled.into()); diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 434c2c8ebb..dd06fbab2f 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -6,7 +6,7 @@ use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, E use glam::{DAffine2, DVec2, IVec2, UVec2}; use graph_craft::application_io::PlatformEditorApi; use graph_craft::document::value::RenderOutput; -use graphene_application_io::ApplicationIo; +use graphene_application_io::{ApplicationIo, ImageTexture}; use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams}; use std::collections::HashSet; use std::hash::Hash; @@ -26,7 +26,7 @@ pub struct TileCoord { #[derive(Debug, Clone)] pub struct CachedRegion { - pub texture: wgpu::Texture, + pub texture: ImageTexture, pub texture_size: UVec2, pub tiles: Vec, pub metadata: rendering::RenderMetadata, @@ -41,7 +41,6 @@ pub struct CacheKey { pub device_scale: u64, pub zoom: u64, pub rotation: u64, - pub hide_artboards: bool, pub for_export: bool, pub for_mask: bool, pub thumbnail: bool, @@ -60,7 +59,6 @@ impl CacheKey { device_scale: f64, zoom: f64, rotation: f64, - hide_artboards: bool, for_export: bool, for_mask: bool, thumbnail: bool, @@ -87,7 +85,6 @@ impl CacheKey { device_scale: device_scale.to_bits(), zoom: zoom.to_bits(), rotation: quantized_rotation.to_bits(), - hide_artboards, for_export, for_mask, thumbnail, @@ -100,23 +97,27 @@ impl CacheKey { } } +#[derive(Clone, Default, dyn_any::DynAny, Debug)] +pub struct TileCache(Arc>); + +impl TileCache { + pub fn query(&self, viewport_bounds: &AxisAlignedBbox, cache_key: &CacheKey, max_region_area: u32) -> CacheQuery { + self.0.lock().unwrap().query(viewport_bounds, cache_key, max_region_area) + } + + pub fn store_regions(&self, regions: Vec) { + self.0.lock().unwrap().store_regions(regions); + } +} + #[derive(Default, Debug)] struct TileCacheImpl { regions: Vec, timestamp: u64, total_memory: usize, cache_key: CacheKey, - texture_cache_resolution: UVec2, - /// Pool of textures of the same size: `texture_cache_resolution`. - /// Reusing textures reduces the wgpu allocation pressure, - /// which is a problem on web since we have to wait for - /// the browser to garbage collect unused textures, eating up memory. - texture_cache: Vec>, } -#[derive(Clone, Default, dyn_any::DynAny, Debug)] -pub struct TileCache(Arc>); - #[derive(Debug, Clone)] pub struct RenderRegion { pub tiles: Vec, @@ -205,7 +206,6 @@ impl TileCacheImpl { while self.total_memory > MAX_CACHE_MEMORY_BYTES && !self.regions.is_empty() { if let Some((oldest_idx, _)) = self.regions.iter().enumerate().min_by_key(|(_, r)| r.last_access) { let removed = self.regions.remove(oldest_idx); - removed.texture.destroy(); self.total_memory = self.total_memory.saturating_sub(removed.memory_size); } else { break; @@ -214,56 +214,9 @@ impl TileCacheImpl { } fn invalidate_all(&mut self) { - for region in &self.regions { - region.texture.destroy(); - } self.regions.clear(); self.total_memory = 0; } - - pub fn request_texture(&mut self, size: UVec2, device: &wgpu::Device) -> Arc { - if self.texture_cache_resolution != size { - self.texture_cache_resolution = size; - self.texture_cache.clear(); - } - self.texture_cache.truncate(5); - for texture in &self.texture_cache { - if Arc::strong_count(texture) == 1 { - return Arc::clone(texture); - } - } - let texture = Arc::new(device.create_texture(&wgpu::TextureDescriptor { - label: Some("viewport_output"), - size: wgpu::Extent3d { - width: size.x, - height: size.y, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - })); - self.texture_cache.push(texture.clone()); - - texture - } -} - -impl TileCache { - pub fn query(&self, viewport_bounds: &AxisAlignedBbox, cache_key: &CacheKey, max_region_area: u32) -> CacheQuery { - self.0.lock().unwrap().query(viewport_bounds, cache_key, max_region_area) - } - - pub fn store_regions(&self, regions: Vec) { - self.0.lock().unwrap().store_regions(regions); - } - - pub fn request_texture(&self, size: UVec2, device: &wgpu::Device) -> Arc { - self.0.lock().unwrap().request_texture(size, device) - } } fn group_into_regions(tiles: &[TileCoord], max_region_area: u32) -> Vec { @@ -411,7 +364,6 @@ pub async fn render_output_cache<'a: 'n>( device_scale, zoom, rotation, - render_params.hide_artboards, render_params.for_export, render_params.for_mask, render_params.thumbnail, @@ -454,10 +406,9 @@ pub async fn render_output_cache<'a: 'n>( let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); - let device = &exec.context.device; - let output_texture = tile_cache.request_texture(physical_resolution, device); + let output_texture = exec.request_texture(physical_resolution).await; - let combined_metadata = composite_cached_regions(&all_regions, output_texture.as_ref(), &device_origin_offset, &footprint.transform, exec); + let combined_metadata = composite_cached_regions(&all_regions, &output_texture, &device_origin_offset, &footprint.transform, exec); RenderOutput { data: RenderOutputType::Texture(output_texture.into()), @@ -496,7 +447,7 @@ where let region_ctx = OwnedContextImpl::from(ctx).with_footprint(region_footprint).with_vararg(Box::new(region_params)).into_context(); let mut result = render_fn(region_ctx).await; - let RenderOutputType::Texture(rendered_texture) = result.data else { + let RenderOutputType::Texture(texture) = result.data else { unreachable!("render_missing_region: expected texture output from Vello render"); }; @@ -506,7 +457,7 @@ where let memory_size = (region_pixel_size.x * region_pixel_size.y) as usize * BYTES_PER_PIXEL; CachedRegion { - texture: rendered_texture.as_ref().clone(), + texture, texture_size: region_pixel_size, tiles: region.tiles.clone(), metadata: result.metadata, @@ -552,7 +503,7 @@ fn composite_cached_regions( if width > 0 && height > 0 { encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { - texture: ®ion.texture, + texture: region.texture.as_ref(), mip_level: 0, origin: wgpu::Origin3d { x: src_x, y: src_y, z: 0 }, aspect: wgpu::TextureAspect::All, diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index cd7454bd59..7d3a291f6c 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -7,12 +7,10 @@ pub use graph_craft::application_io::*; use graph_craft::document::value::RenderOutput; pub use graph_craft::document::value::RenderOutputType; use graphene_application_io::{ApplicationIo, ExportFormat, RenderConfig}; -use graphic_types::raster_types::Image; use graphic_types::raster_types::{CPU, Raster}; use graphic_types::{Graphic, Vector}; -use rendering::{Render, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, checkerboard_brush}; -use rendering::{RenderMetadata, SvgSegment}; -use std::collections::HashMap; +use rendering::{Render, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, SvgRender, SvgRenderOutput}; +use std::fmt::Write; use std::sync::Arc; use vector_types::GradientStops; use wgpu_executor::RenderContext; @@ -20,19 +18,15 @@ use wgpu_executor::RenderContext; // Re-export render_output_cache from render_cache module pub use crate::render_cache::render_output_cache; -/// List of (canvas id, image data) pairs for embedding images as canvases in the final SVG string. -type ImageData = HashMap>, u64>; - #[derive(Clone, dyn_any::DynAny)] pub enum RenderIntermediateType { Vello(Arc<(vello::Scene, RenderContext)>), - Svg(Arc<(String, ImageData, String)>), + Svg(Arc), } #[derive(Clone, dyn_any::DynAny)] pub struct RenderIntermediate { pub(crate) ty: RenderIntermediateType, pub(crate) metadata: RenderMetadata, - pub(crate) contains_artboard: bool, } #[node_macro::node(category(""))] @@ -60,8 +54,6 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + let footprint = Footprint::default(); let mut metadata = RenderMetadata::default(); data.collect_metadata(&mut metadata, footprint, None); - let contains_artboard = data.contains_artboard(); - match &render_params.render_output_type { RenderOutputTypeRequest::Vello => { let mut scene = vello::Scene::new(); @@ -72,7 +64,6 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + RenderIntermediate { ty: RenderIntermediateType::Vello(Arc::new((scene, context))), metadata, - contains_artboard, } } RenderOutputTypeRequest::Svg => { @@ -81,49 +72,13 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + data.render_svg(&mut render, render_params); RenderIntermediate { - ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), + ty: RenderIntermediateType::Svg(Arc::new(render.into())), metadata, - contains_artboard, } } } } -#[node_macro::node(category(""))] -async fn create_context<'a: 'n>( - // Context injections are defined in the wrap_network_in_scope function - render_config: RenderConfig, - data: impl Node, Output = RenderOutput>, -) -> RenderOutput { - let footprint = render_config.viewport; - - let render_output_type = match render_config.export_format { - ExportFormat::Svg => RenderOutputTypeRequest::Svg, - ExportFormat::Raster => RenderOutputTypeRequest::Vello, - }; - - let render_params = RenderParams { - render_mode: render_config.render_mode, - hide_artboards: false, - for_export: render_config.for_export, - render_output_type, - footprint: Footprint::default(), - scale: render_config.scale, - viewport_zoom: footprint.scale_magnitudes().x, - ..Default::default() - }; - - let ctx = OwnedContextImpl::default() - .with_footprint(footprint) - .with_real_time(render_config.time.time) - .with_animation_time(render_config.time.animation_time.as_secs_f64()) - .with_pointer_position(render_config.pointer) - .with_vararg(Box::new(render_params)) - .into_context(); - - data.eval(ctx).await -} - #[node_macro::node(category(""))] async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderIntermediate) -> RenderOutput { let footprint = ctx.footprint(); @@ -134,101 +89,39 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito .expect("Downcasting render params yielded invalid type"); let mut render_params = render_params.clone(); render_params.footprint = *footprint; - let render_params = &render_params; - - let scale = render_params.scale; - let physical_resolution = render_params.footprint.resolution; - let logical_resolution = render_params.footprint.resolution.as_dvec2() / scale; - let RenderIntermediate { ty, mut metadata, contains_artboard } = data; + let RenderIntermediate { ty, mut metadata } = data; metadata.apply_transform(footprint.transform); - let data = match (render_params.render_output_type, &ty) { - (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(svg_data)) => { - let mut rendering = SvgRender::new(); + let data = match (render_params.render_output_type, ty) { + (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(data)) => { + let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale; - // Infinite canvas background (no artboards) - if !contains_artboard && !render_params.hide_artboards { - let show_checkerboard = render_params.to_canvas(); - if show_checkerboard && render_params.viewport_zoom > 0. { - // Checkerboard pattern anchored at the document origin, tiling at 8x8 viewport pixels - let checker_id = format!("checkered-canvas-{}", generate_uuid()); - let cell_size = 8. / render_params.viewport_zoom; - let pattern_size = cell_size * 2.; + let mut render = SvgRender::from(data.as_ref()); + render.wrap_with_transform(render_params.footprint.transform, Some(logical_resolution)); - // Compute the axis-aligned bounding box of all four viewport corners in document space, - // which is necessary when the view is rotated so the rect fully covers the visible area - let inverse_transform = footprint.transform.inverse(); - let corners = [ - inverse_transform.transform_point2(glam::DVec2::ZERO), - inverse_transform.transform_point2(glam::DVec2::new(logical_resolution.x, 0.)), - inverse_transform.transform_point2(glam::DVec2::new(0., logical_resolution.y)), - inverse_transform.transform_point2(logical_resolution), - ]; - let bb_min = corners.iter().fold(glam::DVec2::MAX, |acc, &c| acc.min(c)); - let bb_max = corners.iter().fold(glam::DVec2::MIN, |acc, &c| acc.max(c)); - - rendering.leaf_tag("rect", |attributes| { - attributes.push("x", bb_min.x.to_string()); - attributes.push("y", bb_min.y.to_string()); - attributes.push("width", (bb_max.x - bb_min.x).to_string()); - attributes.push("height", (bb_max.y - bb_min.y).to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); + let output = SvgRenderOutput::from(render); + assert!(output.svg_defs.is_empty()); - // Pattern defs will be appended after the intermediate defs are copied below - rendering.svg_defs = format!( - r##""##, - ); - } - } - - let existing_defs = rendering.svg_defs.clone(); - rendering.svg.push(SvgSegment::from(svg_data.0.clone())); - rendering.image_data = svg_data.1.clone(); - rendering.svg_defs = format!("{existing_defs}{}", svg_data.2); - - rendering.wrap_with_transform(footprint.transform, Some(logical_resolution)); RenderOutputType::Svg { - svg: rendering.svg.to_svg_string(), - image_data: rendering.image_data.into_iter().map(|(image, id)| (id, image.0)).collect(), + svg: output.svg, + image_data: output.image_data.into_iter().map(|(image, id)| (id, image.0)).collect(), } } - (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(vello_data)) => { + (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(data)) => { let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() else { unreachable!("Attempted to render with Vello when no GPU executor is available"); }; - let (child, context) = Arc::as_ref(vello_data); + let (scene, context) = data.as_ref(); + let scale = render_params.scale; + let physical_resolution = render_params.footprint.resolution; let scale_transform = glam::DAffine2::from_scale(glam::DVec2::splat(scale)); - let footprint_transform = scale_transform * footprint.transform; + let footprint_transform = scale_transform * render_params.footprint.transform; let footprint_transform_vello = vello::kurbo::Affine::new(footprint_transform.to_cols_array()); - let mut scene = vello::Scene::new(); - - // Infinite canvas checkerboard (when no artboards are present) - let show_checkerboard = !render_params.for_export && !contains_artboard && !render_params.hide_artboards; - if show_checkerboard && scale > 0. && render_params.viewport_zoom > 0. { - // Compute the axis-aligned bounding box of all four viewport corners in document space, - // which is necessary so the rect fully covers the visible area when the canvas is tilted - let inverse_footprint = footprint_transform.inverse(); - let corners = [ - inverse_footprint.transform_point2(glam::DVec2::ZERO), - inverse_footprint.transform_point2(glam::DVec2::new(physical_resolution.x as f64, 0.)), - inverse_footprint.transform_point2(glam::DVec2::new(0., physical_resolution.y as f64)), - inverse_footprint.transform_point2(physical_resolution.as_dvec2()), - ]; - let bb_min = corners.iter().fold(glam::DVec2::MAX, |acc, &c| acc.min(c)); - let bb_max = corners.iter().fold(glam::DVec2::MIN, |acc, &c| acc.max(c)); - let doc_rect = vello::kurbo::Rect::new(bb_min.x, bb_min.y, bb_max.x, bb_max.y); - - // Draw in document space, transformed to screen by footprint_transform (includes rotation) - // Brush maps each pixel to 1/viewport_zoom document units, giving constant 8px cells - let brush_transform = vello::kurbo::Affine::scale(1. / render_params.viewport_zoom); - scene.fill(vello::peniko::Fill::NonZero, footprint_transform_vello, &checkerboard_brush(), Some(brush_transform), &doc_rect); - } - - scene.append(child, Some(footprint_transform_vello)); + let mut transformed_scene = vello::Scene::new(); + transformed_scene.append(scene, Some(footprint_transform_vello)); // We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport. // See for more detail. @@ -239,17 +132,154 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito // vertices, dropping the gradient and tanking performance. `!is_finite()` also covers NaN as a guard against future // code paths where `matrix[0]` could land on `0 * INFINITY`. let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64); - for transform in scene.encoding_mut().transforms.iter_mut() { + for transform in transformed_scene.encoding_mut().transforms.iter_mut() { if !transform.matrix[0].is_finite() { *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); } } - let texture = Arc::new(exec.render_vello_scene_to_texture(&scene, physical_resolution, context).await.expect("Failed to render Vello scene")); - + let texture = exec + .render_vello_scene(&transformed_scene, physical_resolution, context, None) + .await + .expect("Failed to render Vello scene"); RenderOutputType::Texture(texture.into()) } _ => unreachable!("Render node did not receive its requested data type"), }; + RenderOutput { data, metadata } } + +#[node_macro::node(category(""))] +async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderOutput) -> RenderOutput { + let footprint = ctx.footprint(); + let render_params = ctx + .vararg(0) + .expect("Did not find var args") + .downcast_ref::() + .expect("Downcasting render params yielded invalid type"); + + if !render_params.to_canvas() { + return data; + } + + let RenderOutput { data: foreground_data, metadata } = data; + let mut render_params = render_params.clone(); + render_params.footprint = *footprint; + + let data = match foreground_data { + RenderOutputType::Texture(foreground_texture) => { + if let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() { + let doc_to_screen = (glam::DAffine2::from_scale(glam::DVec2::splat(render_params.scale)) * render_params.footprint.transform).as_affine2(); + let blended = exec + .composite_background(foreground_texture.as_ref(), &metadata.backgrounds, doc_to_screen, render_params.viewport_zoom as f32) + .await; + + RenderOutputType::Texture(blended.into()) + } else { + RenderOutputType::Texture(foreground_texture) + } + } + RenderOutputType::Svg { + svg: foreground_svg, + image_data: foreground_images, + } => { + let mut render = SvgRender::new(); + + if render_params.viewport_zoom > 0. { + let draw_checkerboard = |render: &mut SvgRender, rect: vello::kurbo::Rect, pattern_origin: glam::DVec2, checker_id_prefix: &str| { + let checker_id = format!("{checker_id_prefix}-{}", generate_uuid()); + let cell_size = 8. / render_params.viewport_zoom; + let pattern_size = cell_size * 2.; + + write!( + &mut render.svg_defs, + r##""##, + pattern_origin.x, + pattern_origin.y, + ) + .unwrap(); + + render.leaf_tag("rect", |attributes| { + attributes.push("x", rect.x0.to_string()); + attributes.push("y", rect.y0.to_string()); + attributes.push("width", rect.width().to_string()); + attributes.push("height", rect.height().to_string()); + attributes.push("fill", format!("url(#{checker_id})")); + }); + }; + + if metadata.backgrounds.is_empty() { + if render_params.scale > 0. { + let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale; + let logical_footprint = Footprint { + resolution: logical_resolution.round().as_uvec2().max(glam::UVec2::ONE), + ..render_params.footprint + }; + let bounds = logical_footprint.viewport_bounds_in_local_space(); + let min = bounds.start.floor(); + let max = bounds.end.ceil(); + + if min.is_finite() && max.is_finite() { + let rect = vello::kurbo::Rect::new(min.x, min.y, max.x, max.y); + draw_checkerboard(&mut render, rect, glam::DVec2::ZERO, "checkered-viewport"); + } + } + } else { + for background in &metadata.backgrounds { + let [a, b] = [background.location, background.location + background.dimensions]; + let rect = vello::kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); + draw_checkerboard(&mut render, rect, glam::DVec2::new(rect.x0, rect.y0), "checkered-artboard"); + } + } + } + + let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale; + render.wrap_with_transform(render_params.footprint.transform, Some(logical_resolution)); + + let background = SvgRenderOutput::from(render); + assert!(background.svg_defs.is_empty()); + + let svg = format!("{}{}", background.svg, foreground_svg); + let image_data = foreground_images; + + RenderOutputType::Svg { svg, image_data } + } + _ => unreachable!("Render background node received unsupported render output type"), + }; + + RenderOutput { data, metadata } +} + +#[node_macro::node(category(""))] +async fn create_context<'a: 'n>( + // Context injections are defined in the wrap_network_in_scope function + render_config: RenderConfig, + data: impl Node, Output = RenderOutput>, +) -> RenderOutput { + let footprint = render_config.viewport; + + let render_output_type = match render_config.export_format { + ExportFormat::Svg => RenderOutputTypeRequest::Svg, + ExportFormat::Raster => RenderOutputTypeRequest::Vello, + }; + + let render_params = RenderParams { + render_mode: render_config.render_mode, + for_export: render_config.for_export, + render_output_type, + scale: render_config.scale, + viewport_zoom: footprint.scale_magnitudes().x, + ..Default::default() + }; + + let ctx = OwnedContextImpl::default() + .with_footprint(footprint) + .with_real_time(render_config.time.time) + .with_animation_time(render_config.time.animation_time.as_secs_f64()) + .with_pointer_position(render_config.pointer) + .with_vararg(Box::new(render_params)) + .into_context(); + + data.eval(ctx).await +}