From c3890329d0815946352312b4fa40d940310a1ffc Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Fri, 8 Mar 2024 23:33:29 -0500 Subject: [PATCH 01/34] build: initial commit --- .github/copyright.sh | 37 + .github/dependabot.yml | 11 + .github/workflows/ci.yml | 172 +++++ .gitignore | 30 + Cargo.toml | 36 + LICENSE-APACHE | 176 +++++ LICENSE-MIT | 19 + LICENSE-MPL | 373 +++++++++ README.md | 50 ++ examples/assets/Ghostscript_Tiger.svg | 142 ++++ examples/assets/downloads/.tracked | 1 + LICENSE => examples/assets/roboto/LICENSE.txt | 1 + examples/assets/roboto/Roboto-Regular.ttf | Bin 0 -> 168260 bytes examples/run_wasm/Cargo.toml | 9 + examples/run_wasm/src/main.rs | 25 + examples/scenes/Cargo.toml | 22 + examples/scenes/src/download.rs | 224 ++++++ .../scenes/src/download/default_downloads.rs | 106 +++ examples/scenes/src/lib.rs | 126 ++++ examples/scenes/src/simple_text.rs | 142 ++++ examples/scenes/src/svg.rs | 184 +++++ examples/scenes/src/test_scenes.rs | 73 ++ examples/with_winit/Cargo.toml | 45 ++ examples/with_winit/README.md | 25 + examples/with_winit/src/hot_reload.rs | 32 + examples/with_winit/src/lib.rs | 713 ++++++++++++++++++ examples/with_winit/src/main.rs | 8 + examples/with_winit/src/multi_touch.rs | 312 ++++++++ examples/with_winit/src/stats.rs | 479 ++++++++++++ rustfmt.toml | 4 + src/geom.rs | 58 ++ src/lib.rs | 551 ++++++++++++++ tests/.gitkeep | 0 33 files changed, 4186 insertions(+) create mode 100755 .github/copyright.sh create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 LICENSE-MPL create mode 100644 README.md create mode 100644 examples/assets/Ghostscript_Tiger.svg create mode 100644 examples/assets/downloads/.tracked rename LICENSE => examples/assets/roboto/LICENSE.txt (99%) create mode 100644 examples/assets/roboto/Roboto-Regular.ttf create mode 100644 examples/run_wasm/Cargo.toml create mode 100644 examples/run_wasm/src/main.rs create mode 100644 examples/scenes/Cargo.toml create mode 100644 examples/scenes/src/download.rs create mode 100644 examples/scenes/src/download/default_downloads.rs create mode 100644 examples/scenes/src/lib.rs create mode 100644 examples/scenes/src/simple_text.rs create mode 100644 examples/scenes/src/svg.rs create mode 100644 examples/scenes/src/test_scenes.rs create mode 100644 examples/with_winit/Cargo.toml create mode 100644 examples/with_winit/README.md create mode 100644 examples/with_winit/src/hot_reload.rs create mode 100644 examples/with_winit/src/lib.rs create mode 100644 examples/with_winit/src/main.rs create mode 100644 examples/with_winit/src/multi_touch.rs create mode 100644 examples/with_winit/src/stats.rs create mode 100644 rustfmt.toml create mode 100644 src/geom.rs create mode 100644 src/lib.rs create mode 100644 tests/.gitkeep diff --git a/.github/copyright.sh b/.github/copyright.sh new file mode 100755 index 0000000..d1eb406 --- /dev/null +++ b/.github/copyright.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# If there are new files with headers that can't match the conditions here, +# then the files can be ignored by an additional glob argument via the -g flag. +# For example: +# -g "!src/special_file.rs" +# -g "!src/special_directory" + +# Check all the standard Rust source files +output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Vello Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT$\n\n" --files-without-match --multiline -g "*.rs" -g "!{shader,src/cpu_shader}" -g "!integrations/vello_svg/src/geom.rs" .) + +if [ -n "$output" ]; then + echo -e "The following files lack the correct copyright header:\n" + echo $output + echo -e "\n\nPlease add the following header:\n" + echo "// Copyright $(date +%Y) the Vello Authors" + echo "// SPDX-License-Identifier: Apache-2.0 OR MIT" + echo -e "\n... rest of the file ...\n" + exit 1 +fi + +# Check all the shaders, both WGSL and CPU shaders in Rust, as they also have Unlicense +output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Vello Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT OR Unlicense$\n\n" --files-without-match --multiline -g "{shader,src/cpu_shader}/**/*.{rs,wgsl}" .) + +if [ -n "$output" ]; then + echo -e "The following shader files lack the correct copyright header:\n" + echo $output + echo -e "\n\nPlease add the following header:\n" + echo "// Copyright $(date +%Y) the Vello Authors" + echo "// SPDX-License-Identifier: Apache-2.0 OR MIT OR Unlicense" + echo -e "\n... rest of the file ...\n" + exit 1 +fi + +echo "All files have correct copyright headers." +exit 0 + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f81ed0f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + +- package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..102cf58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,172 @@ +env: + # We aim to always test with the latest stable Rust toolchain, however we pin to a specific + # version like 1.70. Note that we only specify MAJOR.MINOR and not PATCH so that bugfixes still + # come automatically. If the version specified here is no longer the latest stable version, + # then please feel free to submit a PR that adjusts it along with the potential clippy fixes. + RUST_STABLE_VER: "1.76" # In quotes because otherwise (e.g.) 1.70 would be interpreted as 1.7 + + +# Rationale +# +# We don't run clippy with --all-targets because then even --lib and --bins are compiled with +# dev dependencies enabled, which does not match how they would be compiled by users. +# A dev dependency might enable a feature of a regular dependency that we need, but testing +# with --all-targets would not catch that. Thus we split --lib & --bins into a separate step. + +name: CI + +on: + pull_request: + merge_group: + +jobs: + rustfmt: + runs-on: ubuntu-latest + name: cargo fmt + steps: + - uses: actions/checkout@v4 + + - name: install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_STABLE_VER }} + components: rustfmt + + - name: cargo fmt + run: cargo fmt --all --check + + - name: install ripgrep + run: | + sudo apt update + sudo apt install ripgrep + + - name: check copyright headers + run: bash .github/copyright.sh + + test-stable: + runs-on: ${{ matrix.os }} + strategy: + matrix: + # We use macos-14 as that is an arm runner. These have the virtgpu support we need + os: [windows-latest, macos-14, ubuntu-latest] + include: + - os: ubuntu-latest + gpu: 'yes' + - os: macos-14 + gpu: 'yes' + - os: windows-latest + # TODO: The windows runners theoretically have CPU fallback for GPUs, but + # this failed in initial testing + gpu: 'no' + name: cargo clippy + test + steps: + - uses: actions/checkout@v4 + + - name: install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_STABLE_VER }} + components: clippy + + - name: restore cache + uses: Swatinem/rust-cache@v2 + + - name: Install native dependencies + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev + + # Adapted from https://github.com/bevyengine/bevy/blob/b446374392adc70aceb92621b080d1a6cf7a7392/.github/workflows/validation-jobs.yml#L74-L79 + - name: install xvfb, llvmpipe and lavapipe + if: matrix.os == 'ubuntu-latest' + # https://launchpad.net/~kisak/+archive/ubuntu/turtle + run: | + sudo apt-get update -y -qq + sudo add-apt-repository ppa:kisak/turtle -y + sudo apt-get update + sudo apt install -y xvfb libegl1-mesa libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers + + - name: cargo clippy (no default features) + run: cargo clippy --workspace --lib --bins --no-default-features -- -D warnings + + - name: cargo clippy (no default features) (auxiliary) + run: cargo clippy --workspace --tests --benches --examples --no-default-features -- -D warnings + + - name: cargo clippy (default features) + run: cargo clippy --workspace --lib --bins -- -D warnings + + - name: cargo clippy (default features) (auxiliary) + run: cargo clippy --workspace --tests --benches --examples -- -D warnings + + - name: cargo clippy (all features) + run: cargo clippy --workspace --lib --bins --all-features -- -D warnings + + - name: cargo clippy (all features) (auxiliary) + run: cargo clippy --workspace --tests --benches --examples --all-features -- -D warnings + + # At the time of writing, we don't have any tests. Nevertheless, it's better to still run this + - name: cargo test + run: cargo test --workspace --all-features + env: + VELLO_CI_GPU_SUPPORT: ${{ matrix.gpu }} + + clippy-stable-wasm: + runs-on: ubuntu-latest + name: cargo test (wasm32) + steps: + - uses: actions/checkout@v4 + + - name: restore cache + uses: Swatinem/rust-cache@v2 + + - name: install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_STABLE_VER }} + targets: wasm32-unknown-unknown + components: clippy + + - name: cargo clippy (wasm) + run: cargo clippy --all-targets --target wasm32-unknown-unknown --workspace -- -D warnings + + android-stable-check: + runs-on: ubuntu-latest + name: cargo check (aarch64-android) + steps: + - uses: actions/checkout@v4 + + - name: restore cache + uses: Swatinem/rust-cache@v2 + + - name: install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_STABLE_VER }} + targets: aarch64-linux-android + + - name: install cargo apk + run: cargo install cargo-apk + + - name: cargo apk check (android) + run: cargo apk check -p with_winit --lib + env: + # This is a bit of a hack, but cargo apk doesn't seem to allow customising this + RUSTFLAGS: '-D warnings' + + docs: + name: cargo doc + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v4 + + - name: install nightly toolchain + uses: dtolnay/rust-toolchain@nightly + + - name: restore cache + uses: Swatinem/rust-cache@v2 + + # We test documentation using nightly to match docs.rs. This prevents potential breakages + - name: cargo doc + run: cargo doc --workspace --all-features --no-deps --document-private-items -Zunstable-options -Zrustdoc-scrape-examples diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9efee5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# Generated by Cargo +# will have compiled files and executables +target/ + +# Generated by Trunk +dist/ + +# These are backup files generated by rustfmt +**/*.rs.bk + +# Some people use VSCode +/.vscode/ + +# Some people use IntelliJ with Rust +/.idea/ +*.iml + +# Some people use pre-commit +.pre-commit-config.yaml +.pre-commit-config.yml + +# Some people have Apple +.DS_Store + +# Generated on wasm-pack failure +**/unsupported.js diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..589dd7a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,36 @@ +[workspace] +resolver = "2" +members = ["examples/with_winit", "examples/run_wasm", "examples/scenes"] + +[workspace.package] +edition = "2021" +version = "0.1.0" +license = "MIT OR Apache-2.0 AND MPL-2" +repository = "https://github.com/linebender/vello_svg" + +[workspace.dependencies] +vello = "0.1" + +[package] +name = "vello_svg" +description = "An SVG integration for vello." +categories = ["rendering", "graphics"] +keywords = ["2d", "vector-graphics", "vello", "svg"] +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dependencies] +vello = { workspace = true } +usvg = "0.40" +image = { version = "0.24", default-features = false, features = [ + "png", + "jpeg", + "gif", +] } + + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE-MPL b/LICENSE-MPL new file mode 100644 index 0000000..f4bbcd2 --- /dev/null +++ b/LICENSE-MPL @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1064e68 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# vello_svg + +[![Linebender Zulip](https://img.shields.io/badge/Linebender-%23gpu-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/stream/197075-gpu) +[![dependency status](https://deps.rs/repo/github/linebender/vello/status.svg)](https://deps.rs/repo/github/linebender/vello) +[![MIT/Apache 2.0+MPL 2.0](https://img.shields.io/badge/license-MIT%2FApache+MPL2-blue.svg)](#license) +[![Build status](https://github.com/linebender/vello/workflows/CI/badge.svg)](https://github.com/linebender/vello/actions) + + + +An integration to parse SVG files and render them with [Vello](https://vello.dev). + +## Examples + +See [vello](https://github.com/linebender/vello) for more information about limitations. + +### Native + +```shell +cargo run -p with_winit +``` + +You can also load an entire folder or individual files. + +```shell +cargo run -p with_winit -- examples/assets +``` + +### Web + +Because Vello relies heavily on compute shaders, we rely on the emerging WebGPU standard to run on the web. +Until browser support becomes widespread, it will probably be necessary to use development browser versions (e.g. Chrome Canary) and explicitly enable WebGPU. + +This uses [`cargo-run-wasm`](https://github.com/rukai/cargo-run-wasm) to build the example for web, and host a local server for it + +```shell +# Make sure the Rust toolchain supports the wasm32 target +rustup target add wasm32-unknown-unknown + +# The binary name must also be explicitly provided as it differs from the package name +cargo run_wasm -p with_winit --bin with_winit_bin +``` + +There is also a web demo [available here](https://linebender.github.io/vello_svg) on supporting web browsers. + +> [!WARNING] +> The web is not currently a primary target for Vello, and WebGPU implementations are incomplete, so you might run into issues running this example. + +## License + +This project is licensed under your choice of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) licenses, with [MPL 2.0](LICENSE-MPL). diff --git a/examples/assets/Ghostscript_Tiger.svg b/examples/assets/Ghostscript_Tiger.svg new file mode 100644 index 0000000..033611d --- /dev/null +++ b/examples/assets/Ghostscript_Tiger.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/assets/downloads/.tracked b/examples/assets/downloads/.tracked new file mode 100644 index 0000000..e1e92e1 --- /dev/null +++ b/examples/assets/downloads/.tracked @@ -0,0 +1 @@ +This directory is used to store the downloaded scenes by default diff --git a/LICENSE b/examples/assets/roboto/LICENSE.txt similarity index 99% rename from LICENSE rename to examples/assets/roboto/LICENSE.txt index 261eeb9..d645695 100644 --- a/LICENSE +++ b/examples/assets/roboto/LICENSE.txt @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/examples/assets/roboto/Roboto-Regular.ttf b/examples/assets/roboto/Roboto-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3d6861b42396c609e26f38f129383c558e332281 GIT binary patch literal 168260 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 z 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 literal 0 HcmV?d00001 diff --git a/examples/run_wasm/Cargo.toml b/examples/run_wasm/Cargo.toml new file mode 100644 index 0000000..76068dd --- /dev/null +++ b/examples/run_wasm/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "run_wasm" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +cargo-run-wasm = "0.3.2" diff --git a/examples/run_wasm/src/main.rs b/examples/run_wasm/src/main.rs new file mode 100644 index 0000000..dc3ed78 --- /dev/null +++ b/examples/run_wasm/src/main.rs @@ -0,0 +1,25 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +/// Use [cargo-run-wasm](https://github.com/rukai/cargo-run-wasm) to build an example for web +/// +/// Usage: +/// ``` +/// cargo run_wasm --package [example_name] +/// ``` +/// Generally: +/// ``` +/// cargo run_wasm -p with_winit +/// ``` + +fn main() { + // HACK: We rely heavily on compute shaders; which means we need WebGPU to + // be supported However, that requires unstable APIs to be enabled, + // which are not exposed through a feature + let current_value = std::env::var("RUSTFLAGS").unwrap_or("".to_owned()); + std::env::set_var( + "RUSTFLAGS", + format!("{current_value} --cfg=web_sys_unstable_apis",), + ); + cargo_run_wasm::run_wasm_with_css("body { margin: 0px; }"); +} diff --git a/examples/scenes/Cargo.toml b/examples/scenes/Cargo.toml new file mode 100644 index 0000000..db691e0 --- /dev/null +++ b/examples/scenes/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "scenes" +description = "Vello scenes used in the other examples." +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +vello = { workspace = true } +vello_svg = { path = "../.." } +anyhow = "1" +clap = { version = "4.5.1", features = ["derive"] } +image = "0.24.9" +rand = "0.8.5" +instant = "0.1" + +# Used for the `download` command +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +byte-unit = "4.0.19" +inquire = "*" +ureq = "2.9.6" diff --git a/examples/scenes/src/download.rs b/examples/scenes/src/download.rs new file mode 100644 index 0000000..10f4f15 --- /dev/null +++ b/examples/scenes/src/download.rs @@ -0,0 +1,224 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::{ + io::Seek, + path::{Path, PathBuf}, +}; + +use anyhow::{bail, Context, Result}; +use byte_unit::Byte; +use clap::Args; +use inquire::Confirm; +use std::io::Read; +mod default_downloads; + +#[derive(Args, Debug)] +pub(crate) struct Download { + #[clap(long)] + /// Directory to download the files into + #[clap(default_value_os_t = default_directory())] + pub directory: PathBuf, + /// Set of files to download. Use `name@url` format to specify a file prefix + downloads: Option>, + /// Whether to automatically install the default set of files + #[clap(long)] + auto: bool, + /// The size limit for each individual file (ignored if the default files are downloaded) + #[clap(long, default_value = "10 MB")] + size_limit: Byte, +} + +fn default_directory() -> PathBuf { + let mut result = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("assets"); + result.push("downloads"); + result +} + +impl Download { + pub fn action(&self) -> Result<()> { + let mut to_download = vec![]; + if let Some(downloads) = &self.downloads { + to_download = downloads + .iter() + .map(|it| Self::parse_download(it)) + .collect(); + } else { + let mut accepted = self.auto; + let downloads = default_downloads::default_downloads() + .into_iter() + .filter(|it| { + let file = it.file_path(&self.directory); + !file.exists() + }) + .collect::>(); + if !accepted { + if !downloads.is_empty() { + println!( + "Would you like to download a set of default svg files? These files are:" + ); + for download in &downloads { + let builtin = download.builtin.as_ref().unwrap(); + println!( + "{} ({}) under license {} from {}", + download.name, + byte_unit::Byte::from_bytes( + builtin.expected_size.into() + ) + .get_appropriate_unit(false), + builtin.license, + builtin.info + ); + } + + // For rustfmt, split prompt into its own line + const PROMPT: &str = + "Would you like to download a set of default svg files, as explained above?"; + accepted = + Confirm::new(PROMPT).with_default(false).prompt()?; + } else { + println!("Nothing to download! All default downloads already created"); + } + } + if accepted { + to_download = downloads; + } + } + let mut completed_count = 0; + let mut failed_count = 0; + for (index, download) in to_download.iter().enumerate() { + println!( + "{index}: Downloading {} from {}", + download.name, download.url + ); + match download.fetch(&self.directory, self.size_limit) { + Ok(()) => completed_count += 1, + Err(e) => { + failed_count += 1; + eprintln!("Download failed with error: {e}"); + let cont = if self.auto { + false + } else { + Confirm::new("Would you like to try other downloads?") + .with_default(false) + .prompt()? + }; + if !cont { + println!("{} downloads complete", completed_count); + if failed_count > 0 { + println!("{} downloads failed", failed_count); + } + let remaining = to_download.len() + - (completed_count + failed_count); + if remaining > 0 { + println!("{} downloads skipped", remaining); + } + return Err(e); + } + } + } + } + println!("{} downloads complete", completed_count); + if failed_count > 0 { + println!("{} downloads failed", failed_count); + } + debug_assert!(completed_count + failed_count == to_download.len()); + Ok(()) + } + + fn parse_download(value: &str) -> SVGDownload { + if let Some(at_index) = value.find('@') { + let name = &value[0..at_index]; + let url = &value[at_index + 1..]; + SVGDownload { + name: name.to_string(), + url: url.to_string(), + builtin: None, + } + } else { + let end_index = value.rfind(".svg").unwrap_or(value.len()); + let url_with_name = &value[0..end_index]; + let name = url_with_name + .rfind('/') + .map(|v| &url_with_name[v + 1..]) + .unwrap_or(url_with_name); + SVGDownload { + name: name.to_string(), + url: value.to_string(), + builtin: None, + } + } + } +} + +struct SVGDownload { + name: String, + url: String, + builtin: Option, +} + +impl SVGDownload { + fn file_path(&self, directory: &Path) -> PathBuf { + directory.join(&self.name).with_extension("svg") + } + + fn fetch(&self, directory: &Path, size_limit: Byte) -> Result<()> { + let mut size_limit = size_limit.get_bytes().try_into()?; + let mut limit_exact = false; + if let Some(builtin) = &self.builtin { + size_limit = builtin.expected_size; + limit_exact = true; + } + // If we're expecting an exact version of the file, it's worth not fetching + // the file if we know it will fail + if limit_exact { + let head_response = ureq::head(&self.url).call()?; + let content_length = head_response.header("content-length"); + if let Some(Ok(content_length)) = + content_length.map(|it| it.parse::()) + { + if content_length != size_limit { + bail!( + "Size is not as expected for download. Expected {}, server reported {}", + Byte::from_bytes(size_limit.into()).get_appropriate_unit(true), + Byte::from_bytes(content_length.into()).get_appropriate_unit(true) + ) + } + } + } + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(self.file_path(directory)) + .context("Creating file")?; + let mut reader = ureq::get(&self.url).call()?.into_reader(); + + std::io::copy( + // ureq::into_string() has a limit of 10MiB so we must use the reader + &mut (&mut reader).take(size_limit), + &mut file, + )?; + if reader.read_exact(&mut [0]).is_ok() { + bail!("Size limit exceeded"); + } + if limit_exact { + let bytes_downloaded = + file.stream_position().context("Checking file limit")?; + if bytes_downloaded != size_limit { + bail!( + "Builtin downloaded file was not as expected. Expected {size_limit}, received {bytes_downloaded}.", + ); + } + } + Ok(()) + } +} + +struct BuiltinSvgProps { + expected_size: u64, + license: &'static str, + info: &'static str, +} diff --git a/examples/scenes/src/download/default_downloads.rs b/examples/scenes/src/download/default_downloads.rs new file mode 100644 index 0000000..7a22a36 --- /dev/null +++ b/examples/scenes/src/download/default_downloads.rs @@ -0,0 +1,106 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +// This content cannot be formatted by rustfmt because of the long strings, so it's in its own file +use super::{BuiltinSvgProps, SVGDownload}; + +pub(super) fn default_downloads() -> Vec { + vec![ + SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://commons.wikimedia.org/wiki/File:CIA_WorldFactBook-Political_world.svg", + license: "Public Domain", + expected_size: 12771150, + }), + url: "https://upload.wikimedia.org/wikipedia/commons/7/72/Political_Map_of_the_World_%28august_2013%29.svg".to_string(), + name: "CIA World Map".to_string() + }, + SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://commons.wikimedia.org/wiki/File:World_-_time_zones_map_(2014).svg", + license: "Public Domain", + expected_size: 5235172, + }), + url: "https://upload.wikimedia.org/wikipedia/commons/c/c6/World_-_time_zones_map_%282014%29.svg".to_string(), + name: "Time Zones Map".to_string() + }, + SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://commons.wikimedia.org/wiki/File:Coat_of_arms_of_Poland-official.svg", + license: "Public Domain", + expected_size: 10747708, + }), + url: "https://upload.wikimedia.org/wikipedia/commons/3/3e/Coat_of_arms_of_Poland-official.svg".to_string(), + name: "Coat of Arms of Poland".to_string() + }, + SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://commons.wikimedia.org/wiki/File:Coat_of_arms_of_the_Kingdom_of_Yugoslavia.svg", + license: "Public Domain", + expected_size: 15413803, + }), + url: "https://upload.wikimedia.org/wikipedia/commons/5/58/Coat_of_arms_of_the_Kingdom_of_Yugoslavia.svg".to_string(), + name: "Coat of Arms of the Kingdom of Yugoslavia".to_string() + }, + SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://github.com/RazrFalcon/resvg-test-suite", + license: "MIT", + expected_size: 383, + }), + url: "https://raw.githubusercontent.com/RazrFalcon/resvg-test-suite/master/tests/painting/stroke-dashoffset/default.svg".to_string(), + name: "SVG Stroke Dasharray Test".to_string() + }, + SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://github.com/RazrFalcon/resvg-test-suite", + license: "MIT", + expected_size: 342, + }), + url: "https://raw.githubusercontent.com/RazrFalcon/resvg-test-suite/master/tests/painting/stroke-linecap/butt.svg".to_string(), + name: "SVG Stroke Linecap Butt Test".to_string() + }, + SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://github.com/RazrFalcon/resvg-test-suite", + license: "MIT", + expected_size: 344, + }), + url: "https://raw.githubusercontent.com/RazrFalcon/resvg-test-suite/master/tests/painting/stroke-linecap/round.svg".to_string(), + name: "SVG Stroke Linecap Round Test".to_string() + }, + SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://github.com/RazrFalcon/resvg-test-suite", + license: "MIT", + expected_size: 346, + }), + url: "https://raw.githubusercontent.com/RazrFalcon/resvg-test-suite/master/tests/painting/stroke-linecap/square.svg".to_string(), + name: "SVG Stroke Linecap Square Test".to_string() + }, SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://github.com/RazrFalcon/resvg-test-suite", + license: "MIT", + expected_size: 381, + }), + url: "https://github.com/RazrFalcon/resvg-test-suite/raw/master/tests/painting/stroke-linejoin/miter.svg".to_string(), + name: "SVG Stroke Linejoin Bevel Test".to_string() + }, SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://github.com/RazrFalcon/resvg-test-suite", + license: "MIT", + expected_size: 381, + }), + url: "https://github.com/RazrFalcon/resvg-test-suite/raw/master/tests/painting/stroke-linejoin/round.svg".to_string(), + name: "SVG Stroke Linejoin Round Test".to_string() + },SVGDownload { + builtin:Some(BuiltinSvgProps { + info: "https://github.com/RazrFalcon/resvg-test-suite", + license: "MIT", + expected_size: 351, + }), + url: "https://github.com/RazrFalcon/resvg-test-suite/raw/master/tests/painting/stroke-miterlimit/default.svg".to_string(), + name: "SVG Stroke Miterlimit Test".to_string() + }, + ] +} diff --git a/examples/scenes/src/lib.rs b/examples/scenes/src/lib.rs new file mode 100644 index 0000000..a396959 --- /dev/null +++ b/examples/scenes/src/lib.rs @@ -0,0 +1,126 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +#[cfg(not(target_arch = "wasm32"))] +pub mod download; +mod simple_text; +mod svg; +mod test_scenes; +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use clap::{Args, Subcommand}; +#[cfg(not(target_arch = "wasm32"))] +use download::Download; +pub use simple_text::RobotoText; +pub use svg::{default_scene, scene_from_files}; +pub use test_scenes::test_scenes; + +use vello::{kurbo::Vec2, peniko::Color, Scene}; + +pub struct SceneParams<'a> { + pub time: f64, + /// Whether blocking should be limited + /// Will not change between runs + // TODO: Just never block/handle this automatically? + pub interactive: bool, + pub text: &'a mut RobotoText, + pub resolution: Option, + pub base_color: Option, + pub complexity: usize, +} + +pub struct SceneConfig { + // TODO: This is currently unused + pub animated: bool, + pub name: String, +} + +pub struct ExampleScene { + pub function: Box, + pub config: SceneConfig, +} + +pub trait TestScene { + fn render(&mut self, scene: &mut Scene, params: &mut SceneParams); +} + +impl TestScene for F { + fn render(&mut self, scene: &mut Scene, params: &mut SceneParams) { + self(scene, params); + } +} + +pub struct SceneSet { + pub scenes: Vec, +} + +#[derive(Args, Debug)] +/// Shared config for scene selection +pub struct Arguments { + #[arg(help_heading = "Scene Selection")] + #[arg(long, global(false))] + /// Whether to use the test scenes created by code + test_scenes: bool, + #[arg(help_heading = "Scene Selection", global(false))] + /// The svg files paths to render + svgs: Option>, + #[arg(help_heading = "Render Parameters")] + #[arg(long, global(false), value_parser = parse_color)] + /// The base color applied as the blend background to the rasterizer. + /// Format is CSS style hexadecimal (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) or + /// an SVG color name such as "aliceblue" + pub base_color: Option, + #[clap(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Download SVG files for testing. By default, downloads a set of files from wikipedia + #[cfg(not(target_arch = "wasm32"))] + Download(Download), +} + +impl Arguments { + pub fn select_scene_set( + &self, + #[allow(unused)] command: impl FnOnce() -> clap::Command, + ) -> Result> { + if let Some(command) = &self.command { + command.action()?; + Ok(None) + } else { + // There is no file access on WASM, and on Android we haven't set up the assets + // directory. + // TODO: Upload the assets directory on Android + // Therefore, only render the `test_scenes` (including one SVG example) + #[cfg(any(target_arch = "wasm32", target_os = "android"))] + return Ok(Some(test_scenes())); + #[cfg(not(any(target_arch = "wasm32", target_os = "android")))] + if self.test_scenes { + Ok(test_scenes()) + } else if let Some(svgs) = &self.svgs { + scene_from_files(svgs) + } else { + default_scene(command) + } + .map(Some) + } + } +} + +impl Command { + fn action(&self) -> Result<()> { + match self { + #[cfg(not(target_arch = "wasm32"))] + Command::Download(download) => download.action(), + #[cfg(target_arch = "wasm32")] + _ => unreachable!("downloads not supported on wasm"), + } + } +} + +fn parse_color(s: &str) -> Result { + Color::parse(s).ok_or(anyhow!("'{s}' is not a valid color")) +} diff --git a/examples/scenes/src/simple_text.rs b/examples/scenes/src/simple_text.rs new file mode 100644 index 0000000..bd8a6e6 --- /dev/null +++ b/examples/scenes/src/simple_text.rs @@ -0,0 +1,142 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::sync::Arc; +use vello::{ + glyph::Glyph, + kurbo::Affine, + peniko::{Blob, Brush, BrushRef, Font, StyleRef}, + skrifa::{raw::FontRef, MetadataProvider}, + Scene, +}; + +// This is very much a hack to get things working. +// On Windows, can set this to "c:\\Windows\\Fonts\\seguiemj.ttf" to get color +// emoji +const ROBOTO_FONT: &[u8] = + include_bytes!("../../assets/roboto/Roboto-Regular.ttf"); +pub struct RobotoText { + font: Font, +} + +impl RobotoText { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + font: Font::new(Blob::new(Arc::new(ROBOTO_FONT)), 0), + } + } + + #[allow(clippy::too_many_arguments)] + pub fn add_run<'a>( + &mut self, + scene: &mut Scene, + font: Option<&Font>, + size: f32, + brush: impl Into>, + transform: Affine, + glyph_transform: Option, + style: impl Into>, + text: &str, + ) { + self.add_var_run( + scene, + font, + size, + &[], + brush, + transform, + glyph_transform, + style, + text, + ); + } + + #[allow(clippy::too_many_arguments)] + pub fn add_var_run<'a>( + &mut self, + scene: &mut Scene, + font: Option<&Font>, + size: f32, + variations: &[(&str, f32)], + brush: impl Into>, + transform: Affine, + glyph_transform: Option, + style: impl Into>, + text: &str, + ) { + let default_font = &self.font; + let font = font.unwrap_or(default_font); + let font_ref = to_font_ref(font).unwrap(); + let brush = brush.into(); + let style = style.into(); + let axes = font_ref.axes(); + let font_size = vello::skrifa::instance::Size::new(size); + let var_loc = axes.location(variations.iter().copied()); + let charmap = font_ref.charmap(); + let metrics = font_ref.metrics(font_size, &var_loc); + let line_height = metrics.ascent - metrics.descent + metrics.leading; + let glyph_metrics = font_ref.glyph_metrics(font_size, &var_loc); + let mut pen_x = 0f32; + let mut pen_y = 0f32; + scene + .draw_glyphs(font) + .font_size(size) + .transform(transform) + .glyph_transform(glyph_transform) + .normalized_coords(var_loc.coords()) + .brush(brush) + .draw( + style, + text.chars().filter_map(|ch| { + if ch == '\n' { + pen_y += line_height; + pen_x = 0.0; + return None; + } + let gid = charmap.map(ch).unwrap_or_default(); + let advance = + glyph_metrics.advance_width(gid).unwrap_or_default(); + let x = pen_x; + pen_x += advance; + Some(Glyph { + id: gid.to_u16() as u32, + x, + y: pen_y, + }) + }), + ); + } + + pub fn add( + &mut self, + scene: &mut Scene, + font: Option<&Font>, + size: f32, + brush: Option<&Brush>, + transform: Affine, + text: &str, + ) { + use vello::peniko::{Color, Fill}; + let brush = brush.unwrap_or(&Brush::Solid(Color::WHITE)); + self.add_run( + scene, + font, + size, + brush, + transform, + None, + Fill::NonZero, + text, + ); + } +} + +fn to_font_ref(font: &Font) -> Option> { + use vello::skrifa::raw::FileRef; + let file_ref = FileRef::new(font.data.as_ref()).ok()?; + match file_ref { + FileRef::Font(font) => Some(font), + FileRef::Collection(collection) => collection.get(font.index).ok(), + } +} diff --git a/examples/scenes/src/svg.rs b/examples/scenes/src/svg.rs new file mode 100644 index 0000000..73693ff --- /dev/null +++ b/examples/scenes/src/svg.rs @@ -0,0 +1,184 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::{ + fs::read_dir, + path::{Path, PathBuf}, +}; + +use anyhow::{Ok, Result}; +use instant::Instant; +use vello::{kurbo::Vec2, Scene}; +use vello_svg::usvg; + +use crate::{ExampleScene, SceneParams, SceneSet}; + +pub fn scene_from_files(files: &[PathBuf]) -> Result { + scene_from_files_inner(files, || ()) +} + +pub fn default_scene( + command: impl FnOnce() -> clap::Command, +) -> Result { + let assets_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../assets/") + .canonicalize()?; + let mut has_empty_directory = false; + let result = scene_from_files_inner( + &[ + assets_dir.join("Ghostscript_Tiger.svg"), + assets_dir.join("downloads"), + ], + || has_empty_directory = true, + )?; + if has_empty_directory { + let mut command = command(); + command.build(); + println!( + "No test files have been downloaded. Consider downloading some using the subcommand:" + ); + let subcmd = command.find_subcommand_mut("download").unwrap(); + subcmd.print_help()?; + } + Ok(result) +} + +fn scene_from_files_inner( + files: &[PathBuf], + mut empty_dir: impl FnMut(), +) -> std::result::Result { + let mut scenes = Vec::new(); + for path in files { + if path.is_dir() { + let mut count = 0; + let start_index = scenes.len(); + for file in read_dir(path)? { + let entry = file?; + if let Some(extension) = + Path::new(&entry.file_name()).extension() + { + if extension == "svg" { + count += 1; + scenes.push(example_scene_of(entry.path())); + } + } + } + // Ensure a consistent order within directories + scenes[start_index..] + .sort_by_key(|scene| scene.config.name.to_lowercase()); + if count == 0 { + empty_dir(); + } + } else { + scenes.push(example_scene_of(path.to_owned())); + } + } + Ok(SceneSet { scenes }) +} + +fn example_scene_of(file: PathBuf) -> ExampleScene { + let name = file + .file_stem() + .map(|it| it.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + ExampleScene { + function: Box::new(svg_function_of(name.clone(), move || { + std::fs::read_to_string(&file).unwrap_or_else(|e| { + panic!("failed to read svg file {file:?}: {e}") + }) + })), + config: crate::SceneConfig { + animated: false, + name, + }, + } +} + +pub fn svg_function_of>( + name: String, + contents: impl FnOnce() -> R + Send + 'static, +) -> impl FnMut(&mut Scene, &mut SceneParams) { + fn render_svg_contents(name: &str, contents: &str) -> (Scene, Vec2) { + let start = Instant::now(); + let fontdb = usvg::fontdb::Database::new(); + let svg = + usvg::Tree::from_str(contents, &usvg::Options::default(), &fontdb) + .unwrap_or_else(|e| { + panic!("failed to parse svg file {name}: {e}") + }); + eprintln!("Parsed svg {name} in {:?}", start.elapsed()); + let start = Instant::now(); + let mut new_scene = Scene::new(); + vello_svg::render_tree(&mut new_scene, &svg); + let resolution = + Vec2::new(svg.size().width() as f64, svg.size().height() as f64); + eprintln!("Encoded svg {name} in {:?}", start.elapsed()); + (new_scene, resolution) + } + let mut cached_scene = None; + #[cfg(not(target_arch = "wasm32"))] + let (tx, rx) = std::sync::mpsc::channel(); + #[cfg(not(target_arch = "wasm32"))] + let mut tx = Some(tx); + #[cfg(not(target_arch = "wasm32"))] + let mut has_started_parse = false; + let mut contents = Some(contents); + move |scene, params| { + if let Some((scene_frag, resolution)) = cached_scene.as_mut() { + scene.append(scene_frag, None); + params.resolution = Some(*resolution); + return; + } + if cfg!(target_arch = "wasm32") || !params.interactive { + let contents = contents.take().unwrap(); + let contents = contents(); + let (scene_frag, resolution) = + render_svg_contents(&name, contents.as_ref()); + scene.append(&scene_frag, None); + params.resolution = Some(resolution); + cached_scene = Some((scene_frag, resolution)); + #[cfg_attr( + target_arch = "wasm32", + allow(clippy::needless_return) + )] + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut timeout = std::time::Duration::from_millis(10); + if !has_started_parse { + has_started_parse = true; + // Prefer jank over loading screen for first time + timeout = std::time::Duration::from_millis(75); + let tx = tx.take().unwrap(); + let contents = contents.take().unwrap(); + let name = name.clone(); + std::thread::spawn(move || { + let contents = contents(); + tx.send(render_svg_contents(&name, contents.as_ref())) + .unwrap(); + }); + } + let recv = rx.recv_timeout(timeout); + use std::sync::mpsc::RecvTimeoutError; + match recv { + Result::Ok((scene_frag, resolution)) => { + scene.append(&scene_frag, None); + params.resolution = Some(resolution); + cached_scene = Some((scene_frag, resolution)); + } + Err(RecvTimeoutError::Timeout) => params.text.add( + scene, + None, + 48., + None, + vello::kurbo::Affine::translate((110.0, 600.0)), + &format!("Loading {name}"), + ), + Err(RecvTimeoutError::Disconnected) => { + panic!() + } + } + }; + } +} diff --git a/examples/scenes/src/test_scenes.rs b/examples/scenes/src/test_scenes.rs new file mode 100644 index 0000000..e8fb905 --- /dev/null +++ b/examples/scenes/src/test_scenes.rs @@ -0,0 +1,73 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::{ExampleScene, SceneConfig, SceneParams, SceneSet}; +use vello::{kurbo::Affine, *}; + +macro_rules! scene { + ($name: ident) => { + scene!($name: false) + }; + ($name: ident: animated) => { + scene!($name: true) + }; + ($name: ident: $animated: literal) => { + scene!($name, stringify!($name), $animated) + }; + ($func:expr, $name: expr, $animated: literal) => { + ExampleScene { + config: SceneConfig { + animated: $animated, + name: $name.to_owned(), + }, + function: Box::new($func), + } + }; +} + +pub fn test_scenes() -> SceneSet { + let scenes = vec![scene!(splash_with_tiger(), "Tiger", true)]; + SceneSet { scenes } +} + +// Scenes +fn splash_screen(scene: &mut Scene, params: &mut SceneParams) { + let strings = [ + "Vello SVG Demo", + #[cfg(not(target_arch = "wasm32"))] + " Arrow keys: switch scenes", + " Space: reset transform", + " S: toggle stats", + " V: toggle vsync", + " M: cycle AA method", + " Q, E: rotate", + ]; + // Tweak to make it fit with tiger + let a = Affine::scale(1.) * Affine::translate((-90.0, -50.0)); + for (i, s) in strings.iter().enumerate() { + let text_size = if i == 0 { 60.0 } else { 40.0 }; + params.text.add( + scene, + None, + text_size, + None, + a * Affine::translate((100.0, 100.0 + 60.0 * i as f64)), + s, + ); + } +} + +fn splash_with_tiger() -> impl FnMut(&mut Scene, &mut SceneParams) { + let contents = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../assets/Ghostscript_Tiger.svg" + )); + let mut tiger = crate::svg::svg_function_of( + "Ghostscript Tiger".to_string(), + move || contents, + ); + move |scene, params| { + tiger(scene, params); + splash_screen(scene, params); + } +} diff --git a/examples/with_winit/Cargo.toml b/examples/with_winit/Cargo.toml new file mode 100644 index 0000000..868b682 --- /dev/null +++ b/examples/with_winit/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "with_winit" +version = "0.0.0" +description = "An example using vello to render to a winit window" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[lib] +name = "with_winit" +crate-type = ["cdylib", "lib"] + +[[bin]] +# Stop the PDB collision warning on windows +name = "with_winit_bin" +path = "src/main.rs" + +[dependencies] +vello = { workspace = true, features = ["buffer_labels", "wgpu-profiler"] } +scenes = { path = "../scenes" } +anyhow = "1" +clap = { version = "4.5.1", features = ["derive"] } +instant = { version = "0.1.12", features = ["wasm-bindgen"] } +pollster = "0.3" +wgpu-profiler = "0.16" +wgpu = "0.19" +winit = "0.29.12" +env_logger = "0.11.2" +log = "0.4.21" + +[target.'cfg(not(any(target_arch = "wasm32", target_os = "android")))'.dependencies] +vello = { workspace = true, features = ["hot_reload", "wgpu-profiler"] } +notify-debouncer-mini = "0.3" + +[target.'cfg(target_os = "android")'.dependencies] +winit = { version = "0.29.12", features = ["android-native-activity"] } +android_logger = "0.13.3" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook = "0.1.7" +console_log = "1.0.0" +wasm-bindgen-futures = "0.4.41" +web-sys = { version = "0.3.67", features = ["HtmlCollection", "Text"] } +getrandom = { version = "0.2.12", features = ["js"] } diff --git a/examples/with_winit/README.md b/examples/with_winit/README.md new file mode 100644 index 0000000..abcc8f3 --- /dev/null +++ b/examples/with_winit/README.md @@ -0,0 +1,25 @@ +## Usage + +Running the viewer without any arguments will render a built-in set of public-domain SVG images: + +```bash +$ cargo run -p with_winit --release +``` + +Optionally, you can pass in paths to SVG files that you want to render: + +```bash +$ cargo run -p with_winit --release -- [SVG FILES] +``` + +## Controls + +- Mouse drag-and-drop will translate the image. +- Mouse scroll wheel will zoom. +- Arrow keys switch between SVG images in the current set. +- Space resets the position and zoom of the image. +- S toggles the frame statistics layer +- C resets the min/max frame time tracked by statistics +- D toggles displaying the required number of each kind of dynamically allocated element (default: off) +- V toggles VSync on/off (default: on) +- Escape exits the program. diff --git a/examples/with_winit/src/hot_reload.rs b/examples/with_winit/src/hot_reload.rs new file mode 100644 index 0000000..9c317bc --- /dev/null +++ b/examples/with_winit/src/hot_reload.rs @@ -0,0 +1,32 @@ +// Copyright 2023 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::{path::Path, time::Duration}; + +use anyhow::Result; +use notify_debouncer_mini::{new_debouncer, notify::*, DebounceEventResult}; + +pub(crate) fn hot_reload( + mut f: impl FnMut() -> Option<()> + Send + 'static, +) -> Result { + let mut debouncer = new_debouncer( + Duration::from_millis(500), + None, + move |res: DebounceEventResult| match res { + Ok(_) => f().unwrap(), + Err(errors) => { + errors.iter().for_each(|e| println!("Error {:?}", e)) + } + }, + )?; + + debouncer.watcher().watch( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../shader") + .canonicalize()?, + // We currently don't support hot reloading the imports, so don't + // recurse into there + RecursiveMode::NonRecursive, + )?; + Ok(debouncer) +} diff --git a/examples/with_winit/src/lib.rs b/examples/with_winit/src/lib.rs new file mode 100644 index 0000000..b9cbd04 --- /dev/null +++ b/examples/with_winit/src/lib.rs @@ -0,0 +1,713 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use instant::{Duration, Instant}; +use std::{collections::HashSet, num::NonZeroUsize, sync::Arc}; + +use anyhow::Result; +use clap::{CommandFactory, Parser}; +use scenes::{RobotoText, SceneParams, SceneSet}; +use vello::{ + kurbo::{Affine, Vec2}, + peniko::Color, + util::{RenderContext, RenderSurface}, + AaConfig, BumpAllocators, Renderer, RendererOptions, Scene, +}; + +use winit::{ + event_loop::{EventLoop, EventLoopBuilder}, + window::Window, +}; + +#[cfg(not(any(target_arch = "wasm32", target_os = "android")))] +mod hot_reload; +mod multi_touch; +mod stats; + +#[derive(Parser, Debug)] +#[command(about, long_about = None, bin_name="cargo run -p with_winit --")] +struct Args { + /// Which scene (index) to start on + /// Switch between scenes with left and right arrow keys + #[arg(long)] + scene: Option, + #[command(flatten)] + args: scenes::Arguments, + #[arg(long)] + /// Whether to use CPU shaders + use_cpu: bool, + /// Whether to force initialising the shaders serially (rather than + /// spawning threads) This has no effect on wasm, and defaults to 1 on + /// macOS for performance reasons + /// + /// Use `0` for an automatic choice + #[arg(long, default_value_t=default_threads())] + num_init_threads: usize, +} + +fn default_threads() -> usize { + #![allow(unreachable_code)] + #[cfg(target_os = "mac")] + { + return 1; + } + 0 +} + +struct RenderState<'s> { + // SAFETY: We MUST drop the surface before the `window`, so the fields + // must be in this order + surface: RenderSurface<'s>, + window: Arc, +} + +fn run( + event_loop: EventLoop, + args: Args, + mut scenes: SceneSet, + render_cx: RenderContext, + #[cfg(target_arch = "wasm32")] render_state: RenderState, +) { + use winit::{event::*, event_loop::ControlFlow, keyboard::*}; + let mut renderers: Vec> = vec![]; + #[cfg(not(target_arch = "wasm32"))] + let mut render_cx = render_cx; + #[cfg(not(target_arch = "wasm32"))] + let mut render_state = None::; + let use_cpu = args.use_cpu; + // The design of `RenderContext` forces delayed renderer initialisation to + // not work on wasm, as WASM futures effectively must be 'static. + // Otherwise, this could work by sending the result to event_loop.proxy + // instead of blocking + #[cfg(target_arch = "wasm32")] + let mut render_state = { + renderers.resize_with(render_cx.devices.len(), || None); + let id = render_state.surface.dev_id; + let mut renderer = Renderer::new( + &render_cx.devices[id].device, + RendererOptions { + surface_format: Some(render_state.surface.format), + use_cpu, + antialiasing_support: vello::AaSupport::all(), + // We currently initialise on one thread on WASM, but mark this here + // anyway + num_init_threads: NonZeroUsize::new(1), + }, + ) + .expect("Could create renderer"); + renderer + .profiler + .change_settings(wgpu_profiler::GpuProfilerSettings { + enable_timer_queries: false, + enable_debug_groups: false, + ..Default::default() + }) + .expect("Not setting max_num_pending_frames"); + renderers[id] = Some(renderer); + Some(render_state) + }; + // Whilst suspended, we drop `render_state`, but need to keep the same + // window. If render_state exists, we must store the window in it, to + // maintain drop order + #[cfg(not(target_arch = "wasm32"))] + let mut cached_window = None; + + let mut scene = Scene::new(); + let mut fragment = Scene::new(); + let mut simple_text = RobotoText::new(); + let mut stats = stats::Stats::new(); + let mut stats_shown = true; + // Currently not updated in wasm builds + #[allow(unused_mut)] + let mut scene_complexity: Option = None; + let mut complexity_shown = false; + let mut vsync_on = true; + + const AA_CONFIGS: [AaConfig; 3] = + [AaConfig::Area, AaConfig::Msaa8, AaConfig::Msaa16]; + // We allow cycling through AA configs in either direction, so use a signed + // index + let mut aa_config_ix: i32 = 0; + + let mut frame_start_time = Instant::now(); + let start = Instant::now(); + + let mut touch_state = multi_touch::TouchState::new(); + // navigation_fingers are fingers which are used in the navigation 'zone' at + // the bottom of the screen. This ensures that one press on the screen + // doesn't have multiple actions + let mut navigation_fingers = HashSet::new(); + let mut transform = Affine::IDENTITY; + let mut mouse_down = false; + let mut prior_position: Option = None; + // We allow looping left and right through the scenes, so use a signed index + let mut scene_ix: i32 = 0; + let mut complexity: usize = 0; + if let Some(set_scene) = args.scene { + scene_ix = set_scene; + } + let mut profile_stored = None; + let mut prev_scene_ix = scene_ix - 1; + let mut profile_taken = Instant::now(); + let mut modifiers = ModifiersState::default(); + event_loop + .run(move |event, event_loop| match event { + Event::WindowEvent { + ref event, + window_id, + } => { + let Some(render_state) = &mut render_state else { + return; + }; + if render_state.window.id() != window_id { + return; + } + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::ModifiersChanged(m) => modifiers = m.state(), + WindowEvent::KeyboardInput { event, .. } => { + if event.state == ElementState::Pressed { + match event.logical_key.as_ref() { + Key::Named(NamedKey::ArrowLeft) => { + scene_ix = scene_ix.saturating_sub(1) + } + Key::Named(NamedKey::ArrowRight) => { + scene_ix = scene_ix.saturating_add(1) + } + Key::Named(NamedKey::ArrowUp) => complexity += 1, + Key::Named(NamedKey::ArrowDown) => { + complexity = complexity.saturating_sub(1) + } + Key::Named(NamedKey::Space) => { + transform = Affine::IDENTITY; + } + Key::Character(char) => { + // TODO: Have a more principled way of handling modifiers on keypress + // see e.g. https://xi.zulipchat.com/#narrow/stream/351333-glazier/topic/Keyboard.20shortcuts + let char = char.to_lowercase(); + match char.as_str() { + "q" | "e" => { + if let Some(prior_position) = prior_position { + let is_clockwise = char == "e"; + let angle = if is_clockwise { -0.05 } else { 0.05 }; + transform = Affine::translate(prior_position) + * Affine::rotate(angle) + * Affine::translate(-prior_position) + * transform; + } + } + "s" => { + stats_shown = !stats_shown; + } + "d" => { + complexity_shown = !complexity_shown; + } + "c" => { + stats.clear_min_and_max(); + } + "m" => { + aa_config_ix = if modifiers.shift_key() { + aa_config_ix.saturating_sub(1) + } else { + aa_config_ix.saturating_add(1) + }; + } + "p" => { + if let Some(renderer) = &renderers[render_state.surface.dev_id] + { + if let Some(profile_result) = &renderer + .profile_result + .as_ref() + .or(profile_stored.as_ref()) + { + // There can be empty results if the required features aren't supported + if !profile_result.is_empty() { + let path = std::path::Path::new("trace.json"); + match wgpu_profiler::chrometrace::write_chrometrace( + path, + profile_result, + ) { + Ok(()) => { + println!("Wrote trace to path {path:?}"); + } + Err(e) => { + eprintln!("Failed to write trace {e}") + } + } + } + } + } + } + "v" => { + vsync_on = !vsync_on; + render_cx.set_present_mode( + &mut render_state.surface, + if vsync_on { + wgpu::PresentMode::AutoVsync + } else { + wgpu::PresentMode::AutoNoVsync + }, + ); + } + _ => {} + } + } + Key::Named(NamedKey::Escape) => event_loop.exit(), + _ => {} + } + } + } + WindowEvent::Touch(touch) => { + match touch.phase { + TouchPhase::Started => { + // We reserve the bottom third of the screen for navigation + // This also prevents strange effects whilst using the navigation gestures on Android + // TODO: How do we know what the client area is? Winit seems to just give us the + // full screen + // TODO: Render a display of the navigation regions. We don't do + // this currently because we haven't researched how to determine when we're + // in a touch context (i.e. Windows/Linux/MacOS with a touch screen could + // also be using mouse/keyboard controls) + // Note that winit's rendering is y-down + if touch.location.y + > render_state.surface.config.height as f64 * 2. / 3. + { + navigation_fingers.insert(touch.id); + // The left third of the navigation zone navigates backwards + if touch.location.x + < render_state.surface.config.width as f64 / 3. + { + scene_ix = scene_ix.saturating_sub(1); + } else if touch.location.x + > 2. * render_state.surface.config.width as f64 / 3. + { + scene_ix = scene_ix.saturating_add(1); + } + } + } + TouchPhase::Ended | TouchPhase::Cancelled => { + // We intentionally ignore the result here + navigation_fingers.remove(&touch.id); + } + TouchPhase::Moved => (), + } + // See documentation on navigation_fingers + if !navigation_fingers.contains(&touch.id) { + touch_state.add_event(touch); + } + } + WindowEvent::Resized(size) => { + render_cx.resize_surface( + &mut render_state.surface, + size.width, + size.height, + ); + render_state.window.request_redraw(); + } + WindowEvent::MouseInput { state, button, .. } => { + if button == &MouseButton::Left { + mouse_down = state == &ElementState::Pressed; + } + } + WindowEvent::MouseWheel { delta, .. } => { + const BASE: f64 = 1.05; + const PIXELS_PER_LINE: f64 = 20.0; + + if let Some(prior_position) = prior_position { + let exponent = if let MouseScrollDelta::PixelDelta(delta) = delta { + delta.y / PIXELS_PER_LINE + } else if let MouseScrollDelta::LineDelta(_, y) = delta { + *y as f64 + } else { + 0.0 + }; + transform = Affine::translate(prior_position) + * Affine::scale(BASE.powf(exponent)) + * Affine::translate(-prior_position) + * transform; + } else { + eprintln!( + "Scrolling without mouse in window; this shouldn't be possible" + ); + } + } + WindowEvent::CursorLeft { .. } => { + prior_position = None; + } + WindowEvent::CursorMoved { position, .. } => { + let position = Vec2::new(position.x, position.y); + if mouse_down { + if let Some(prior) = prior_position { + transform = Affine::translate(position - prior) * transform; + } + } + prior_position = Some(position); + } + WindowEvent::RedrawRequested => { + let width = render_state.surface.config.width; + let height = render_state.surface.config.height; + let device_handle = &render_cx.devices[render_state.surface.dev_id]; + let snapshot = stats.snapshot(); + + // Allow looping forever + scene_ix = scene_ix.rem_euclid(scenes.scenes.len() as i32); + aa_config_ix = aa_config_ix.rem_euclid(AA_CONFIGS.len() as i32); + + let example_scene = &mut scenes.scenes[scene_ix as usize]; + if prev_scene_ix != scene_ix { + transform = Affine::IDENTITY; + prev_scene_ix = scene_ix; + render_state + .window + .set_title(&format!("Vello demo - {}", example_scene.config.name)); + } + fragment.reset(); + let mut scene_params = SceneParams { + time: start.elapsed().as_secs_f64(), + text: &mut simple_text, + resolution: None, + base_color: None, + interactive: true, + complexity, + }; + example_scene + .function + .render(&mut fragment, &mut scene_params); + + // If the user specifies a base color in the CLI we use that. Otherwise we use any + // color specified by the scene. The default is black. + let base_color = args + .args + .base_color + .or(scene_params.base_color) + .unwrap_or(Color::BLACK); + let antialiasing_method = AA_CONFIGS[aa_config_ix as usize]; + let render_params = vello::RenderParams { + base_color, + width, + height, + antialiasing_method, + }; + scene.reset(); + let mut transform = transform; + if let Some(resolution) = scene_params.resolution { + // Automatically scale the rendering to fill as much of the window as possible + // TODO: Apply svg view_box, somehow + let factor = Vec2::new(width as f64, height as f64); + let scale_factor = + (factor.x / resolution.x).min(factor.y / resolution.y); + transform *= Affine::scale(scale_factor); + } + scene.append(&fragment, Some(transform)); + if stats_shown { + snapshot.draw_layer( + &mut scene, + scene_params.text, + width as f64, + height as f64, + stats.samples(), + complexity_shown.then_some(scene_complexity).flatten(), + vsync_on, + antialiasing_method, + ); + if let Some(profiling_result) = renderers[render_state.surface.dev_id] + .as_mut() + .and_then(|it| it.profile_result.take()) + { + if profile_stored.is_none() + || profile_taken.elapsed() > Duration::from_secs(1) + { + profile_stored = Some(profiling_result); + profile_taken = Instant::now(); + } + } + if let Some(profiling_result) = profile_stored.as_ref() { + stats::draw_gpu_profiling( + &mut scene, + scene_params.text, + width as f64, + height as f64, + profiling_result, + ); + } + } + let surface_texture = render_state + .surface + .surface + .get_current_texture() + .expect("failed to get surface texture"); + #[cfg(not(target_arch = "wasm32"))] + { + scene_complexity = vello::block_on_wgpu( + &device_handle.device, + renderers[render_state.surface.dev_id] + .as_mut() + .unwrap() + .render_to_surface_async( + &device_handle.device, + &device_handle.queue, + &scene, + &surface_texture, + &render_params, + ), + ) + .expect("failed to render to surface"); + } + // Note: in the wasm case, we're currently not running the robust + // pipeline, as it requires more async wiring for the readback. + #[cfg(target_arch = "wasm32")] + renderers[render_state.surface.dev_id] + .as_mut() + .unwrap() + .render_to_surface( + &device_handle.device, + &device_handle.queue, + &scene, + &surface_texture, + &render_params, + ) + .expect("failed to render to surface"); + surface_texture.present(); + device_handle.device.poll(wgpu::Maintain::Poll); + + let new_time = Instant::now(); + stats.add_sample(stats::Sample { + frame_time_us: (new_time - frame_start_time).as_micros() as u64, + }); + frame_start_time = new_time; + } + _ => {} + } + } + Event::AboutToWait => { + touch_state.end_frame(); + let touch_info = touch_state.info(); + if let Some(touch_info) = touch_info { + let centre = Vec2::new(touch_info.zoom_centre.x, touch_info.zoom_centre.y); + transform = Affine::translate(touch_info.translation_delta) + * Affine::translate(centre) + * Affine::scale(touch_info.zoom_delta) + * Affine::rotate(touch_info.rotation_delta) + * Affine::translate(-centre) + * transform; + } + + if let Some(render_state) = &mut render_state { + render_state.window.request_redraw(); + } + } + Event::UserEvent(event) => match event { + #[cfg(not(any(target_arch = "wasm32", target_os = "android")))] + UserEvent::HotReload => { + let Some(render_state) = &mut render_state else { + return; + }; + let device_handle = &render_cx.devices[render_state.surface.dev_id]; + eprintln!("==============\nReloading shaders"); + let start = Instant::now(); + let result = renderers[render_state.surface.dev_id] + .as_mut() + .unwrap() + .reload_shaders(&device_handle.device); + // We know that the only async here (`pop_error_scope`) is actually sync, so blocking is fine + match pollster::block_on(result) { + Ok(_) => eprintln!("Reloading took {:?}", start.elapsed()), + Err(e) => eprintln!("Failed to reload shaders because of {e}"), + } + } + }, + Event::Suspended => { + eprintln!("Suspending"); + #[cfg(not(target_arch = "wasm32"))] + // When we suspend, we need to remove the `wgpu` Surface + if let Some(render_state) = render_state.take() { + cached_window = Some(render_state.window); + } + event_loop.set_control_flow(ControlFlow::Wait); + } + Event::Resumed => { + #[cfg(target_arch = "wasm32")] + {} + #[cfg(not(target_arch = "wasm32"))] + { + let Option::None = render_state else { return }; + let window = cached_window + .take() + .unwrap_or_else(|| create_window(event_loop)); + let size = window.inner_size(); + let surface_future = render_cx.create_surface(window.clone(), size.width, size.height, wgpu::PresentMode::AutoVsync); + // We need to block here, in case a Suspended event appeared + let surface = + pollster::block_on(surface_future).expect("Error creating surface"); + render_state = { + let render_state = RenderState { window, surface }; + renderers.resize_with(render_cx.devices.len(), || None); + let id = render_state.surface.dev_id; + renderers[id].get_or_insert_with(|| { + let start = Instant::now(); + let renderer = Renderer::new( + &render_cx.devices[id].device, + RendererOptions { + surface_format: Some(render_state.surface.format), + use_cpu, + antialiasing_support: vello::AaSupport::all(), + num_init_threads: NonZeroUsize::new(args.num_init_threads) + }, + ) + .expect("Could create renderer"); + eprintln!("Creating renderer {id} took {:?}", start.elapsed()); + renderer + }); + Some(render_state) + }; + event_loop.set_control_flow(ControlFlow::Poll); + } + } + _ => {} + }) + .expect("run to completion"); +} + +fn create_window( + event_loop: &winit::event_loop::EventLoopWindowTarget, +) -> Arc { + use winit::{dpi::LogicalSize, window::WindowBuilder}; + Arc::new( + WindowBuilder::new() + .with_inner_size(LogicalSize::new(1044, 800)) + .with_resizable(true) + .with_title("Vello demo") + .build(event_loop) + .unwrap(), + ) +} + +#[derive(Debug)] +enum UserEvent { + #[cfg(not(any(target_arch = "wasm32", target_os = "android")))] + HotReload, +} + +#[cfg(target_arch = "wasm32")] +fn display_error_message() -> Option<()> { + let window = web_sys::window()?; + let document = window.document()?; + let elements = document.get_elements_by_tag_name("body"); + let body = elements.item(0)?; + body.set_inner_html( + r#" + "#, + ); + Some(()) +} + +pub fn main() -> Result<()> { + // TODO: initializing both env_logger and console_logger fails on wasm. + // Figure out a more principled approach. + #[cfg(not(target_arch = "wasm32"))] + env_logger::init(); + let args = Args::parse(); + let scenes = args.args.select_scene_set(Args::command)?; + if let Some(scenes) = scenes { + let event_loop = + EventLoopBuilder::::with_user_event().build()?; + #[allow(unused_mut)] + let mut render_cx = RenderContext::new().unwrap(); + #[cfg(not(target_arch = "wasm32"))] + { + #[cfg(not(target_os = "android"))] + let proxy = event_loop.create_proxy(); + #[cfg(not(target_os = "android"))] + let _keep = hot_reload::hot_reload(move || { + proxy.send_event(UserEvent::HotReload).ok().map(drop) + }); + + run(event_loop, args, scenes, render_cx); + } + #[cfg(target_arch = "wasm32")] + { + std::panic::set_hook(Box::new(console_error_panic_hook::hook)); + console_log::init().expect("could not initialize logger"); + use winit::platform::web::WindowExtWebSys; + let window = create_window(&event_loop); + // On wasm, append the canvas to the document body + let canvas = window.canvas().unwrap(); + web_sys::window() + .and_then(|win| win.document()) + .and_then(|doc| doc.body()) + .and_then(|body| body.append_child(canvas.as_ref()).ok()) + .expect("couldn't append canvas to document body"); + // Best effort to start with the canvas focused, taking input + _ = web_sys::HtmlElement::from(canvas).focus(); + wasm_bindgen_futures::spawn_local(async move { + let (width, height, scale_factor) = web_sys::window() + .map(|w| { + ( + w.inner_width().unwrap().as_f64().unwrap(), + w.inner_height().unwrap().as_f64().unwrap(), + w.device_pixel_ratio(), + ) + }) + .unwrap(); + let size = winit::dpi::PhysicalSize::from_logical::<_, f64>( + (width, height), + scale_factor, + ); + _ = window.request_inner_size(size); + let surface = render_cx + .create_surface( + window.clone(), + size.width, + size.height, + wgpu::PresentMode::AutoVsync, + ) + .await; + if let Ok(surface) = surface { + let render_state = RenderState { window, surface }; + // No error handling here; if the event loop has finished, + // we don't need to send them the surface + run(event_loop, args, scenes, render_cx, render_state); + } else { + _ = display_error_message(); + } + }); + } + } + Ok(()) +} + +#[cfg(target_os = "android")] +use winit::platform::android::activity::AndroidApp; + +#[cfg(target_os = "android")] +#[no_mangle] +fn android_main(app: AndroidApp) { + use winit::platform::android::EventLoopBuilderExtAndroid; + + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Warn), + ); + + let event_loop = EventLoopBuilder::with_user_event() + .with_android_app(app) + .build() + .expect("Required to continue"); + let args = Args::parse(); + let scenes = args + .args + .select_scene_set(|| Args::command()) + .unwrap() + .unwrap(); + let render_cx = RenderContext::new().unwrap(); + + run(event_loop, args, scenes, render_cx); +} diff --git a/examples/with_winit/src/main.rs b/examples/with_winit/src/main.rs new file mode 100644 index 0000000..1d49806 --- /dev/null +++ b/examples/with_winit/src/main.rs @@ -0,0 +1,8 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use anyhow::Result; + +fn main() -> Result<()> { + with_winit::main() +} diff --git a/examples/with_winit/src/multi_touch.rs b/examples/with_winit/src/multi_touch.rs new file mode 100644 index 0000000..2e869f0 --- /dev/null +++ b/examples/with_winit/src/multi_touch.rs @@ -0,0 +1,312 @@ +// Copyright 2021 the egui Authors and the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +/// Adapted from https://github.com/emilk/egui/blob/212656f3fc6b931b21eaad401e5cec2b0da93baa/crates/egui/src/input_state/touch_state.rs +use std::{collections::BTreeMap, fmt::Debug}; + +use vello::kurbo::{Point, Vec2}; +use winit::event::{Touch, TouchPhase}; + +/// All you probably need to know about a multi-touch gesture. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MultiTouchInfo { + /// Number of touches (fingers) on the surface. Value is ≥ 2 since for a + /// single touch no [`MultiTouchInfo`] is created. + pub num_touches: usize, + + /// Proportional zoom factor (pinch gesture). + /// * `zoom = 1`: no change + /// * `zoom < 1`: pinch together + /// * `zoom > 1`: pinch spread + pub zoom_delta: f64, + + /// 2D non-proportional zoom factor (pinch gesture). + /// + /// For horizontal pinches, this will return `[z, 1]`, + /// for vertical pinches this will return `[1, z]`, + /// and otherwise this will return `[z, z]`, + /// where `z` is the zoom factor: + /// * `zoom = 1`: no change + /// * `zoom < 1`: pinch together + /// * `zoom > 1`: pinch spread + pub zoom_delta_2d: Vec2, + + /// Rotation in radians. Moving fingers around each other will change this + /// value. This is a relative value, comparing the orientation of + /// fingers in the current frame with the previous frame. If all + /// fingers are resting, this value is `0.0`. + pub rotation_delta: f64, + + /// Relative movement (comparing previous frame and current frame) of the + /// average position of all touch points. Without movement this value + /// is `Vec2::ZERO`. + /// + /// Note that this may not necessarily be measured in screen points + /// (although it _will_ be for most mobile devices). In general + /// (depending on the touch device), touch coordinates cannot + /// be directly mapped to the screen. A touch always is considered to start + /// at the position of the pointer, but touch movement is always + /// measured in the units delivered by the device, and may depend on + /// hardware and system settings. + pub translation_delta: Vec2, + pub zoom_centre: Point, +} + +/// The current state (for a specific touch device) of touch events and +/// gestures. +#[derive(Clone)] +pub(crate) struct TouchState { + /// Active touches, if any. + /// + /// TouchId is the unique identifier of the touch. It is valid as long as + /// the finger/pen touches the surface. The next touch will receive a + /// new unique ID. + /// + /// Refer to [`ActiveTouch`]. + active_touches: BTreeMap, + + /// If a gesture has been recognized (i.e. when exactly two fingers touch + /// the surface), this holds state information + gesture_state: Option, + + added_or_removed_touches: bool, +} + +#[derive(Clone, Debug)] +struct GestureState { + pinch_type: PinchType, + previous: Option, + current: DynGestureState, +} + +/// Gesture data that can change over time +#[derive(Clone, Copy, Debug)] +struct DynGestureState { + /// used for proportional zooming + avg_distance: f64, + /// used for non-proportional zooming + avg_abs_distance2: Vec2, + avg_pos: Point, + heading: f64, +} + +/// Describes an individual touch (finger or digitizer) on the touch surface. +/// Instances exist as long as the finger/pen touches the surface. +#[derive(Clone, Copy, Debug)] +struct ActiveTouch { + /// Current position of this touch, in device coordinates (not necessarily + /// screen position) + pos: Point, +} + +impl TouchState { + pub fn new() -> Self { + Self { + active_touches: Default::default(), + gesture_state: None, + added_or_removed_touches: false, + } + } + + pub fn add_event(&mut self, event: &Touch) { + let pos = Point::new(event.location.x, event.location.y); + match event.phase { + TouchPhase::Started => { + self.active_touches.insert(event.id, ActiveTouch { pos }); + self.added_or_removed_touches = true; + } + TouchPhase::Moved => { + if let Some(touch) = self.active_touches.get_mut(&event.id) { + touch.pos = Point::new(event.location.x, event.location.y); + } + } + TouchPhase::Ended | TouchPhase::Cancelled => { + self.active_touches.remove(&event.id); + self.added_or_removed_touches = true; + } + } + } + + pub fn end_frame(&mut self) { + // This needs to be called each frame, even if there are no new touch + // events. Otherwise, we would send the same old delta + // information multiple times: + self.update_gesture(); + + if self.added_or_removed_touches { + // Adding or removing fingers makes the average values "jump". We + // better forget about the previous values, and don't + // create delta information for this frame: + if let Some(ref mut state) = &mut self.gesture_state { + state.previous = None; + } + } + self.added_or_removed_touches = false; + } + + pub fn info(&self) -> Option { + self.gesture_state.as_ref().map(|state| { + // state.previous can be `None` when the number of simultaneous + // touches has just changed. In this case, we take + // `current` as `previous`, pretending that there was no + // change for the current frame. + let state_previous = state.previous.unwrap_or(state.current); + + let zoom_delta = if self.active_touches.len() > 1 { + state.current.avg_distance / state_previous.avg_distance + } else { + 1. + }; + + let zoom_delta2 = if self.active_touches.len() > 1 { + match state.pinch_type { + PinchType::Horizontal => Vec2::new( + state.current.avg_abs_distance2.x + / state_previous.avg_abs_distance2.x, + 1.0, + ), + PinchType::Vertical => Vec2::new( + 1.0, + state.current.avg_abs_distance2.y + / state_previous.avg_abs_distance2.y, + ), + PinchType::Proportional => { + Vec2::new(zoom_delta, zoom_delta) + } + } + } else { + Vec2::new(1.0, 1.0) + }; + + MultiTouchInfo { + num_touches: self.active_touches.len(), + zoom_delta, + zoom_delta_2d: zoom_delta2, + zoom_centre: state.current.avg_pos, + rotation_delta: (state.current.heading + - state_previous.heading), + translation_delta: state.current.avg_pos + - state_previous.avg_pos, + } + }) + } + + fn update_gesture(&mut self) { + if let Some(dyn_state) = self.calc_dynamic_state() { + if let Some(ref mut state) = &mut self.gesture_state { + // updating an ongoing gesture + state.previous = Some(state.current); + state.current = dyn_state; + } else { + // starting a new gesture + self.gesture_state = Some(GestureState { + pinch_type: PinchType::classify(&self.active_touches), + previous: None, + current: dyn_state, + }); + } + } else { + // the end of a gesture (if there is any) + self.gesture_state = None; + } + } + + /// `None` if less than two fingers + fn calc_dynamic_state(&self) -> Option { + let num_touches = self.active_touches.len(); + if num_touches == 0 { + return None; + } + let mut state = DynGestureState { + avg_distance: 0.0, + avg_abs_distance2: Vec2::ZERO, + avg_pos: Point::ZERO, + heading: 0.0, + }; + let num_touches_recip = 1. / num_touches as f64; + + // first pass: calculate force and center of touch positions: + for touch in self.active_touches.values() { + state.avg_pos.x += touch.pos.x; + state.avg_pos.y += touch.pos.y; + } + state.avg_pos.x *= num_touches_recip; + state.avg_pos.y *= num_touches_recip; + + // second pass: calculate distances from center: + for touch in self.active_touches.values() { + state.avg_distance += state.avg_pos.distance(touch.pos); + state.avg_abs_distance2.x += (state.avg_pos.x - touch.pos.x).abs(); + state.avg_abs_distance2.y += (state.avg_pos.y - touch.pos.y).abs(); + } + state.avg_distance *= num_touches_recip; + state.avg_abs_distance2 *= num_touches_recip; + + // Calculate the direction from the first touch to the center position. + // This is not the perfect way of calculating the direction if more than + // two fingers are involved, but as long as all fingers rotate + // more or less at the same angular velocity, the shortcomings + // of this method will not be noticed. One can see the + // issues though, when touching with three or more fingers, and moving + // only one of them (it takes two hands to do this in a + // controlled manner). A better technique would be to store the + // current and previous directions (with reference to the center) for + // each touch individually, and then calculate the average of + // all individual changes in direction. But this approach cannot + // be implemented locally in this method, making everything a + // bit more complicated. + let first_touch = self.active_touches.values().next().unwrap(); + state.heading = (state.avg_pos - first_touch.pos).atan2(); + + Some(state) + } +} + +impl Debug for TouchState { + // This outputs less clutter than `#[derive(Debug)]`: + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (id, touch) in &self.active_touches { + f.write_fmt(format_args!("#{:?}: {:#?}\n", id, touch))?; + } + f.write_fmt(format_args!("gesture: {:#?}\n", self.gesture_state))?; + Ok(()) + } +} + +#[derive(Clone, Debug)] +enum PinchType { + Horizontal, + Vertical, + Proportional, +} + +impl PinchType { + fn classify(touches: &BTreeMap) -> Self { + // For non-proportional 2d zooming: + // If the user is pinching with two fingers that have roughly the same Y + // coord, then the Y zoom is unstable and should be 1. + // Similarly, if the fingers are directly above/below each other, + // we should only zoom on the Y axis. + // If the fingers are roughly on a diagonal, we revert to the + // proportional zooming. + + if touches.len() == 2 { + let mut touches = touches.values(); + let t0 = touches.next().unwrap().pos; + let t1 = touches.next().unwrap().pos; + + let dx = (t0.x - t1.x).abs(); + let dy = (t0.y - t1.y).abs(); + + if dx > 3.0 * dy { + Self::Horizontal + } else if dy > 3.0 * dx { + Self::Vertical + } else { + Self::Proportional + } + } else { + Self::Proportional + } + } +} diff --git a/examples/with_winit/src/stats.rs b/examples/with_winit/src/stats.rs new file mode 100644 index 0000000..aa80036 --- /dev/null +++ b/examples/with_winit/src/stats.rs @@ -0,0 +1,479 @@ +// Copyright 2023 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use scenes::RobotoText; +use std::{collections::VecDeque, time::Duration}; +use vello::{ + kurbo::{Affine, Line, PathEl, Rect, Stroke}, + peniko::{Brush, Color, Fill}, + AaConfig, BumpAllocators, Scene, +}; +use wgpu_profiler::GpuTimerQueryResult; + +const SLIDING_WINDOW_SIZE: usize = 100; + +#[derive(Debug)] +pub struct Snapshot { + pub fps: f64, + pub frame_time_ms: f64, + pub frame_time_min_ms: f64, + pub frame_time_max_ms: f64, +} + +impl Snapshot { + #[allow(clippy::too_many_arguments)] + pub fn draw_layer<'a, T>( + &self, + scene: &mut Scene, + text: &mut RobotoText, + viewport_width: f64, + viewport_height: f64, + samples: T, + bump: Option, + vsync: bool, + aa_config: AaConfig, + ) where + T: Iterator, + { + let width = (viewport_width * 0.4).max(200.).min(600.); + let height = width * 0.7; + let x_offset = viewport_width - width; + let y_offset = viewport_height - height; + let offset = Affine::translate((x_offset, y_offset)); + + // Draw the background + scene.fill( + Fill::NonZero, + offset, + &Brush::Solid(Color::rgba8(0, 0, 0, 200)), + None, + &Rect::new(0., 0., width, height), + ); + + let mut labels = vec![ + format!("Frame Time: {:.2} ms", self.frame_time_ms), + format!("Frame Time (min): {:.2} ms", self.frame_time_min_ms), + format!("Frame Time (max): {:.2} ms", self.frame_time_max_ms), + format!("VSync: {}", if vsync { "on" } else { "off" }), + format!( + "AA method: {}", + match aa_config { + AaConfig::Area => "Analytic Area", + AaConfig::Msaa16 => "16xMSAA", + AaConfig::Msaa8 => "8xMSAA", + } + ), + format!("Resolution: {viewport_width}x{viewport_height}"), + ]; + if let Some(bump) = &bump { + if bump.failed >= 1 { + labels.push("Allocation Failed!".into()); + } + labels.push(format!("binning: {}", bump.binning)); + labels.push(format!("ptcl: {}", bump.ptcl)); + labels.push(format!("tile: {}", bump.tile)); + labels.push(format!("segments: {}", bump.segments)); + labels.push(format!("blend: {}", bump.blend)); + } + + // height / 2 is dedicated to the text labels and the rest is filled by + // the bar graph. + let text_height = height * 0.5 / (1 + labels.len()) as f64; + let left_margin = width * 0.01; + let text_size = (text_height * 0.9) as f32; + for (i, label) in labels.iter().enumerate() { + text.add( + scene, + None, + text_size, + Some(&Brush::Solid(Color::WHITE)), + offset + * Affine::translate(( + left_margin, + (i + 1) as f64 * text_height, + )), + label, + ); + } + text.add( + scene, + None, + text_size, + Some(&Brush::Solid(Color::WHITE)), + offset * Affine::translate((width * 0.67, text_height)), + &format!("FPS: {:.2}", self.fps), + ); + + // Plot the samples with a bar graph + use PathEl::*; + let left_padding = width * 0.05; // Left padding for the frame time marker text. + let graph_max_height = height * 0.5; + let graph_max_width = width - 2. * left_margin - left_padding; + let left_margin_padding = left_margin + left_padding; + let bar_extent = graph_max_width / (SLIDING_WINDOW_SIZE as f64); + let bar_width = bar_extent * 0.4; + let bar = [ + MoveTo((0., graph_max_height).into()), + LineTo((0., 0.).into()), + LineTo((bar_width, 0.).into()), + LineTo((bar_width, graph_max_height).into()), + ]; + // We determine the scale of the graph based on the maximum sampled + // frame time unless it's greater than 3x the current average. + // In that case we cap the max scale at 4/3 * the + // current average (rounded up to the nearest multiple of 5ms). This + // allows the scale to adapt to the most recent sample set as + // relying on the maximum alone can make the displayed samples + // to look too small in the presence of spikes/fluctuation without + // manually resetting the max sample. + let display_max = if self.frame_time_max_ms > 3. * self.frame_time_ms { + round_up((1.33334 * self.frame_time_ms) as usize, 5) as f64 + } else { + self.frame_time_max_ms + }; + for (i, sample) in samples.enumerate() { + let t = offset + * Affine::translate((i as f64 * bar_extent, graph_max_height)); + // The height of each sample is based on its ratio to the maximum + // observed frame time. + let sample_ms = ((*sample as f64) * 0.001).min(display_max); + let h = sample_ms / display_max; + let s = Affine::scale_non_uniform(1., -h); + #[allow(clippy::match_overlapping_arm)] + let color = match *sample { + ..=16_667 => Color::rgb8(100, 143, 255), + ..=33_334 => Color::rgb8(255, 176, 0), + _ => Color::rgb8(220, 38, 127), + }; + scene.fill( + Fill::NonZero, + t * Affine::translate(( + left_margin_padding, + (1 + labels.len()) as f64 * text_height, + )) * s, + color, + None, + &bar, + ); + } + // Draw horizontal lines to mark 8.33ms, 16.33ms, and 33.33ms + let marker = [ + MoveTo((0., graph_max_height).into()), + LineTo((graph_max_width, graph_max_height).into()), + ]; + let thresholds = [8.33, 16.66, 33.33]; + let thres_text_height = graph_max_height * 0.05; + let thres_text_height_2 = thres_text_height * 0.5; + for t in thresholds.iter().filter(|&&t| t < display_max) { + let y = t / display_max; + text.add( + scene, + None, + thres_text_height as f32, + Some(&Brush::Solid(Color::WHITE)), + offset + * Affine::translate(( + left_margin, + (2. - y) * graph_max_height + thres_text_height_2, + )), + &format!("{}", t), + ); + scene.stroke( + &Stroke::new(graph_max_height * 0.01), + offset + * Affine::translate(( + left_margin_padding, + (1. - y) * graph_max_height, + )), + Color::WHITE, + None, + &marker, + ); + } + } +} + +pub struct Sample { + pub frame_time_us: u64, +} + +pub struct Stats { + count: usize, + sum: u64, + min: u64, + max: u64, + samples: VecDeque, +} + +impl Stats { + pub fn new() -> Stats { + Stats { + count: 0, + sum: 0, + min: u64::MAX, + max: u64::MIN, + samples: VecDeque::with_capacity(SLIDING_WINDOW_SIZE), + } + } + + pub fn samples(&self) -> impl Iterator { + self.samples.iter() + } + + pub fn snapshot(&self) -> Snapshot { + let frame_time_ms = (self.sum as f64 / self.count as f64) * 0.001; + let fps = 1000. / frame_time_ms; + Snapshot { + fps, + frame_time_ms, + frame_time_min_ms: self.min as f64 * 0.001, + frame_time_max_ms: self.max as f64 * 0.001, + } + } + + pub fn clear_min_and_max(&mut self) { + self.min = u64::MAX; + self.max = u64::MIN; + } + + pub fn add_sample(&mut self, sample: Sample) { + let oldest = if self.count < SLIDING_WINDOW_SIZE { + self.count += 1; + None + } else { + self.samples.pop_front() + }; + let micros = sample.frame_time_us; + self.sum += micros; + self.samples.push_back(micros); + if let Some(oldest) = oldest { + self.sum -= oldest; + } + self.min = self.min.min(micros); + self.max = self.max.max(micros); + } +} + +fn round_up(n: usize, f: usize) -> usize { + n - 1 - (n - 1) % f + f +} + +const COLORS: &[Color] = &[ + Color::AQUA, + Color::RED, + Color::ALICE_BLUE, + Color::YELLOW, + Color::GREEN, + Color::BLUE, + Color::ORANGE, + Color::WHITE, +]; + +pub fn draw_gpu_profiling( + scene: &mut Scene, + text: &mut RobotoText, + viewport_width: f64, + viewport_height: f64, + profiles: &[GpuTimerQueryResult], +) { + if profiles.is_empty() { + return; + } + let width = (viewport_width * 0.3).clamp(150., 450.); + let height = width * 1.5; + let y_offset = viewport_height - height; + let offset = Affine::translate((0., y_offset)); + + // Draw the background + scene.fill( + Fill::NonZero, + offset, + &Brush::Solid(Color::rgba8(0, 0, 0, 200)), + None, + &Rect::new(0., 0., width, height), + ); + // Find the range of the samples, so we can normalise them + let mut min = f64::MAX; + let mut max = f64::MIN; + let mut max_depth = 0; + let mut depth = 0; + let mut count = 0; + traverse_profiling(profiles, &mut |profile, stage| { + match stage { + TraversalStage::Enter => { + count += 1; + min = min.min(profile.time.start); + max = max.max(profile.time.end); + max_depth = max_depth.max(depth); + // Apply a higher depth to the children + depth += 1; + } + TraversalStage::Leave => depth -= 1, + } + }); + let total_time = max - min; + { + let labels = [ + format!("GPU Time: {:.2?}", Duration::from_secs_f64(total_time)), + "Press P to save a trace".to_string(), + ]; + + // height / 5 is dedicated to the text labels and the rest is filled by + // the frame time. + let text_height = height * 0.2 / (1 + labels.len()) as f64; + let left_margin = width * 0.01; + let text_size = (text_height * 0.9) as f32; + for (i, label) in labels.iter().enumerate() { + text.add( + scene, + None, + text_size, + Some(&Brush::Solid(Color::WHITE)), + offset + * Affine::translate(( + left_margin, + (i + 1) as f64 * text_height, + )), + label, + ); + } + + let text_size = (text_height * 0.9) as f32; + for (i, label) in labels.iter().enumerate() { + text.add( + scene, + None, + text_size, + Some(&Brush::Solid(Color::WHITE)), + offset + * Affine::translate(( + left_margin, + (i + 1) as f64 * text_height, + )), + label, + ); + } + } + let timeline_start_y = height * 0.21; + let timeline_range_y = height * 0.78; + let timeline_range_end = timeline_start_y + timeline_range_y; + + // Add 6 items worth of margin + let text_height = timeline_range_y / (6 + count) as f64; + let left_margin = width * 0.35; + let mut cur_text_y = timeline_start_y; + let mut cur_index = 0; + let mut depth = 0; + // Leave 1 bar's worth of margin + let depth_width = width * 0.28 / (max_depth + 1) as f64; + let depth_size = depth_width * 0.8; + traverse_profiling(profiles, &mut |profile, stage| { + if let TraversalStage::Enter = stage { + let start_normalised = ((profile.time.start - min) / total_time) + * timeline_range_y + + timeline_start_y; + let end_normalised = ((profile.time.end - min) / total_time) + * timeline_range_y + + timeline_start_y; + + let color = COLORS[cur_index % COLORS.len()]; + let x = width * 0.01 + (depth as f64 * depth_width); + scene.fill( + Fill::NonZero, + offset, + &Brush::Solid(color), + None, + &Rect::new(x, start_normalised, x + depth_size, end_normalised), + ); + + let mut text_start = start_normalised; + let nested = !profile.nested_queries.is_empty(); + if nested { + // If we have children, leave some more space for them + text_start -= text_height * 0.7; + } + let this_time = profile.time.end - profile.time.start; + // Highlight as important if more than 10% of the total time, or + // more than 1ms + let slow = this_time * 20. >= total_time || this_time >= 0.001; + let text_y = text_start + // Ensure that we don't overlap the previous item + .max(cur_text_y) + // Ensure that all remaining items can fit + .min( + timeline_range_end + - (count - cur_index) as f64 * text_height, + ); + let (text_height, text_color) = if slow { + (text_height, Color::WHITE) + } else { + (text_height * 0.6, Color::LIGHT_GRAY) + }; + let text_size = (text_height * 0.9) as f32; + // Text is specified by the baseline, but the y positions all refer + // to the top of the text + cur_text_y = text_y + text_height; + let label = format!( + "{:.2?} - {:.30}", + Duration::from_secs_f64(this_time), + profile.label + ); + scene.fill( + Fill::NonZero, + offset, + &Brush::Solid(color), + None, + &Rect::new( + width * 0.31, + cur_text_y - text_size as f64 * 0.7, + width * 0.34, + cur_text_y, + ), + ); + text.add( + scene, + None, + text_size, + Some(&Brush::Solid(text_color)), + offset * Affine::translate((left_margin, cur_text_y)), + &label, + ); + if !nested && slow { + scene.stroke( + &Stroke::new(2.), + offset, + &Brush::Solid(color), + None, + &Line::new( + ( + x + depth_size, + (end_normalised + start_normalised) / 2., + ), + (width * 0.31, cur_text_y - text_size as f64 * 0.35), + ), + ); + } + cur_index += 1; + // Higher depth applies only to the children + depth += 1; + } else { + depth -= 1; + } + }); +} + +enum TraversalStage { + Enter, + Leave, +} + +fn traverse_profiling( + profiles: &[GpuTimerQueryResult], + callback: &mut impl FnMut(&GpuTimerQueryResult, TraversalStage), +) { + for profile in profiles { + callback(profile, TraversalStage::Enter); + traverse_profiling(&profile.nested_queries, &mut *callback); + callback(profile, TraversalStage::Leave); + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..aba92e1 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +max_width = 80 +comment_width = 80 +wrap_comments = true +imports_granularity = "Crate" diff --git a/src/geom.rs b/src/geom.rs new file mode 100644 index 0000000..dedddf6 --- /dev/null +++ b/src/geom.rs @@ -0,0 +1,58 @@ +// Copyright 2018 Yevhenii Reizner +// SPDX-License-Identifier: MPL-2.0 + +// copied from https://github.com/RazrFalcon/resvg/blob/4d27b8c3be3ecfff3256c9be1b8362eb22533659/crates/resvg/src/geom.rs + +/// Converts `viewBox` to `Transform` with an optional clip rectangle. +/// +/// Unlike `view_box_to_transform`, returns an optional clip rectangle +/// that should be applied before rendering the image. +pub fn view_box_to_transform_with_clip( + view_box: &usvg::ViewBox, + img_size: usvg::tiny_skia_path::IntSize, +) -> (usvg::Transform, Option) { + let r = view_box.rect; + + let new_size = fit_view_box(img_size.to_size(), view_box); + + let (tx, ty, clip) = if view_box.aspect.slice { + let (dx, dy) = usvg::utils::aligned_pos( + view_box.aspect.align, + 0.0, + 0.0, + new_size.width() - r.width(), + new_size.height() - r.height(), + ); + + (r.x() - dx, r.y() - dy, Some(r)) + } else { + let (dx, dy) = usvg::utils::aligned_pos( + view_box.aspect.align, + r.x(), + r.y(), + r.width() - new_size.width(), + r.height() - new_size.height(), + ); + + (dx, dy, None) + }; + + let sx = new_size.width() / img_size.width() as f32; + let sy = new_size.height() / img_size.height() as f32; + let ts = usvg::Transform::from_row(sx, 0.0, 0.0, sy, tx, ty); + + (ts, clip) +} + +/// Fits size into a viewbox. +pub fn fit_view_box(size: usvg::Size, vb: &usvg::ViewBox) -> usvg::Size { + let s = vb.rect.size(); + + if vb.aspect.align == usvg::Align::None { + s + } else if vb.aspect.slice { + size.expand_to(s) + } else { + size.scale_to(s) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ae04756 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,551 @@ +// Copyright 2023 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Append a [`usvg::Tree`] to a Vello [`Scene`] +//! +//! This currently lacks support for a [number of important](crate#unsupported-features) SVG features. +//! This is because this integration was developed for examples, which only need to support enough SVG +//! to demonstrate Vello. +//! +//! However, this is also intended to be the preferred integration between Vello and [usvg], so [consider +//! contributing](https://github.com/linebender/vello) if you need a feature which is missing. +//! +//! [`render_tree_with`] is the primary entry point function, which supports choosing the behaviour +//! when [unsupported features](crate#unsupported-features) are detected. In a future release where there are +//! no unsupported features, this may be phased out +//! +//! [`render_tree`] is a convenience wrapper around [`render_tree_with`] which renders an indicator around not +//! yet supported features +//! +//! This crate also re-exports [`usvg`], to make handling dependency versions easier +//! +//! # Unsupported features +//! +//! Missing features include: +//! - text +//! - group opacity +//! - mix-blend-modes +//! - clipping +//! - masking +//! - filter effects +//! - group background +//! - path shape-rendering +//! - patterns + +mod geom; + +use std::convert::Infallible; +use std::sync::Arc; +use vello::kurbo::{Affine, BezPath, Point, Rect, Stroke}; +use vello::peniko::{BlendMode, Blob, Brush, Color, Fill, Image}; +use vello::Scene; + +/// Re-export vello. +pub use vello; + +/// Re-export usvg. +pub use usvg; + +/// Append a [`usvg::Tree`] into a Vello [`Scene`], with default error handling +/// This will draw a red box over (some) unsupported elements +/// +/// Calls [`render_tree_with`] with an error handler implementing the above. +/// +/// See the [module level documentation](crate#unsupported-features) for a list of some unsupported svg features +pub fn render_tree(scene: &mut Scene, svg: &usvg::Tree) { + render_tree_with::<_, Infallible>( + scene, + svg, + &usvg::Transform::identity(), + &mut default_error_handler, + ) + .unwrap_or_else(|e| match e {}); +} + +/// Append a [`usvg::Tree`] into a Vello [`Scene`]. +/// +/// Calls [`render_tree_with`] with [`default_error_handler`]. +/// This will draw a red box over unsupported element types. +/// +/// See the [module level documentation](crate#unsupported-features) for a list of some unsupported svg features +pub fn render_tree_with< + F: FnMut(&mut Scene, &usvg::Node) -> Result<(), E>, + E, +>( + scene: &mut Scene, + svg: &usvg::Tree, + ts: &usvg::Transform, + error_handler: &mut F, +) -> Result<(), E> { + render_tree_impl(scene, svg, &svg.view_box(), ts, error_handler) +} + +fn render_tree_impl Result<(), E>, E>( + scene: &mut Scene, + svg: &usvg::Tree, + view_box: &usvg::ViewBox, + ts: &usvg::Transform, + error_handler: &mut F, +) -> Result<(), E> { + let transform = to_affine(ts); + scene.push_layer( + BlendMode { + mix: vello::peniko::Mix::Clip, + compose: vello::peniko::Compose::SrcOver, + }, + 1.0, + transform, + &vello::kurbo::Rect::new( + view_box.rect.left().into(), + view_box.rect.top().into(), + view_box.rect.right().into(), + view_box.rect.bottom().into(), + ), + ); + let (view_box_transform, clip) = geom::view_box_to_transform_with_clip( + view_box, + svg.size().to_int_size(), + ); + if let Some(clip) = clip { + scene.push_layer( + BlendMode { + mix: vello::peniko::Mix::Clip, + compose: vello::peniko::Compose::SrcOver, + }, + 1.0, + transform, + &vello::kurbo::Rect::new( + clip.left().into(), + clip.top().into(), + clip.right().into(), + clip.bottom().into(), + ), + ); + } + render_group( + scene, + svg.root(), + &ts.pre_concat(view_box_transform) + .pre_concat(svg.root().transform()), + error_handler, + )?; + if clip.is_some() { + scene.pop_layer(); + } + scene.pop_layer(); + + Ok(()) +} + +fn render_group Result<(), E>, E>( + scene: &mut Scene, + group: &usvg::Group, + ts: &usvg::Transform, + error_handler: &mut F, +) -> Result<(), E> { + for node in group.children() { + let transform = to_affine(ts); + match node { + usvg::Node::Group(g) => { + let mut pushed_clip = false; + if let Some(clip_path) = g.clip_path() { + if let Some(usvg::Node::Path(clip_path)) = + clip_path.root().children().first() + { + // support clip-path with a single path + let local_path = to_bez_path(clip_path); + scene.push_layer( + BlendMode { + mix: vello::peniko::Mix::Clip, + compose: vello::peniko::Compose::SrcOver, + }, + 1.0, + transform, + &local_path, + ); + pushed_clip = true; + } + } + + render_group( + scene, + g, + &ts.pre_concat(g.transform()), + error_handler, + )?; + + if pushed_clip { + scene.pop_layer(); + } + } + usvg::Node::Path(path) => { + if path.visibility() != usvg::Visibility::Visible { + continue; + } + let local_path = to_bez_path(path); + + let do_fill = |scene: &mut Scene, error_handler: &mut F| { + if let Some(fill) = &path.fill() { + if let Some((brush, brush_transform)) = + paint_to_brush(fill.paint(), fill.opacity()) + { + scene.fill( + match fill.rule() { + usvg::FillRule::NonZero => Fill::NonZero, + usvg::FillRule::EvenOdd => Fill::EvenOdd, + }, + transform, + &brush, + Some(brush_transform), + &local_path, + ); + } else { + return error_handler(scene, node); + } + } + Ok(()) + }; + let do_stroke = |scene: &mut Scene, error_handler: &mut F| { + if let Some(stroke) = &path.stroke() { + if let Some((brush, brush_transform)) = + paint_to_brush(stroke.paint(), stroke.opacity()) + { + let mut conv_stroke = + Stroke::new(stroke.width().get() as f64) + .with_caps(match stroke.linecap() { + usvg::LineCap::Butt => { + vello::kurbo::Cap::Butt + } + usvg::LineCap::Round => { + vello::kurbo::Cap::Round + } + usvg::LineCap::Square => { + vello::kurbo::Cap::Square + } + }) + .with_join(match stroke.linejoin() { + usvg::LineJoin::Miter + | usvg::LineJoin::MiterClip => { + vello::kurbo::Join::Miter + } + usvg::LineJoin::Round => { + vello::kurbo::Join::Round + } + usvg::LineJoin::Bevel => { + vello::kurbo::Join::Bevel + } + }) + .with_miter_limit( + stroke.miterlimit().get() as f64, + ); + if let Some(dash_array) = + stroke.dasharray().as_ref() + { + conv_stroke = conv_stroke.with_dashes( + stroke.dashoffset() as f64, + dash_array.iter().map(|x| *x as f64), + ); + } + scene.stroke( + &conv_stroke, + transform, + &brush, + Some(brush_transform), + &local_path, + ); + } else { + return error_handler(scene, node); + } + } + Ok(()) + }; + match path.paint_order() { + usvg::PaintOrder::FillAndStroke => { + do_fill(scene, error_handler)?; + do_stroke(scene, error_handler)?; + } + usvg::PaintOrder::StrokeAndFill => { + do_stroke(scene, error_handler)?; + do_fill(scene, error_handler)?; + } + } + } + usvg::Node::Image(img) => { + if img.visibility() != usvg::Visibility::Visible { + continue; + } + match img.kind() { + usvg::ImageKind::JPEG(_) + | usvg::ImageKind::PNG(_) + | usvg::ImageKind::GIF(_) => { + let Ok(decoded_image) = + decode_raw_raster_image(img.kind()) + else { + error_handler(scene, node)?; + continue; + }; + let Some(size) = usvg::Size::from_wh( + decoded_image.width() as f32, + decoded_image.height() as f32, + ) else { + error_handler(scene, node)?; + continue; + }; + let view_box = img.view_box(); + let new_size = geom::fit_view_box(size, &view_box); + let (tx, ty) = usvg::utils::aligned_pos( + view_box.aspect.align, + view_box.rect.x(), + view_box.rect.y(), + view_box.rect.width() - new_size.width(), + view_box.rect.height() - new_size.height(), + ); + let (sx, sy) = ( + new_size.width() / size.width(), + new_size.height() / size.height(), + ); + let view_box_transform = + usvg::Transform::from_row(sx, 0.0, 0.0, sy, tx, ty); + let (width, height) = + (decoded_image.width(), decoded_image.height()); + scene.push_layer( + BlendMode { + mix: vello::peniko::Mix::Clip, + compose: vello::peniko::Compose::SrcOver, + }, + 1.0, + transform, + &vello::kurbo::Rect::new( + view_box.rect.left().into(), + view_box.rect.top().into(), + view_box.rect.right().into(), + view_box.rect.bottom().into(), + ), + ); + + let image_ts = + to_affine(&ts.pre_concat(view_box_transform)); + let image_data: Arc> = + decoded_image.into_vec().into(); + scene.draw_image( + &Image::new( + Blob::new(image_data), + vello::peniko::Format::Rgba8, + width, + height, + ), + image_ts, + ); + + scene.pop_layer(); + } + usvg::ImageKind::SVG(svg) => { + render_tree_impl( + scene, + svg, + &img.view_box(), + ts, + error_handler, + )?; + } + } + } + usvg::Node::Text(_) => { + error_handler(scene, node)?; + } + } + } + + Ok(()) +} + +fn decode_raw_raster_image( + img: &usvg::ImageKind, +) -> Result { + let res = match img { + usvg::ImageKind::JPEG(data) => { + image::load_from_memory_with_format(data, image::ImageFormat::Jpeg) + } + usvg::ImageKind::PNG(data) => { + image::load_from_memory_with_format(data, image::ImageFormat::Png) + } + usvg::ImageKind::GIF(data) => { + image::load_from_memory_with_format(data, image::ImageFormat::Gif) + } + usvg::ImageKind::SVG(_) => unreachable!(), + }? + .into_rgba8(); + Ok(res) +} + +fn to_affine(ts: &usvg::Transform) -> Affine { + let usvg::Transform { + sx, + kx, + ky, + sy, + tx, + ty, + } = ts; + Affine::new([sx, kx, ky, sy, tx, ty].map(|&x| f64::from(x))) +} + +fn to_bez_path(path: &usvg::Path) -> BezPath { + let mut local_path = BezPath::new(); + // The semantics of SVG paths don't line up with `BezPath`; we + // must manually track initial points + let mut just_closed = false; + let mut most_recent_initial = (0., 0.); + for elt in path.data().segments() { + match elt { + usvg::tiny_skia_path::PathSegment::MoveTo(p) => { + if std::mem::take(&mut just_closed) { + local_path.move_to(most_recent_initial); + } + most_recent_initial = (p.x.into(), p.y.into()); + local_path.move_to(most_recent_initial) + } + usvg::tiny_skia_path::PathSegment::LineTo(p) => { + if std::mem::take(&mut just_closed) { + local_path.move_to(most_recent_initial); + } + local_path.line_to(Point::new(p.x as f64, p.y as f64)) + } + usvg::tiny_skia_path::PathSegment::QuadTo(p1, p2) => { + if std::mem::take(&mut just_closed) { + local_path.move_to(most_recent_initial); + } + local_path.quad_to( + Point::new(p1.x as f64, p1.y as f64), + Point::new(p2.x as f64, p2.y as f64), + ) + } + usvg::tiny_skia_path::PathSegment::CubicTo(p1, p2, p3) => { + if std::mem::take(&mut just_closed) { + local_path.move_to(most_recent_initial); + } + local_path.curve_to( + Point::new(p1.x as f64, p1.y as f64), + Point::new(p2.x as f64, p2.y as f64), + Point::new(p3.x as f64, p3.y as f64), + ) + } + usvg::tiny_skia_path::PathSegment::Close => { + just_closed = true; + local_path.close_path() + } + } + } + + local_path +} + +/// Error handler function for [`render_tree_with`] which draws a transparent red box +/// instead of unsupported SVG features +pub fn default_error_handler( + scene: &mut Scene, + node: &usvg::Node, +) -> Result<(), Infallible> { + let bb = node.bounding_box(); + let rect = Rect { + x0: bb.left() as f64, + y0: bb.top() as f64, + x1: bb.right() as f64, + y1: bb.bottom() as f64, + }; + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + Color::RED.with_alpha_factor(0.5), + None, + &rect, + ); + + Ok(()) +} + +fn paint_to_brush( + paint: &usvg::Paint, + opacity: usvg::Opacity, +) -> Option<(Brush, Affine)> { + match paint { + usvg::Paint::Color(color) => Some(( + Brush::Solid(Color::rgba8( + color.red, + color.green, + color.blue, + opacity.to_u8(), + )), + Affine::IDENTITY, + )), + usvg::Paint::LinearGradient(gr) => { + let stops: Vec = gr + .stops() + .iter() + .map(|stop| { + let mut cstop = vello::peniko::ColorStop::default(); + cstop.color.r = stop.color().red; + cstop.color.g = stop.color().green; + cstop.color.b = stop.color().blue; + cstop.color.a = (stop.opacity() * opacity).to_u8(); + cstop.offset = stop.offset().get(); + cstop + }) + .collect(); + let start = Point::new(gr.x1() as f64, gr.y1() as f64); + let end = Point::new(gr.x2() as f64, gr.y2() as f64); + let arr = [ + gr.transform().sx, + gr.transform().ky, + gr.transform().kx, + gr.transform().sy, + gr.transform().tx, + gr.transform().ty, + ] + .map(f64::from); + let transform = Affine::new(arr); + let gradient = vello::peniko::Gradient::new_linear(start, end) + .with_stops(stops.as_slice()); + Some((Brush::Gradient(gradient), transform)) + } + usvg::Paint::RadialGradient(gr) => { + let stops: Vec = gr + .stops() + .iter() + .map(|stop| { + let mut cstop = vello::peniko::ColorStop::default(); + cstop.color.r = stop.color().red; + cstop.color.g = stop.color().green; + cstop.color.b = stop.color().blue; + cstop.color.a = (stop.opacity() * opacity).to_u8(); + cstop.offset = stop.offset().get(); + cstop + }) + .collect(); + + let start_center = Point::new(gr.cx() as f64, gr.cy() as f64); + let end_center = Point::new(gr.fx() as f64, gr.fy() as f64); + let start_radius = 0_f32; + let end_radius = gr.r().get(); + let arr = [ + gr.transform().sx, + gr.transform().ky, + gr.transform().kx, + gr.transform().sy, + gr.transform().tx, + gr.transform().ty, + ] + .map(f64::from); + let transform = Affine::new(arr); + let gradient = vello::peniko::Gradient::new_two_point_radial( + start_center, + start_radius, + end_center, + end_radius, + ) + .with_stops(stops.as_slice()); + Some((Brush::Gradient(gradient), transform)) + } + usvg::Paint::Pattern(_) => None, + } +} diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 From 49e4894d5cfc33bf1e879b411779ea08492eca70 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 08:21:01 -0500 Subject: [PATCH 02/34] Update Cargo.toml Co-authored-by: Kaur Kuut --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 589dd7a..f75de5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["examples/with_winit", "examples/run_wasm", "examples/scenes"] [workspace.package] edition = "2021" version = "0.1.0" -license = "MIT OR Apache-2.0 AND MPL-2" +license = "(Apache-2.0 OR MIT) AND MPL-2.0" repository = "https://github.com/linebender/vello_svg" [workspace.dependencies] From c5153a888baa8628543d447509de01267719c0cd Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 08:34:54 -0500 Subject: [PATCH 03/34] Update rustfmt.toml Co-authored-by: Kaur Kuut --- rustfmt.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rustfmt.toml b/rustfmt.toml index aba92e1..a18b909 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,4 +1,3 @@ -max_width = 80 -comment_width = 80 -wrap_comments = true -imports_granularity = "Crate" +max_width = 100 +use_field_init_shorthand = true +newline_style = "Unix" From 25eb0e080f4c7134c6e4f5aefcbdc060e872cfe1 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 09:30:18 -0500 Subject: [PATCH 04/34] feat: remove dependabot --- .github/dependabot.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index f81ed0f..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -updates: -- package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - -- package-ecosystem: "cargo" - directory: "/" - schedule: - interval: "weekly" From c1fc37067e6776e342d8987e717049b523d298b6 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 09:31:17 -0500 Subject: [PATCH 05/34] refactor: fmt --- examples/scenes/src/download.rs | 19 ++-- examples/scenes/src/simple_text.rs | 6 +- examples/scenes/src/svg.rs | 34 +++----- examples/scenes/src/test_scenes.rs | 5 +- examples/with_winit/src/hot_reload.rs | 8 +- examples/with_winit/src/lib.rs | 19 ++-- examples/with_winit/src/multi_touch.rs | 16 ++-- examples/with_winit/src/stats.rs | 47 +++------- src/geom.rs | 116 ++++++++++++------------- src/lib.rs | 107 +++++++---------------- 10 files changed, 131 insertions(+), 246 deletions(-) diff --git a/examples/scenes/src/download.rs b/examples/scenes/src/download.rs index 10f4f15..258c1ea 100644 --- a/examples/scenes/src/download.rs +++ b/examples/scenes/src/download.rs @@ -65,10 +65,8 @@ impl Download { println!( "{} ({}) under license {} from {}", download.name, - byte_unit::Byte::from_bytes( - builtin.expected_size.into() - ) - .get_appropriate_unit(false), + byte_unit::Byte::from_bytes(builtin.expected_size.into()) + .get_appropriate_unit(false), builtin.license, builtin.info ); @@ -77,8 +75,7 @@ impl Download { // For rustfmt, split prompt into its own line const PROMPT: &str = "Would you like to download a set of default svg files, as explained above?"; - accepted = - Confirm::new(PROMPT).with_default(false).prompt()?; + accepted = Confirm::new(PROMPT).with_default(false).prompt()?; } else { println!("Nothing to download! All default downloads already created"); } @@ -111,8 +108,7 @@ impl Download { if failed_count > 0 { println!("{} downloads failed", failed_count); } - let remaining = to_download.len() - - (completed_count + failed_count); + let remaining = to_download.len() - (completed_count + failed_count); if remaining > 0 { println!("{} downloads skipped", remaining); } @@ -177,9 +173,7 @@ impl SVGDownload { if limit_exact { let head_response = ureq::head(&self.url).call()?; let content_length = head_response.header("content-length"); - if let Some(Ok(content_length)) = - content_length.map(|it| it.parse::()) - { + if let Some(Ok(content_length)) = content_length.map(|it| it.parse::()) { if content_length != size_limit { bail!( "Size is not as expected for download. Expected {}, server reported {}", @@ -205,8 +199,7 @@ impl SVGDownload { bail!("Size limit exceeded"); } if limit_exact { - let bytes_downloaded = - file.stream_position().context("Checking file limit")?; + let bytes_downloaded = file.stream_position().context("Checking file limit")?; if bytes_downloaded != size_limit { bail!( "Builtin downloaded file was not as expected. Expected {size_limit}, received {bytes_downloaded}.", diff --git a/examples/scenes/src/simple_text.rs b/examples/scenes/src/simple_text.rs index bd8a6e6..785dc05 100644 --- a/examples/scenes/src/simple_text.rs +++ b/examples/scenes/src/simple_text.rs @@ -13,8 +13,7 @@ use vello::{ // This is very much a hack to get things working. // On Windows, can set this to "c:\\Windows\\Fonts\\seguiemj.ttf" to get color // emoji -const ROBOTO_FONT: &[u8] = - include_bytes!("../../assets/roboto/Roboto-Regular.ttf"); +const ROBOTO_FONT: &[u8] = include_bytes!("../../assets/roboto/Roboto-Regular.ttf"); pub struct RobotoText { font: Font, } @@ -95,8 +94,7 @@ impl RobotoText { return None; } let gid = charmap.map(ch).unwrap_or_default(); - let advance = - glyph_metrics.advance_width(gid).unwrap_or_default(); + let advance = glyph_metrics.advance_width(gid).unwrap_or_default(); let x = pen_x; pen_x += advance; Some(Glyph { diff --git a/examples/scenes/src/svg.rs b/examples/scenes/src/svg.rs index 73693ff..3039383 100644 --- a/examples/scenes/src/svg.rs +++ b/examples/scenes/src/svg.rs @@ -17,9 +17,7 @@ pub fn scene_from_files(files: &[PathBuf]) -> Result { scene_from_files_inner(files, || ()) } -pub fn default_scene( - command: impl FnOnce() -> clap::Command, -) -> Result { +pub fn default_scene(command: impl FnOnce() -> clap::Command) -> Result { let assets_dir = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../assets/") .canonicalize()?; @@ -54,9 +52,7 @@ fn scene_from_files_inner( let start_index = scenes.len(); for file in read_dir(path)? { let entry = file?; - if let Some(extension) = - Path::new(&entry.file_name()).extension() - { + if let Some(extension) = Path::new(&entry.file_name()).extension() { if extension == "svg" { count += 1; scenes.push(example_scene_of(entry.path())); @@ -64,8 +60,7 @@ fn scene_from_files_inner( } } // Ensure a consistent order within directories - scenes[start_index..] - .sort_by_key(|scene| scene.config.name.to_lowercase()); + scenes[start_index..].sort_by_key(|scene| scene.config.name.to_lowercase()); if count == 0 { empty_dir(); } @@ -83,9 +78,8 @@ fn example_scene_of(file: PathBuf) -> ExampleScene { .unwrap_or_else(|| "unknown".to_string()); ExampleScene { function: Box::new(svg_function_of(name.clone(), move || { - std::fs::read_to_string(&file).unwrap_or_else(|e| { - panic!("failed to read svg file {file:?}: {e}") - }) + std::fs::read_to_string(&file) + .unwrap_or_else(|e| panic!("failed to read svg file {file:?}: {e}")) })), config: crate::SceneConfig { animated: false, @@ -101,17 +95,13 @@ pub fn svg_function_of>( fn render_svg_contents(name: &str, contents: &str) -> (Scene, Vec2) { let start = Instant::now(); let fontdb = usvg::fontdb::Database::new(); - let svg = - usvg::Tree::from_str(contents, &usvg::Options::default(), &fontdb) - .unwrap_or_else(|e| { - panic!("failed to parse svg file {name}: {e}") - }); + let svg = usvg::Tree::from_str(contents, &usvg::Options::default(), &fontdb) + .unwrap_or_else(|e| panic!("failed to parse svg file {name}: {e}")); eprintln!("Parsed svg {name} in {:?}", start.elapsed()); let start = Instant::now(); let mut new_scene = Scene::new(); vello_svg::render_tree(&mut new_scene, &svg); - let resolution = - Vec2::new(svg.size().width() as f64, svg.size().height() as f64); + let resolution = Vec2::new(svg.size().width() as f64, svg.size().height() as f64); eprintln!("Encoded svg {name} in {:?}", start.elapsed()); (new_scene, resolution) } @@ -132,15 +122,11 @@ pub fn svg_function_of>( if cfg!(target_arch = "wasm32") || !params.interactive { let contents = contents.take().unwrap(); let contents = contents(); - let (scene_frag, resolution) = - render_svg_contents(&name, contents.as_ref()); + let (scene_frag, resolution) = render_svg_contents(&name, contents.as_ref()); scene.append(&scene_frag, None); params.resolution = Some(resolution); cached_scene = Some((scene_frag, resolution)); - #[cfg_attr( - target_arch = "wasm32", - allow(clippy::needless_return) - )] + #[cfg_attr(target_arch = "wasm32", allow(clippy::needless_return))] return; } #[cfg(not(target_arch = "wasm32"))] diff --git a/examples/scenes/src/test_scenes.rs b/examples/scenes/src/test_scenes.rs index e8fb905..7c4aa4b 100644 --- a/examples/scenes/src/test_scenes.rs +++ b/examples/scenes/src/test_scenes.rs @@ -62,10 +62,7 @@ fn splash_with_tiger() -> impl FnMut(&mut Scene, &mut SceneParams) { env!("CARGO_MANIFEST_DIR"), "/../assets/Ghostscript_Tiger.svg" )); - let mut tiger = crate::svg::svg_function_of( - "Ghostscript Tiger".to_string(), - move || contents, - ); + let mut tiger = crate::svg::svg_function_of("Ghostscript Tiger".to_string(), move || contents); move |scene, params| { tiger(scene, params); splash_screen(scene, params); diff --git a/examples/with_winit/src/hot_reload.rs b/examples/with_winit/src/hot_reload.rs index 9c317bc..ed51c41 100644 --- a/examples/with_winit/src/hot_reload.rs +++ b/examples/with_winit/src/hot_reload.rs @@ -6,17 +6,13 @@ use std::{path::Path, time::Duration}; use anyhow::Result; use notify_debouncer_mini::{new_debouncer, notify::*, DebounceEventResult}; -pub(crate) fn hot_reload( - mut f: impl FnMut() -> Option<()> + Send + 'static, -) -> Result { +pub(crate) fn hot_reload(mut f: impl FnMut() -> Option<()> + Send + 'static) -> Result { let mut debouncer = new_debouncer( Duration::from_millis(500), None, move |res: DebounceEventResult| match res { Ok(_) => f().unwrap(), - Err(errors) => { - errors.iter().for_each(|e| println!("Error {:?}", e)) - } + Err(errors) => errors.iter().for_each(|e| println!("Error {:?}", e)), }, )?; diff --git a/examples/with_winit/src/lib.rs b/examples/with_winit/src/lib.rs index b9cbd04..48d9470 100644 --- a/examples/with_winit/src/lib.rs +++ b/examples/with_winit/src/lib.rs @@ -123,8 +123,7 @@ fn run( let mut complexity_shown = false; let mut vsync_on = true; - const AA_CONFIGS: [AaConfig; 3] = - [AaConfig::Area, AaConfig::Msaa8, AaConfig::Msaa16]; + const AA_CONFIGS: [AaConfig; 3] = [AaConfig::Area, AaConfig::Msaa8, AaConfig::Msaa16]; // We allow cycling through AA configs in either direction, so use a signed // index let mut aa_config_ix: i32 = 0; @@ -568,9 +567,7 @@ fn run( .expect("run to completion"); } -fn create_window( - event_loop: &winit::event_loop::EventLoopWindowTarget, -) -> Arc { +fn create_window(event_loop: &winit::event_loop::EventLoopWindowTarget) -> Arc { use winit::{dpi::LogicalSize, window::WindowBuilder}; Arc::new( WindowBuilder::new() @@ -617,8 +614,7 @@ pub fn main() -> Result<()> { let args = Args::parse(); let scenes = args.args.select_scene_set(Args::command)?; if let Some(scenes) = scenes { - let event_loop = - EventLoopBuilder::::with_user_event().build()?; + let event_loop = EventLoopBuilder::::with_user_event().build()?; #[allow(unused_mut)] let mut render_cx = RenderContext::new().unwrap(); #[cfg(not(target_arch = "wasm32"))] @@ -657,10 +653,8 @@ pub fn main() -> Result<()> { ) }) .unwrap(); - let size = winit::dpi::PhysicalSize::from_logical::<_, f64>( - (width, height), - scale_factor, - ); + let size = + winit::dpi::PhysicalSize::from_logical::<_, f64>((width, height), scale_factor); _ = window.request_inner_size(size); let surface = render_cx .create_surface( @@ -693,8 +687,7 @@ fn android_main(app: AndroidApp) { use winit::platform::android::EventLoopBuilderExtAndroid; android_logger::init_once( - android_logger::Config::default() - .with_max_level(log::LevelFilter::Warn), + android_logger::Config::default().with_max_level(log::LevelFilter::Warn), ); let event_loop = EventLoopBuilder::with_user_event() diff --git a/examples/with_winit/src/multi_touch.rs b/examples/with_winit/src/multi_touch.rs index 2e869f0..29fb966 100644 --- a/examples/with_winit/src/multi_touch.rs +++ b/examples/with_winit/src/multi_touch.rs @@ -161,18 +161,14 @@ impl TouchState { let zoom_delta2 = if self.active_touches.len() > 1 { match state.pinch_type { PinchType::Horizontal => Vec2::new( - state.current.avg_abs_distance2.x - / state_previous.avg_abs_distance2.x, + state.current.avg_abs_distance2.x / state_previous.avg_abs_distance2.x, 1.0, ), PinchType::Vertical => Vec2::new( 1.0, - state.current.avg_abs_distance2.y - / state_previous.avg_abs_distance2.y, + state.current.avg_abs_distance2.y / state_previous.avg_abs_distance2.y, ), - PinchType::Proportional => { - Vec2::new(zoom_delta, zoom_delta) - } + PinchType::Proportional => Vec2::new(zoom_delta, zoom_delta), } } else { Vec2::new(1.0, 1.0) @@ -183,10 +179,8 @@ impl TouchState { zoom_delta, zoom_delta_2d: zoom_delta2, zoom_centre: state.current.avg_pos, - rotation_delta: (state.current.heading - - state_previous.heading), - translation_delta: state.current.avg_pos - - state_previous.avg_pos, + rotation_delta: (state.current.heading - state_previous.heading), + translation_delta: state.current.avg_pos - state_previous.avg_pos, } }) } diff --git a/examples/with_winit/src/stats.rs b/examples/with_winit/src/stats.rs index aa80036..8f9c96c 100644 --- a/examples/with_winit/src/stats.rs +++ b/examples/with_winit/src/stats.rs @@ -87,11 +87,7 @@ impl Snapshot { None, text_size, Some(&Brush::Solid(Color::WHITE)), - offset - * Affine::translate(( - left_margin, - (i + 1) as f64 * text_height, - )), + offset * Affine::translate((left_margin, (i + 1) as f64 * text_height)), label, ); } @@ -132,8 +128,7 @@ impl Snapshot { self.frame_time_max_ms }; for (i, sample) in samples.enumerate() { - let t = offset - * Affine::translate((i as f64 * bar_extent, graph_max_height)); + let t = offset * Affine::translate((i as f64 * bar_extent, graph_max_height)); // The height of each sample is based on its ratio to the maximum // observed frame time. let sample_ms = ((*sample as f64) * 0.001).min(display_max); @@ -180,11 +175,7 @@ impl Snapshot { ); scene.stroke( &Stroke::new(graph_max_height * 0.01), - offset - * Affine::translate(( - left_margin_padding, - (1. - y) * graph_max_height, - )), + offset * Affine::translate((left_margin_padding, (1. - y) * graph_max_height)), Color::WHITE, None, &marker, @@ -329,11 +320,7 @@ pub fn draw_gpu_profiling( None, text_size, Some(&Brush::Solid(Color::WHITE)), - offset - * Affine::translate(( - left_margin, - (i + 1) as f64 * text_height, - )), + offset * Affine::translate((left_margin, (i + 1) as f64 * text_height)), label, ); } @@ -345,11 +332,7 @@ pub fn draw_gpu_profiling( None, text_size, Some(&Brush::Solid(Color::WHITE)), - offset - * Affine::translate(( - left_margin, - (i + 1) as f64 * text_height, - )), + offset * Affine::translate((left_margin, (i + 1) as f64 * text_height)), label, ); } @@ -369,12 +352,10 @@ pub fn draw_gpu_profiling( let depth_size = depth_width * 0.8; traverse_profiling(profiles, &mut |profile, stage| { if let TraversalStage::Enter = stage { - let start_normalised = ((profile.time.start - min) / total_time) - * timeline_range_y - + timeline_start_y; - let end_normalised = ((profile.time.end - min) / total_time) - * timeline_range_y - + timeline_start_y; + let start_normalised = + ((profile.time.start - min) / total_time) * timeline_range_y + timeline_start_y; + let end_normalised = + ((profile.time.end - min) / total_time) * timeline_range_y + timeline_start_y; let color = COLORS[cur_index % COLORS.len()]; let x = width * 0.01 + (depth as f64 * depth_width); @@ -400,10 +381,7 @@ pub fn draw_gpu_profiling( // Ensure that we don't overlap the previous item .max(cur_text_y) // Ensure that all remaining items can fit - .min( - timeline_range_end - - (count - cur_index) as f64 * text_height, - ); + .min(timeline_range_end - (count - cur_index) as f64 * text_height); let (text_height, text_color) = if slow { (text_height, Color::WHITE) } else { @@ -445,10 +423,7 @@ pub fn draw_gpu_profiling( &Brush::Solid(color), None, &Line::new( - ( - x + depth_size, - (end_normalised + start_normalised) / 2., - ), + (x + depth_size, (end_normalised + start_normalised) / 2.), (width * 0.31, cur_text_y - text_size as f64 * 0.35), ), ); diff --git a/src/geom.rs b/src/geom.rs index dedddf6..20f53be 100644 --- a/src/geom.rs +++ b/src/geom.rs @@ -1,58 +1,58 @@ -// Copyright 2018 Yevhenii Reizner -// SPDX-License-Identifier: MPL-2.0 - -// copied from https://github.com/RazrFalcon/resvg/blob/4d27b8c3be3ecfff3256c9be1b8362eb22533659/crates/resvg/src/geom.rs - -/// Converts `viewBox` to `Transform` with an optional clip rectangle. -/// -/// Unlike `view_box_to_transform`, returns an optional clip rectangle -/// that should be applied before rendering the image. -pub fn view_box_to_transform_with_clip( - view_box: &usvg::ViewBox, - img_size: usvg::tiny_skia_path::IntSize, -) -> (usvg::Transform, Option) { - let r = view_box.rect; - - let new_size = fit_view_box(img_size.to_size(), view_box); - - let (tx, ty, clip) = if view_box.aspect.slice { - let (dx, dy) = usvg::utils::aligned_pos( - view_box.aspect.align, - 0.0, - 0.0, - new_size.width() - r.width(), - new_size.height() - r.height(), - ); - - (r.x() - dx, r.y() - dy, Some(r)) - } else { - let (dx, dy) = usvg::utils::aligned_pos( - view_box.aspect.align, - r.x(), - r.y(), - r.width() - new_size.width(), - r.height() - new_size.height(), - ); - - (dx, dy, None) - }; - - let sx = new_size.width() / img_size.width() as f32; - let sy = new_size.height() / img_size.height() as f32; - let ts = usvg::Transform::from_row(sx, 0.0, 0.0, sy, tx, ty); - - (ts, clip) -} - -/// Fits size into a viewbox. -pub fn fit_view_box(size: usvg::Size, vb: &usvg::ViewBox) -> usvg::Size { - let s = vb.rect.size(); - - if vb.aspect.align == usvg::Align::None { - s - } else if vb.aspect.slice { - size.expand_to(s) - } else { - size.scale_to(s) - } -} +// Copyright 2018 Yevhenii Reizner +// SPDX-License-Identifier: MPL-2.0 + +// copied from https://github.com/RazrFalcon/resvg/blob/4d27b8c3be3ecfff3256c9be1b8362eb22533659/crates/resvg/src/geom.rs + +/// Converts `viewBox` to `Transform` with an optional clip rectangle. +/// +/// Unlike `view_box_to_transform`, returns an optional clip rectangle +/// that should be applied before rendering the image. +pub fn view_box_to_transform_with_clip( + view_box: &usvg::ViewBox, + img_size: usvg::tiny_skia_path::IntSize, +) -> (usvg::Transform, Option) { + let r = view_box.rect; + + let new_size = fit_view_box(img_size.to_size(), view_box); + + let (tx, ty, clip) = if view_box.aspect.slice { + let (dx, dy) = usvg::utils::aligned_pos( + view_box.aspect.align, + 0.0, + 0.0, + new_size.width() - r.width(), + new_size.height() - r.height(), + ); + + (r.x() - dx, r.y() - dy, Some(r)) + } else { + let (dx, dy) = usvg::utils::aligned_pos( + view_box.aspect.align, + r.x(), + r.y(), + r.width() - new_size.width(), + r.height() - new_size.height(), + ); + + (dx, dy, None) + }; + + let sx = new_size.width() / img_size.width() as f32; + let sy = new_size.height() / img_size.height() as f32; + let ts = usvg::Transform::from_row(sx, 0.0, 0.0, sy, tx, ty); + + (ts, clip) +} + +/// Fits size into a viewbox. +pub fn fit_view_box(size: usvg::Size, vb: &usvg::ViewBox) -> usvg::Size { + let s = vb.rect.size(); + + if vb.aspect.align == usvg::Align::None { + s + } else if vb.aspect.slice { + size.expand_to(s) + } else { + size.scale_to(s) + } +} diff --git a/src/lib.rs b/src/lib.rs index ae04756..021fa22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,10 +68,7 @@ pub fn render_tree(scene: &mut Scene, svg: &usvg::Tree) { /// This will draw a red box over unsupported element types. /// /// See the [module level documentation](crate#unsupported-features) for a list of some unsupported svg features -pub fn render_tree_with< - F: FnMut(&mut Scene, &usvg::Node) -> Result<(), E>, - E, ->( +pub fn render_tree_with Result<(), E>, E>( scene: &mut Scene, svg: &usvg::Tree, ts: &usvg::Transform, @@ -102,10 +99,8 @@ fn render_tree_impl Result<(), E>, E>( view_box.rect.bottom().into(), ), ); - let (view_box_transform, clip) = geom::view_box_to_transform_with_clip( - view_box, - svg.size().to_int_size(), - ); + let (view_box_transform, clip) = + geom::view_box_to_transform_with_clip(view_box, svg.size().to_int_size()); if let Some(clip) = clip { scene.push_layer( BlendMode { @@ -149,9 +144,7 @@ fn render_group Result<(), E>, E>( usvg::Node::Group(g) => { let mut pushed_clip = false; if let Some(clip_path) = g.clip_path() { - if let Some(usvg::Node::Path(clip_path)) = - clip_path.root().children().first() - { + if let Some(usvg::Node::Path(clip_path)) = clip_path.root().children().first() { // support clip-path with a single path let local_path = to_bez_path(clip_path); scene.push_layer( @@ -167,12 +160,7 @@ fn render_group Result<(), E>, E>( } } - render_group( - scene, - g, - &ts.pre_concat(g.transform()), - error_handler, - )?; + render_group(scene, g, &ts.pre_concat(g.transform()), error_handler)?; if pushed_clip { scene.pop_layer(); @@ -210,37 +198,21 @@ fn render_group Result<(), E>, E>( if let Some((brush, brush_transform)) = paint_to_brush(stroke.paint(), stroke.opacity()) { - let mut conv_stroke = - Stroke::new(stroke.width().get() as f64) - .with_caps(match stroke.linecap() { - usvg::LineCap::Butt => { - vello::kurbo::Cap::Butt - } - usvg::LineCap::Round => { - vello::kurbo::Cap::Round - } - usvg::LineCap::Square => { - vello::kurbo::Cap::Square - } - }) - .with_join(match stroke.linejoin() { - usvg::LineJoin::Miter - | usvg::LineJoin::MiterClip => { - vello::kurbo::Join::Miter - } - usvg::LineJoin::Round => { - vello::kurbo::Join::Round - } - usvg::LineJoin::Bevel => { - vello::kurbo::Join::Bevel - } - }) - .with_miter_limit( - stroke.miterlimit().get() as f64, - ); - if let Some(dash_array) = - stroke.dasharray().as_ref() - { + let mut conv_stroke = Stroke::new(stroke.width().get() as f64) + .with_caps(match stroke.linecap() { + usvg::LineCap::Butt => vello::kurbo::Cap::Butt, + usvg::LineCap::Round => vello::kurbo::Cap::Round, + usvg::LineCap::Square => vello::kurbo::Cap::Square, + }) + .with_join(match stroke.linejoin() { + usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => { + vello::kurbo::Join::Miter + } + usvg::LineJoin::Round => vello::kurbo::Join::Round, + usvg::LineJoin::Bevel => vello::kurbo::Join::Bevel, + }) + .with_miter_limit(stroke.miterlimit().get() as f64); + if let Some(dash_array) = stroke.dasharray().as_ref() { conv_stroke = conv_stroke.with_dashes( stroke.dashoffset() as f64, dash_array.iter().map(|x| *x as f64), @@ -278,9 +250,7 @@ fn render_group Result<(), E>, E>( usvg::ImageKind::JPEG(_) | usvg::ImageKind::PNG(_) | usvg::ImageKind::GIF(_) => { - let Ok(decoded_image) = - decode_raw_raster_image(img.kind()) - else { + let Ok(decoded_image) = decode_raw_raster_image(img.kind()) else { error_handler(scene, node)?; continue; }; @@ -306,8 +276,7 @@ fn render_group Result<(), E>, E>( ); let view_box_transform = usvg::Transform::from_row(sx, 0.0, 0.0, sy, tx, ty); - let (width, height) = - (decoded_image.width(), decoded_image.height()); + let (width, height) = (decoded_image.width(), decoded_image.height()); scene.push_layer( BlendMode { mix: vello::peniko::Mix::Clip, @@ -323,10 +292,8 @@ fn render_group Result<(), E>, E>( ), ); - let image_ts = - to_affine(&ts.pre_concat(view_box_transform)); - let image_data: Arc> = - decoded_image.into_vec().into(); + let image_ts = to_affine(&ts.pre_concat(view_box_transform)); + let image_data: Arc> = decoded_image.into_vec().into(); scene.draw_image( &Image::new( Blob::new(image_data), @@ -340,13 +307,7 @@ fn render_group Result<(), E>, E>( scene.pop_layer(); } usvg::ImageKind::SVG(svg) => { - render_tree_impl( - scene, - svg, - &img.view_box(), - ts, - error_handler, - )?; + render_tree_impl(scene, svg, &img.view_box(), ts, error_handler)?; } } } @@ -359,9 +320,7 @@ fn render_group Result<(), E>, E>( Ok(()) } -fn decode_raw_raster_image( - img: &usvg::ImageKind, -) -> Result { +fn decode_raw_raster_image(img: &usvg::ImageKind) -> Result { let res = match img { usvg::ImageKind::JPEG(data) => { image::load_from_memory_with_format(data, image::ImageFormat::Jpeg) @@ -442,10 +401,7 @@ fn to_bez_path(path: &usvg::Path) -> BezPath { /// Error handler function for [`render_tree_with`] which draws a transparent red box /// instead of unsupported SVG features -pub fn default_error_handler( - scene: &mut Scene, - node: &usvg::Node, -) -> Result<(), Infallible> { +pub fn default_error_handler(scene: &mut Scene, node: &usvg::Node) -> Result<(), Infallible> { let bb = node.bounding_box(); let rect = Rect { x0: bb.left() as f64, @@ -464,10 +420,7 @@ pub fn default_error_handler( Ok(()) } -fn paint_to_brush( - paint: &usvg::Paint, - opacity: usvg::Opacity, -) -> Option<(Brush, Affine)> { +fn paint_to_brush(paint: &usvg::Paint, opacity: usvg::Opacity) -> Option<(Brush, Affine)> { match paint { usvg::Paint::Color(color) => Some(( Brush::Solid(Color::rgba8( @@ -504,8 +457,8 @@ fn paint_to_brush( ] .map(f64::from); let transform = Affine::new(arr); - let gradient = vello::peniko::Gradient::new_linear(start, end) - .with_stops(stops.as_slice()); + let gradient = + vello::peniko::Gradient::new_linear(start, end).with_stops(stops.as_slice()); Some((Brush::Gradient(gradient), transform)) } usvg::Paint::RadialGradient(gr) => { From 49e85addb6458ec8ad89eaafb2c41a9363f84430 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 09:35:10 -0500 Subject: [PATCH 06/34] fix: copyright notice --- .github/copyright.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copyright.sh b/.github/copyright.sh index d1eb406..04fb306 100755 --- a/.github/copyright.sh +++ b/.github/copyright.sh @@ -7,7 +7,7 @@ # -g "!src/special_directory" # Check all the standard Rust source files -output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Vello Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT$\n\n" --files-without-match --multiline -g "*.rs" -g "!{shader,src/cpu_shader}" -g "!integrations/vello_svg/src/geom.rs" .) +output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Vello Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT$\n\n" --files-without-match --multiline -g "*.rs" -g "!{shader,src/cpu_shader}" -g "!src/geom.rs" .) if [ -n "$output" ]; then echo -e "The following files lack the correct copyright header:\n" From c7b4754d127fbbb63b2f9e8f40a9c94bc9331101 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 09:57:59 -0500 Subject: [PATCH 07/34] feat: cargo release --- .github/workflows/cargo-release.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/cargo-release.yml diff --git a/.github/workflows/cargo-release.yml b/.github/workflows/cargo-release.yml new file mode 100644 index 0000000..1444aa8 --- /dev/null +++ b/.github/workflows/cargo-release.yml @@ -0,0 +1,18 @@ +name: Cargo Release + +on: + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Setup | Checkout + uses: actions/checkout@v4 + + - name: Setup | Toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cargo | Publish + run: | + cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} \ No newline at end of file From d7946b64e777950c46f68ec14e4c3699c2973273 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 10:00:49 -0500 Subject: [PATCH 08/34] docs: add warning --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1064e68..94ac72f 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # vello_svg [![Linebender Zulip](https://img.shields.io/badge/Linebender-%23gpu-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/stream/197075-gpu) -[![dependency status](https://deps.rs/repo/github/linebender/vello/status.svg)](https://deps.rs/repo/github/linebender/vello) -[![MIT/Apache 2.0+MPL 2.0](https://img.shields.io/badge/license-MIT%2FApache+MPL2-blue.svg)](#license) -[![Build status](https://github.com/linebender/vello/workflows/CI/badge.svg)](https://github.com/linebender/vello/actions) - - +[![dependency status](https://deps.rs/repo/github/linebender/vello_svg/status.svg)](https://deps.rs/repo/github/linebender/vello_svg) +[![(MIT/Apache 2.0)+MPL 2.0](https://img.shields.io/badge/license-(MIT%2FApache)+MPL2-blue.svg)](#license) +[![Build status](https://github.com/linebender/vello_svg/workflows/CI/badge.svg)](https://github.com/linebender/vello_svg/actions) +[![Crates.io](https://img.shields.io/crates/v/vello_svg.svg)](https://crates.io/crates/vello_svg) +[![Docs](https://docs.rs/vello_svg/badge.svg)](https://docs.rs/vello_svg) An integration to parse SVG files and render them with [Vello](https://vello.dev). -## Examples +> [!CAUTION] +> Although we are not there yet, the goal of this crate is to provide decent coverage of the (large) SVG spec, up to what vello can support, for use in interactive graphics. If you are looking for a correct SVG renderer, see [resvg](https://github.com/RazrFalcon/resvg). See [vello](https://github.com/linebender/vello) for more information about limitations. -See [vello](https://github.com/linebender/vello) for more information about limitations. +## Examples ### Native From 712ec216f42bca79979c4d1aa49125218e47895c Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 10:02:08 -0500 Subject: [PATCH 09/34] feat: publish on tags --- .github/workflows/cargo-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/cargo-release.yml b/.github/workflows/cargo-release.yml index 1444aa8..0d70ddc 100644 --- a/.github/workflows/cargo-release.yml +++ b/.github/workflows/cargo-release.yml @@ -1,6 +1,9 @@ name: Cargo Release on: + push: + tags: + - 'v*' workflow_dispatch: jobs: From db6af5423b5bafc65b3636effa95b8024df3fea3 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 10:08:23 -0500 Subject: [PATCH 10/34] docs: update badges --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 94ac72f..337f7b7 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ +
+ # vello_svg +**An integration to parse and render SVG with [Vello](https://vello.dev).** + [![Linebender Zulip](https://img.shields.io/badge/Linebender-%23gpu-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/stream/197075-gpu) [![dependency status](https://deps.rs/repo/github/linebender/vello_svg/status.svg)](https://deps.rs/repo/github/linebender/vello_svg) [![(MIT/Apache 2.0)+MPL 2.0](https://img.shields.io/badge/license-(MIT%2FApache)+MPL2-blue.svg)](#license) -[![Build status](https://github.com/linebender/vello_svg/workflows/CI/badge.svg)](https://github.com/linebender/vello_svg/actions) +[![vello version](https://img.shields.io/badge/vello-v0.1.0-orange.svg)](https://crates.io/crates/vello) + [![Crates.io](https://img.shields.io/crates/v/vello_svg.svg)](https://crates.io/crates/vello_svg) [![Docs](https://docs.rs/vello_svg/badge.svg)](https://docs.rs/vello_svg) +[![Build status](https://github.com/linebender/vello_svg/workflows/CI/badge.svg)](https://github.com/linebender/vello_svg/actions) -An integration to parse SVG files and render them with [Vello](https://vello.dev). +
-> [!CAUTION] -> Although we are not there yet, the goal of this crate is to provide decent coverage of the (large) SVG spec, up to what vello can support, for use in interactive graphics. If you are looking for a correct SVG renderer, see [resvg](https://github.com/RazrFalcon/resvg). See [vello](https://github.com/linebender/vello) for more information about limitations. +> [!WARNING] +> The goal of this crate is to provide decent coverage of the (large) SVG spec, up to what vello will support, for use in interactive graphics. If you are looking for a correct SVG renderer, see [resvg](https://github.com/RazrFalcon/resvg). See [vello](https://github.com/linebender/vello) for more information about limitations. ## Examples From 05ac08a74e6daf8d77c77b5bba4d9c3c4cb8c3df Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 10:30:20 -0500 Subject: [PATCH 11/34] build: pin versions --- examples/scenes/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/scenes/Cargo.toml b/examples/scenes/Cargo.toml index db691e0..3f46ed5 100644 --- a/examples/scenes/Cargo.toml +++ b/examples/scenes/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scenes" -description = "Vello scenes used in the other examples." +description = "Scenes used in the other examples." edition.workspace = true license.workspace = true repository.workspace = true @@ -18,5 +18,5 @@ instant = "0.1" # Used for the `download` command [target.'cfg(not(target_arch = "wasm32"))'.dependencies] byte-unit = "4.0.19" -inquire = "*" +inquire = "0.7" ureq = "2.9.6" From 670d29d86d6a83eb5397e314823d368f4c639332 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 10:33:43 -0500 Subject: [PATCH 12/34] fix: wasm demo --- .cargo/config.toml | 5 +++++ examples/scenes/src/test_scenes.rs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e2ec9e5 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[alias] +run_wasm = "run --release --package run_wasm --" +# Other crates use the alias run-wasm, even though crate names should use `_`s not `-`s +# Allow this to be used +run-wasm = "run_wasm" diff --git a/examples/scenes/src/test_scenes.rs b/examples/scenes/src/test_scenes.rs index 7c4aa4b..7ce3da1 100644 --- a/examples/scenes/src/test_scenes.rs +++ b/examples/scenes/src/test_scenes.rs @@ -43,7 +43,7 @@ fn splash_screen(scene: &mut Scene, params: &mut SceneParams) { " Q, E: rotate", ]; // Tweak to make it fit with tiger - let a = Affine::scale(1.) * Affine::translate((-90.0, -50.0)); + let a = Affine::scale(0.11) * Affine::translate((-90.0, -50.0)); for (i, s) in strings.iter().enumerate() { let text_size = if i == 0 { 60.0 } else { 40.0 }; params.text.add( From 87e35d4646da7dfafcf8dd7b5eb26643f5082e9c Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 10:40:17 -0500 Subject: [PATCH 13/34] ci: pages release --- .github/workflows/pages-release.yml | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/pages-release.yml diff --git a/.github/workflows/pages-release.yml b/.github/workflows/pages-release.yml new file mode 100644 index 0000000..4be1388 --- /dev/null +++ b/.github/workflows/pages-release.yml @@ -0,0 +1,71 @@ +name: Web Demo Update + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + release-web: + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install | Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install | WASM Bindgen + uses: jetli/wasm-bindgen-action@v0.2.0 + with: + version: 'latest' + + - name: Build | WASM + run: cargo build -p with_winit --bin with_winit_bin --release --target wasm32-unknown-unknown + env: + RUSTFLAGS: '--cfg=web_sys_unstable_apis' + + - name: Package | WASM + run: | + mkdir public + wasm-bindgen --target web --out-dir public target/wasm32-unknown-unknown/release/with_winit_bin.wasm --no-typescript + cat << EOF > public/index.html + + vello_svg Web Demo + + + + + + + + + + + EOF + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './public' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 From 98491aad3af527b433b728761f6edda155b183c3 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 10:48:50 -0500 Subject: [PATCH 14/34] build: initial author list --- AUTHORS | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 AUTHORS diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..f912594 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,8 @@ +# This is the list of Velato's significant contributors. +# +# This does not necessarily list everyone who has contributed code, +# especially since many employees of one corporation may be contributing. +# To see the full list of contributors, see the revision history in +# source control. +Google LLC +Spencer C. Imbleau \ No newline at end of file From d6ad412cda88b62d3649842b9291d4087a6224bd Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 10:50:04 -0500 Subject: [PATCH 15/34] build: badges --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 337f7b7..0a25f12 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ [![Linebender Zulip](https://img.shields.io/badge/Linebender-%23gpu-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/stream/197075-gpu) [![dependency status](https://deps.rs/repo/github/linebender/vello_svg/status.svg)](https://deps.rs/repo/github/linebender/vello_svg) [![(MIT/Apache 2.0)+MPL 2.0](https://img.shields.io/badge/license-(MIT%2FApache)+MPL2-blue.svg)](#license) -[![vello version](https://img.shields.io/badge/vello-v0.1.0-orange.svg)](https://crates.io/crates/vello) +[![wgpu version](https://img.shields.io/badge/wgpu-v0.19-orange.svg)](https://crates.io/crates/wgpu) +[![vello version](https://img.shields.io/badge/vello-v0.1.0-purple.svg)](https://crates.io/crates/vello) [![Crates.io](https://img.shields.io/crates/v/vello_svg.svg)](https://crates.io/crates/vello_svg) [![Docs](https://docs.rs/vello_svg/badge.svg)](https://docs.rs/vello_svg) From 393f9747546db3d0901eab66c040b135204401f0 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 11:09:23 -0500 Subject: [PATCH 16/34] refactor: imports_granularity --- examples/scenes/src/download.rs | 6 ++---- examples/scenes/src/lib.rs | 4 +++- examples/scenes/src/simple_text.rs | 13 ++++++------- examples/scenes/src/svg.rs | 9 ++++----- examples/scenes/src/test_scenes.rs | 3 ++- examples/with_winit/src/hot_reload.rs | 6 ++++-- examples/with_winit/src/lib.rs | 27 ++++++++++++++------------- examples/with_winit/src/stats.rs | 11 +++++------ rustfmt.toml | 1 + 9 files changed, 41 insertions(+), 39 deletions(-) diff --git a/examples/scenes/src/download.rs b/examples/scenes/src/download.rs index 258c1ea..caf2641 100644 --- a/examples/scenes/src/download.rs +++ b/examples/scenes/src/download.rs @@ -1,10 +1,8 @@ // Copyright 2022 the Vello Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use std::{ - io::Seek, - path::{Path, PathBuf}, -}; +use std::io::Seek; +use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use byte_unit::Byte; diff --git a/examples/scenes/src/lib.rs b/examples/scenes/src/lib.rs index a396959..5a42ce0 100644 --- a/examples/scenes/src/lib.rs +++ b/examples/scenes/src/lib.rs @@ -16,7 +16,9 @@ pub use simple_text::RobotoText; pub use svg::{default_scene, scene_from_files}; pub use test_scenes::test_scenes; -use vello::{kurbo::Vec2, peniko::Color, Scene}; +use vello::kurbo::Vec2; +use vello::peniko::Color; +use vello::Scene; pub struct SceneParams<'a> { pub time: f64, diff --git a/examples/scenes/src/simple_text.rs b/examples/scenes/src/simple_text.rs index 785dc05..c1cc3b8 100644 --- a/examples/scenes/src/simple_text.rs +++ b/examples/scenes/src/simple_text.rs @@ -2,13 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use std::sync::Arc; -use vello::{ - glyph::Glyph, - kurbo::Affine, - peniko::{Blob, Brush, BrushRef, Font, StyleRef}, - skrifa::{raw::FontRef, MetadataProvider}, - Scene, -}; +use vello::glyph::Glyph; +use vello::kurbo::Affine; +use vello::peniko::{Blob, Brush, BrushRef, Font, StyleRef}; +use vello::skrifa::raw::FontRef; +use vello::skrifa::MetadataProvider; +use vello::Scene; // This is very much a hack to get things working. // On Windows, can set this to "c:\\Windows\\Fonts\\seguiemj.ttf" to get color diff --git a/examples/scenes/src/svg.rs b/examples/scenes/src/svg.rs index 3039383..3bb1967 100644 --- a/examples/scenes/src/svg.rs +++ b/examples/scenes/src/svg.rs @@ -1,14 +1,13 @@ // Copyright 2022 the Vello Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use std::{ - fs::read_dir, - path::{Path, PathBuf}, -}; +use std::fs::read_dir; +use std::path::{Path, PathBuf}; use anyhow::{Ok, Result}; use instant::Instant; -use vello::{kurbo::Vec2, Scene}; +use vello::kurbo::Vec2; +use vello::Scene; use vello_svg::usvg; use crate::{ExampleScene, SceneParams, SceneSet}; diff --git a/examples/scenes/src/test_scenes.rs b/examples/scenes/src/test_scenes.rs index 7ce3da1..a4de763 100644 --- a/examples/scenes/src/test_scenes.rs +++ b/examples/scenes/src/test_scenes.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use crate::{ExampleScene, SceneConfig, SceneParams, SceneSet}; -use vello::{kurbo::Affine, *}; +use vello::kurbo::Affine; +use vello::*; macro_rules! scene { ($name: ident) => { diff --git a/examples/with_winit/src/hot_reload.rs b/examples/with_winit/src/hot_reload.rs index ed51c41..3d1cc17 100644 --- a/examples/with_winit/src/hot_reload.rs +++ b/examples/with_winit/src/hot_reload.rs @@ -1,10 +1,12 @@ // Copyright 2023 the Vello Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use std::{path::Path, time::Duration}; +use std::path::Path; +use std::time::Duration; use anyhow::Result; -use notify_debouncer_mini::{new_debouncer, notify::*, DebounceEventResult}; +use notify_debouncer_mini::notify::*; +use notify_debouncer_mini::{new_debouncer, DebounceEventResult}; pub(crate) fn hot_reload(mut f: impl FnMut() -> Option<()> + Send + 'static) -> Result { let mut debouncer = new_debouncer( diff --git a/examples/with_winit/src/lib.rs b/examples/with_winit/src/lib.rs index 48d9470..534b584 100644 --- a/examples/with_winit/src/lib.rs +++ b/examples/with_winit/src/lib.rs @@ -2,22 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use instant::{Duration, Instant}; -use std::{collections::HashSet, num::NonZeroUsize, sync::Arc}; +use std::collections::HashSet; +use std::num::NonZeroUsize; +use std::sync::Arc; use anyhow::Result; use clap::{CommandFactory, Parser}; use scenes::{RobotoText, SceneParams, SceneSet}; -use vello::{ - kurbo::{Affine, Vec2}, - peniko::Color, - util::{RenderContext, RenderSurface}, - AaConfig, BumpAllocators, Renderer, RendererOptions, Scene, -}; +use vello::kurbo::{Affine, Vec2}; +use vello::peniko::Color; +use vello::util::{RenderContext, RenderSurface}; +use vello::{AaConfig, BumpAllocators, Renderer, RendererOptions, Scene}; -use winit::{ - event_loop::{EventLoop, EventLoopBuilder}, - window::Window, -}; +use winit::event_loop::{EventLoop, EventLoopBuilder}; +use winit::window::Window; #[cfg(not(any(target_arch = "wasm32", target_os = "android")))] mod hot_reload; @@ -68,7 +66,9 @@ fn run( render_cx: RenderContext, #[cfg(target_arch = "wasm32")] render_state: RenderState, ) { - use winit::{event::*, event_loop::ControlFlow, keyboard::*}; + use winit::event::*; + use winit::event_loop::ControlFlow; + use winit::keyboard::*; let mut renderers: Vec> = vec![]; #[cfg(not(target_arch = "wasm32"))] let mut render_cx = render_cx; @@ -568,7 +568,8 @@ fn run( } fn create_window(event_loop: &winit::event_loop::EventLoopWindowTarget) -> Arc { - use winit::{dpi::LogicalSize, window::WindowBuilder}; + use winit::dpi::LogicalSize; + use winit::window::WindowBuilder; Arc::new( WindowBuilder::new() .with_inner_size(LogicalSize::new(1044, 800)) diff --git a/examples/with_winit/src/stats.rs b/examples/with_winit/src/stats.rs index 8f9c96c..54215de 100644 --- a/examples/with_winit/src/stats.rs +++ b/examples/with_winit/src/stats.rs @@ -2,12 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use scenes::RobotoText; -use std::{collections::VecDeque, time::Duration}; -use vello::{ - kurbo::{Affine, Line, PathEl, Rect, Stroke}, - peniko::{Brush, Color, Fill}, - AaConfig, BumpAllocators, Scene, -}; +use std::collections::VecDeque; +use std::time::Duration; +use vello::kurbo::{Affine, Line, PathEl, Rect, Stroke}; +use vello::peniko::{Brush, Color, Fill}; +use vello::{AaConfig, BumpAllocators, Scene}; use wgpu_profiler::GpuTimerQueryResult; const SLIDING_WINDOW_SIZE: usize = 100; diff --git a/rustfmt.toml b/rustfmt.toml index a18b909..ce1f14b 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,3 +1,4 @@ max_width = 100 use_field_init_shorthand = true newline_style = "Unix" +# TODO: imports_granularity = "Module" - Wait for this to be stable. From b12f9b3100f2b8fb42ad97602005d1487f7523aa Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 16:00:51 -0500 Subject: [PATCH 17/34] Update AUTHORS Co-authored-by: Kaur Kuut --- AUTHORS | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index f912594..b45c485 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,8 +1,20 @@ -# This is the list of Velato's significant contributors. +# This is the list of Vello's significant contributors. # # This does not necessarily list everyone who has contributed code, # especially since many employees of one corporation may be contributing. # To see the full list of contributors, see the revision history in # source control. Google LLC -Spencer C. Imbleau \ No newline at end of file +Raph Levien +Chad Brokaw +Arman Uguray +Elias Naur +Daniel McNab +Spencer C. Imbleau +Bruce Mitchener +Tatsuyuki Ishi +Markus Siglreithmaier +Rose Hudson +Brian Merchant +Matt Rice +Kaur Kuut \ No newline at end of file From 810e0eb52c43271e3624735ea908b29d17f2d9cb Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 16:01:49 -0500 Subject: [PATCH 18/34] Update Cargo.toml Co-authored-by: Kaur Kuut --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f75de5b..caace9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ description = "An SVG integration for vello." categories = ["rendering", "graphics"] keywords = ["2d", "vector-graphics", "vello", "svg"] version.workspace = true -license.workspace = true +license = "(Apache-2.0 OR MIT) AND MPL-2.0" edition.workspace = true repository.workspace = true From 0bd330baa9d2d5211ee13ad0f4418cdfa68e29cd Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 16:01:57 -0500 Subject: [PATCH 19/34] Update Cargo.toml Co-authored-by: Kaur Kuut --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index caace9e..feb88fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["examples/with_winit", "examples/run_wasm", "examples/scenes"] [workspace.package] edition = "2021" version = "0.1.0" -license = "(Apache-2.0 OR MIT) AND MPL-2.0" +license = "Apache-2.0 OR MIT" repository = "https://github.com/linebender/vello_svg" [workspace.dependencies] From a9673b08956328f1d55cee8f243dad3cdccc9763 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 16:02:10 -0500 Subject: [PATCH 20/34] Update .github/copyright.sh Co-authored-by: Kaur Kuut --- .github/copyright.sh | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/copyright.sh b/.github/copyright.sh index 04fb306..72aa129 100755 --- a/.github/copyright.sh +++ b/.github/copyright.sh @@ -19,19 +19,6 @@ if [ -n "$output" ]; then exit 1 fi -# Check all the shaders, both WGSL and CPU shaders in Rust, as they also have Unlicense -output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Vello Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT OR Unlicense$\n\n" --files-without-match --multiline -g "{shader,src/cpu_shader}/**/*.{rs,wgsl}" .) - -if [ -n "$output" ]; then - echo -e "The following shader files lack the correct copyright header:\n" - echo $output - echo -e "\n\nPlease add the following header:\n" - echo "// Copyright $(date +%Y) the Vello Authors" - echo "// SPDX-License-Identifier: Apache-2.0 OR MIT OR Unlicense" - echo -e "\n... rest of the file ...\n" - exit 1 -fi - echo "All files have correct copyright headers." exit 0 From e2a881450593022fb765a32534a1fd16d457279d Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 16:02:21 -0500 Subject: [PATCH 21/34] Update .github/copyright.sh Co-authored-by: Kaur Kuut --- .github/copyright.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copyright.sh b/.github/copyright.sh index 72aa129..383eb2b 100755 --- a/.github/copyright.sh +++ b/.github/copyright.sh @@ -7,7 +7,7 @@ # -g "!src/special_directory" # Check all the standard Rust source files -output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Vello Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT$\n\n" --files-without-match --multiline -g "*.rs" -g "!{shader,src/cpu_shader}" -g "!src/geom.rs" .) +output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Vello Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT$\n\n" --files-without-match --multiline -g "*.rs" -g "!src/geom.rs" .) if [ -n "$output" ]; then echo -e "The following files lack the correct copyright header:\n" From e8a3f7e9f30b9e2313ebcfaed6f19d1e537325ab Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 16:03:19 -0500 Subject: [PATCH 22/34] Update .github/workflows/pages-release.yml Co-authored-by: Kaur Kuut --- .github/workflows/pages-release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pages-release.yml b/.github/workflows/pages-release.yml index 4be1388..f697dfa 100644 --- a/.github/workflows/pages-release.yml +++ b/.github/workflows/pages-release.yml @@ -31,8 +31,6 @@ jobs: - name: Build | WASM run: cargo build -p with_winit --bin with_winit_bin --release --target wasm32-unknown-unknown - env: - RUSTFLAGS: '--cfg=web_sys_unstable_apis' - name: Package | WASM run: | From 691abc6c6d24101bab6f86e4311b730ce1725f8b Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sat, 9 Mar 2024 16:03:27 -0500 Subject: [PATCH 23/34] Update examples/run_wasm/src/main.rs Co-authored-by: Kaur Kuut --- examples/run_wasm/src/main.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/examples/run_wasm/src/main.rs b/examples/run_wasm/src/main.rs index dc3ed78..41d8eb0 100644 --- a/examples/run_wasm/src/main.rs +++ b/examples/run_wasm/src/main.rs @@ -13,13 +13,5 @@ /// ``` fn main() { - // HACK: We rely heavily on compute shaders; which means we need WebGPU to - // be supported However, that requires unstable APIs to be enabled, - // which are not exposed through a feature - let current_value = std::env::var("RUSTFLAGS").unwrap_or("".to_owned()); - std::env::set_var( - "RUSTFLAGS", - format!("{current_value} --cfg=web_sys_unstable_apis",), - ); cargo_run_wasm::run_wasm_with_css("body { margin: 0px; }"); } From 253960a6dcc4e2e9a325dd10c1633f87a506ec69 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sun, 10 Mar 2024 10:16:37 -0400 Subject: [PATCH 24/34] build: ignore example assets --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index a9efee5..44e97cf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock +examples/assets/* + # Generated by Cargo # will have compiled files and executables target/ From 4440ff4e4f6e3a6f1a95aeb3d86e00571f11b486 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Sun, 10 Mar 2024 10:18:18 -0400 Subject: [PATCH 25/34] refactor: remove CI --- .github/workflows/cargo-release.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/cargo-release.yml diff --git a/.github/workflows/cargo-release.yml b/.github/workflows/cargo-release.yml deleted file mode 100644 index 0d70ddc..0000000 --- a/.github/workflows/cargo-release.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Cargo Release - -on: - push: - tags: - - 'v*' - workflow_dispatch: - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Setup | Checkout - uses: actions/checkout@v4 - - - name: Setup | Toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cargo | Publish - run: | - cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} \ No newline at end of file From c61c739ea80446e5857b950d11dcd135d0b48c89 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Mon, 11 Mar 2024 11:28:51 -0400 Subject: [PATCH 26/34] build: release on latest main push instead --- .github/workflows/pages-release.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pages-release.yml b/.github/workflows/pages-release.yml index f697dfa..89bce09 100644 --- a/.github/workflows/pages-release.yml +++ b/.github/workflows/pages-release.yml @@ -2,9 +2,8 @@ name: Web Demo Update on: push: - tags: - - 'v*' - workflow_dispatch: + branches: + - main jobs: release-web: From 63ac057fe03e4b9374433951b5b6cf0661e7eb84 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Mon, 11 Mar 2024 11:41:52 -0400 Subject: [PATCH 27/34] fix: .gitignore --- .gitignore | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 44e97cf..cde568b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,31 +2,15 @@ # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock -examples/assets/* +# Don't commit example downloads +examples/assets/downloads/* # Generated by Cargo # will have compiled files and executables -target/ +/target # Generated by Trunk dist/ -# These are backup files generated by rustfmt -**/*.rs.bk - -# Some people use VSCode -/.vscode/ - -# Some people use IntelliJ with Rust -/.idea/ -*.iml - -# Some people use pre-commit -.pre-commit-config.yaml -.pre-commit-config.yml - # Some people have Apple -.DS_Store - -# Generated on wasm-pack failure -**/unsupported.js +.DS_Store \ No newline at end of file From b176f7344d4742e8cc2ddcf268498eab9048ac89 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Mon, 11 Mar 2024 11:43:03 -0400 Subject: [PATCH 28/34] fix: remove trunk --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index cde568b..0d0f019 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,5 @@ examples/assets/downloads/* # will have compiled files and executables /target -# Generated by Trunk -dist/ - # Some people have Apple .DS_Store \ No newline at end of file From c6911adf611d4c532247fc2c13c3424090691ceb Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Mon, 11 Mar 2024 12:00:06 -0400 Subject: [PATCH 29/34] docs: update the badge --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index feb88fd..0206a29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ license = "Apache-2.0 OR MIT" repository = "https://github.com/linebender/vello_svg" [workspace.dependencies] +# Update the README badges to match wgpu and vello version! vello = "0.1" [package] From 139febd0ce28822e677055fab063316191b15ad6 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Mon, 11 Mar 2024 12:00:19 -0400 Subject: [PATCH 30/34] docs: update authors --- AUTHORS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index b45c485..a74fe51 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,4 +1,4 @@ -# This is the list of Vello's significant contributors. +# This is the list of Vello SVG's significant contributors. # # This does not necessarily list everyone who has contributed code, # especially since many employees of one corporation may be contributing. @@ -17,4 +17,4 @@ Markus Siglreithmaier Rose Hudson Brian Merchant Matt Rice -Kaur Kuut \ No newline at end of file +Kaur Kuut From 28f258ddefd45755442abd128c0745be0e9443f1 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Mon, 11 Mar 2024 12:00:26 -0400 Subject: [PATCH 31/34] docs: update title --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0a25f12..d262a80 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# vello_svg +# Vello SVG **An integration to parse and render SVG with [Vello](https://vello.dev).** From b7f3d073c6772b2d5f4e125275cf94d54131930e Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Mon, 11 Mar 2024 15:51:27 -0400 Subject: [PATCH 32/34] Update examples/scenes/Cargo.toml Co-authored-by: Kaur Kuut --- examples/scenes/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/scenes/Cargo.toml b/examples/scenes/Cargo.toml index 3f46ed5..04821a7 100644 --- a/examples/scenes/Cargo.toml +++ b/examples/scenes/Cargo.toml @@ -20,3 +20,6 @@ instant = "0.1" byte-unit = "4.0.19" inquire = "0.7" ureq = "2.9.6" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2.12", features = ["js"] } From b11b3eca6980d3a0959250b44fcc4d1cdc231bc6 Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Mon, 11 Mar 2024 15:52:36 -0400 Subject: [PATCH 33/34] fix: pin to 0.19.3 --- examples/with_winit/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/with_winit/Cargo.toml b/examples/with_winit/Cargo.toml index 868b682..93e7d34 100644 --- a/examples/with_winit/Cargo.toml +++ b/examples/with_winit/Cargo.toml @@ -24,7 +24,7 @@ clap = { version = "4.5.1", features = ["derive"] } instant = { version = "0.1.12", features = ["wasm-bindgen"] } pollster = "0.3" wgpu-profiler = "0.16" -wgpu = "0.19" +wgpu = "0.19.3" winit = "0.29.12" env_logger = "0.11.2" log = "0.4.21" From 1d4c9ebf23ce67d650b70a171d8412b4788b57ac Mon Sep 17 00:00:00 2001 From: "Spencer C. Imbleau" Date: Mon, 11 Mar 2024 15:56:49 -0400 Subject: [PATCH 34/34] docs: update --- README.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d262a80..8bc4103 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ [![Linebender Zulip](https://img.shields.io/badge/Linebender-%23gpu-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/stream/197075-gpu) [![dependency status](https://deps.rs/repo/github/linebender/vello_svg/status.svg)](https://deps.rs/repo/github/linebender/vello_svg) [![(MIT/Apache 2.0)+MPL 2.0](https://img.shields.io/badge/license-(MIT%2FApache)+MPL2-blue.svg)](#license) -[![wgpu version](https://img.shields.io/badge/wgpu-v0.19-orange.svg)](https://crates.io/crates/wgpu) [![vello version](https://img.shields.io/badge/vello-v0.1.0-purple.svg)](https://crates.io/crates/vello) [![Crates.io](https://img.shields.io/crates/v/vello_svg.svg)](https://crates.io/crates/vello_svg) @@ -21,7 +20,7 @@ ## Examples -### Native +### Cross platform (Winit) ```shell cargo run -p with_winit @@ -33,7 +32,7 @@ You can also load an entire folder or individual files. cargo run -p with_winit -- examples/assets ``` -### Web +### Web platform Because Vello relies heavily on compute shaders, we rely on the emerging WebGPU standard to run on the web. Until browser support becomes widespread, it will probably be necessary to use development browser versions (e.g. Chrome Canary) and explicitly enable WebGPU. @@ -53,6 +52,31 @@ There is also a web demo [available here](https://linebender.github.io/vello_svg > [!WARNING] > The web is not currently a primary target for Vello, and WebGPU implementations are incomplete, so you might run into issues running this example. +## Community + +Discussion of Velato development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#gpu stream](https://xi.zulipchat.com/#narrow/stream/197075-gpu). All public content can be read without logging in. + +Contributions are welcome by pull request. The [Rust code of conduct](https://www.rust-lang.org/policies/code-of-conduct) applies. + ## License -This project is licensed under your choice of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) licenses, with [MPL 2.0](LICENSE-MPL). +Licensed under either of + +- Apache License, Version 2.0 + ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license + ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option, in addition to + +- Mozilla Public License 2.0 + ([LICENSE-MPL](LICENSE-MPL) or ). + +The files in subdirectories of the [`examples/assets`](/examples/assets) directory are licensed solely under +their respective licenses, available in the `LICENSE` file in their directories. + +## Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in the work by you, as defined in the Apache-2.0 license, shall be +dual licensed as above, without any additional terms or conditions.

WebGPU + is not enabled. Make sure your browser is updated to + Chrome M113 or + another browser compatible with WebGPU.