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/.github/copyright.sh b/.github/copyright.sh new file mode 100755 index 0000000..383eb2b --- /dev/null +++ b/.github/copyright.sh @@ -0,0 +1,24 @@ +#!/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 "!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 + +echo "All files have correct copyright headers." +exit 0 + 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/.github/workflows/pages-release.yml b/.github/workflows/pages-release.yml new file mode 100644 index 0000000..89bce09 --- /dev/null +++ b/.github/workflows/pages-release.yml @@ -0,0 +1,68 @@ +name: Web Demo Update + +on: + push: + branches: + - main + +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 + + - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d0f019 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# 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 + +# Don't commit example downloads +examples/assets/downloads/* + +# Generated by Cargo +# will have compiled files and executables +/target + +# Some people have Apple +.DS_Store \ No newline at end of file diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..a74fe51 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,20 @@ +# 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. +# To see the full list of contributors, see the revision history in +# source control. +Google LLC +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 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0206a29 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[workspace] +resolver = "2" +members = ["examples/with_winit", "examples/run_wasm", "examples/scenes"] + +[workspace.package] +edition = "2021" +version = "0.1.0" +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] +name = "vello_svg" +description = "An SVG integration for vello." +categories = ["rendering", "graphics"] +keywords = ["2d", "vector-graphics", "vello", "svg"] +version.workspace = true +license = "(Apache-2.0 OR MIT) AND MPL-2.0" +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..8bc4103 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +
+ +# 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) +[![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) +[![Build status](https://github.com/linebender/vello_svg/workflows/CI/badge.svg)](https://github.com/linebender/vello_svg/actions) + +
+ +> [!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 + +### Cross platform (Winit) + +```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 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. + +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. + +## 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 + +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. 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 0000000..3d6861b Binary files /dev/null and b/examples/assets/roboto/Roboto-Regular.ttf differ 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..41d8eb0 --- /dev/null +++ b/examples/run_wasm/src/main.rs @@ -0,0 +1,17 @@ +// 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() { + 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..04821a7 --- /dev/null +++ b/examples/scenes/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "scenes" +description = "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 = "0.7" +ureq = "2.9.6" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2.12", features = ["js"] } diff --git a/examples/scenes/src/download.rs b/examples/scenes/src/download.rs new file mode 100644 index 0000000..caf2641 --- /dev/null +++ b/examples/scenes/src/download.rs @@ -0,0 +1,215 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::io::Seek; +use std::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..5a42ce0 --- /dev/null +++ b/examples/scenes/src/lib.rs @@ -0,0 +1,128 @@ +// 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; +use vello::peniko::Color; +use vello::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..c1cc3b8 --- /dev/null +++ b/examples/scenes/src/simple_text.rs @@ -0,0 +1,139 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::sync::Arc; +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 +// 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..3bb1967 --- /dev/null +++ b/examples/scenes/src/svg.rs @@ -0,0 +1,169 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::fs::read_dir; +use std::path::{Path, PathBuf}; + +use anyhow::{Ok, Result}; +use instant::Instant; +use vello::kurbo::Vec2; +use vello::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..a4de763 --- /dev/null +++ b/examples/scenes/src/test_scenes.rs @@ -0,0 +1,71 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::{ExampleScene, SceneConfig, SceneParams, SceneSet}; +use vello::kurbo::Affine; +use vello::*; + +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(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( + 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..93e7d34 --- /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.3" +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..3d1cc17 --- /dev/null +++ b/examples/with_winit/src/hot_reload.rs @@ -0,0 +1,30 @@ +// Copyright 2023 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::path::Path; +use std::time::Duration; + +use anyhow::Result; +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( + 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..534b584 --- /dev/null +++ b/examples/with_winit/src/lib.rs @@ -0,0 +1,707 @@ +// Copyright 2022 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use instant::{Duration, Instant}; +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}; +use vello::peniko::Color; +use vello::util::{RenderContext, RenderSurface}; +use vello::{AaConfig, BumpAllocators, Renderer, RendererOptions, Scene}; + +use winit::event_loop::{EventLoop, EventLoopBuilder}; +use winit::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::*; + use winit::event_loop::ControlFlow; + use winit::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; + use winit::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#" +

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

"#, + ); + 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..29fb966 --- /dev/null +++ b/examples/with_winit/src/multi_touch.rs @@ -0,0 +1,306 @@ +// 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..54215de --- /dev/null +++ b/examples/with_winit/src/stats.rs @@ -0,0 +1,453 @@ +// Copyright 2023 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use scenes::RobotoText; +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; + +#[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..ce1f14b --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +max_width = 100 +use_field_init_shorthand = true +newline_style = "Unix" +# TODO: imports_granularity = "Module" - Wait for this to be stable. diff --git a/src/geom.rs b/src/geom.rs new file mode 100644 index 0000000..20f53be --- /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..021fa22 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,504 @@ +// 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 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