├── .github └── workflows │ └── rust.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── crates ├── transform-gizmo-bevy │ ├── Cargo.toml │ ├── examples │ │ ├── bevy_minimal.rs │ │ └── ui_blocked_gizmo.rs │ └── src │ │ ├── gizmo.wgsl │ │ ├── lib.rs │ │ ├── mouse_interact.rs │ │ ├── picking.rs │ │ ├── prelude.rs │ │ └── render.rs ├── transform-gizmo-egui │ ├── Cargo.toml │ └── src │ │ ├── lib.rs │ │ └── prelude.rs └── transform-gizmo │ ├── Cargo.toml │ └── src │ ├── config.rs │ ├── gizmo.rs │ ├── lib.rs │ ├── math.rs │ ├── prelude.rs │ ├── shape.rs │ ├── subgizmo.rs │ └── subgizmo │ ├── arcball.rs │ ├── common.rs │ ├── rotation.rs │ ├── scale.rs │ └── translation.rs ├── docs ├── bevy-example.js ├── bevy-example_bg.wasm └── index.html ├── examples ├── bevy │ ├── Cargo.toml │ ├── Trunk.toml │ ├── index.html │ └── src │ │ ├── camera.rs │ │ ├── grid.rs │ │ ├── gui.rs │ │ ├── main.rs │ │ ├── picking.rs │ │ └── scene.rs └── egui │ ├── Cargo.toml │ └── src │ └── main.rs └── media ├── all_modes.png ├── rotate_translate.png ├── rotating.png ├── rotation.png ├── scale.png └── translation.png /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Rust 4 | 5 | jobs: 6 | fmt-crank-check-test: 7 | name: format + check + test 8 | runs-on: ubuntu-22.04 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - uses: dtolnay/rust-toolchain@master 13 | with: 14 | toolchain: 1.85.0 15 | 16 | - name: install dependencies 17 | run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev 18 | 19 | - name: cache 20 | uses: Swatinem/rust-cache@v2 21 | 22 | - name: install cargo fmt 23 | run: rustup component add rustfmt 24 | 25 | - name: install clippy 26 | run: rustup component add clippy 27 | 28 | - name: fmt 29 | run: cargo fmt --all -- --check 30 | 31 | - name: install cargo-cranky 32 | uses: baptiste0928/cargo-install@v1 33 | with: 34 | crate: cargo-cranky 35 | 36 | - name: check --all-features 37 | run: cargo check --all-features --all-targets 38 | 39 | - name: test doc-tests 40 | run: cargo test --doc --all-features 41 | 42 | - name: test 43 | run: cargo test --all-features 44 | 45 | - name: cranky 46 | run: cargo cranky --all-targets --all-features -- -D warnings 47 | 48 | - name: cranky --release 49 | run: cargo cranky --all-targets --all-features --release -- -D warnings -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | /.idea 4 | /.vscode 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Thank you for considering to contribute! 4 | 5 | ## Filing an issue 6 | 7 | If you encounter an issue or have a feature request, you can [open an issue](https://github.com/urholaukkarinen/transform-gizmo/issues/new/choose) for it. 8 | 9 | Before opening an issue, check that there is not already a similar issue created by someone else. 10 | 11 | ## Opening a pull request 12 | 13 | For things such as tiny bug fixes or version bumps, feel free to open a pull request without creating an issue first. 14 | 15 | For larger changes, please file an issue first. 16 | 17 | Keep pull requests focused. Avoid including multiple unrelated changes in the same pull request. -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["crates/*", "examples/*"] 4 | 5 | [workspace.package] 6 | version = "0.6.0" 7 | rust-version = "1.85.0" 8 | edition = "2024" 9 | license = "MIT OR Apache-2.0" 10 | homepage = "https://github.com/urholaukkarinen/transform-gizmo" 11 | repository = "https://github.com/urholaukkarinen/transform-gizmo" 12 | authors = ["Urho Laukkarinen "] 13 | 14 | [workspace.dependencies] 15 | transform-gizmo = { version = "0.6.0", path = "crates/transform-gizmo" } 16 | transform-gizmo-egui = { version = "0.6.0", path = "crates/transform-gizmo-egui" } 17 | transform-gizmo-bevy = { version = "0.6.0", path = "crates/transform-gizmo-bevy" } 18 | 19 | egui = "0.31" 20 | eframe = "0.31" 21 | emath = "0.31" 22 | epaint = "0.31" 23 | ecolor = "0.31" 24 | glam = { version = "0.30", features = ["mint"] } 25 | mint = "0.5" 26 | enum_dispatch = "0.3" 27 | ahash = "0.8" 28 | enumset = "1.1" 29 | bytemuck = "1.22" 30 | uuid = "1.1" 31 | bevy = "0.16" 32 | 33 | bevy_app = { version = "0.16", default-features = false } 34 | bevy_core_pipeline = { version = "0.16", default-features = false } 35 | bevy_reflect = { version = "0.16", default-features = false } 36 | bevy_math = { version = "0.16", features = ["mint"], default-features = false } 37 | bevy_render = { version = "0.16", default-features = false } 38 | bevy_input = { version = "0.16", default-features = false } 39 | bevy_asset = { version = "0.16", default-features = false } 40 | bevy_utils = { version = "0.16", default-features = false } 41 | bevy_platform = { version = "0.16", default-features = false } 42 | bevy_pbr = { version = "0.16", default-features = false } 43 | bevy_ecs = { version = "0.16", default-features = false } 44 | bevy_log = { version = "0.16", default-features = false } 45 | bevy_window = { version = "0.16", default-features = false } 46 | bevy_transform = { version = "0.16", default-features = false } 47 | bevy_derive = { version = "0.16", default-features = false } 48 | bevy_image = { version = "0.16", default-features = false } 49 | bevy_picking = { version = "0.16", default-features = false } 50 | 51 | [profile.release] 52 | opt-level = "s" 53 | panic = "abort" 54 | 55 | [workspace.lints.rust] 56 | elided_lifetimes_in_paths = "allow" 57 | rust_2021_prelude_collisions = "deny" 58 | semicolon_in_expressions_from_macros = "deny" 59 | trivial_numeric_casts = "deny" 60 | unsafe_op_in_unsafe_fn = "deny" 61 | unused_extern_crates = "deny" 62 | unused_import_braces = "deny" 63 | unused_lifetimes = "deny" 64 | unsafe_code = "deny" 65 | 66 | [workspace.lints.clippy] 67 | as_ptr_cast_mut = "deny" 68 | await_holding_lock = "deny" 69 | bool_to_int_with_if = "deny" 70 | branches_sharing_code = "deny" 71 | char_lit_as_u8 = "deny" 72 | checked_conversions = "deny" 73 | clear_with_drain = "deny" 74 | cloned_instead_of_copied = "deny" 75 | dbg_macro = "deny" 76 | debug_assert_with_mut_call = "deny" 77 | default_union_representation = "deny" 78 | derive_partial_eq_without_eq = "deny" 79 | disallowed_macros = "deny" 80 | disallowed_methods = "deny" 81 | disallowed_names = "deny" 82 | disallowed_script_idents = "deny" 83 | disallowed_types = "deny" 84 | doc_link_with_quotes = "deny" 85 | doc_markdown = "deny" 86 | empty_enum = "deny" 87 | empty_line_after_outer_attr = "deny" 88 | enum_glob_use = "deny" 89 | equatable_if_let = "deny" 90 | exit = "deny" 91 | expl_impl_clone_on_copy = "deny" 92 | explicit_deref_methods = "deny" 93 | explicit_into_iter_loop = "deny" 94 | explicit_iter_loop = "deny" 95 | fallible_impl_from = "deny" 96 | filter_map_next = "deny" 97 | flat_map_option = "deny" 98 | float_cmp_const = "deny" 99 | fn_params_excessive_bools = "deny" 100 | fn_to_numeric_cast_any = "deny" 101 | from_iter_instead_of_collect = "deny" 102 | get_unwrap = "deny" 103 | if_let_mutex = "deny" 104 | implicit_clone = "deny" 105 | imprecise_flops = "deny" 106 | index_refutable_slice = "deny" 107 | inefficient_to_string = "deny" 108 | invalid_upcast_comparisons = "deny" 109 | iter_not_returning_iterator = "deny" 110 | iter_on_empty_collections = "deny" 111 | iter_on_single_items = "deny" 112 | large_digit_groups = "deny" 113 | large_include_file = "deny" 114 | large_stack_arrays = "deny" 115 | large_stack_frames = "deny" 116 | large_types_passed_by_value = "deny" 117 | let_unit_value = "deny" 118 | linkedlist = "deny" 119 | lossy_float_literal = "deny" 120 | macro_use_imports = "deny" 121 | manual_assert = "deny" 122 | manual_clamp = "deny" 123 | manual_instant_elapsed = "deny" 124 | manual_let_else = "deny" 125 | manual_ok_or = "deny" 126 | manual_string_new = "deny" 127 | map_err_ignore = "deny" 128 | map_flatten = "deny" 129 | map_unwrap_or = "deny" 130 | match_on_vec_items = "deny" 131 | match_same_arms = "deny" 132 | match_wild_err_arm = "deny" 133 | match_wildcard_for_single_variants = "deny" 134 | mem_forget = "deny" 135 | mismatching_type_param_order = "deny" 136 | missing_enforced_import_renames = "deny" 137 | missing_safety_doc = "deny" 138 | mut_mut = "deny" 139 | mutex_integer = "deny" 140 | needless_borrow = "deny" 141 | needless_continue = "deny" 142 | needless_for_each = "deny" 143 | needless_pass_by_value = "deny" 144 | negative_feature_names = "deny" 145 | nonstandard_macro_braces = "deny" 146 | option_option = "deny" 147 | path_buf_push_overwrite = "deny" 148 | print_stdout = "deny" 149 | ptr_as_ptr = "deny" 150 | ptr_cast_constness = "deny" 151 | pub_without_shorthand = "deny" 152 | rc_mutex = "deny" 153 | redundant_type_annotations = "deny" 154 | ref_option_ref = "deny" 155 | rest_pat_in_fully_bound_structs = "deny" 156 | same_functions_in_if_condition = "deny" 157 | semicolon_if_nothing_returned = "deny" 158 | significant_drop_tightening = "deny" 159 | single_match_else = "deny" 160 | str_to_string = "deny" 161 | string_add_assign = "deny" 162 | string_add = "deny" 163 | string_lit_as_bytes = "deny" 164 | string_to_string = "deny" 165 | suspicious_command_arg_space = "deny" 166 | suspicious_xor_used_as_pow = "deny" 167 | todo = "deny" 168 | trailing_empty_array = "deny" 169 | trait_duplication_in_bounds = "deny" 170 | transmute_ptr_to_ptr = "deny" 171 | tuple_array_conversions = "deny" 172 | unchecked_duration_subtraction = "deny" 173 | unimplemented = "deny" 174 | uninlined_format_args = "allow" 175 | unnecessary_box_returns = "deny" 176 | unnecessary_safety_comment = "deny" 177 | unnecessary_safety_doc = "deny" 178 | unnecessary_self_imports = "deny" 179 | unnecessary_struct_initialization = "deny" 180 | unnecessary_wraps = "deny" 181 | unnested_or_patterns = "deny" 182 | unused_peekable = "deny" 183 | unused_rounding = "deny" 184 | unused_self = "deny" 185 | use_self = "deny" 186 | useless_transmute = "deny" 187 | verbose_file_reads = "deny" 188 | wildcard_dependencies = "deny" 189 | zero_sized_map_values = "deny" 190 | let_underscore_untyped = "allow" 191 | missing_assert_message = "allow" 192 | missing_errors_doc = "allow" 193 | too_many_lines = "allow" 194 | undocumented_unsafe_blocks = "allow" 195 | unwrap_used = "allow" 196 | wildcard_imports = "allow" 197 | large-types-passed-by-value = "allow" 198 | needless-pass-by-value = "allow" 199 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Urho Laukkarinen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # transform-gizmo 2 | 3 | [![Latest version](https://img.shields.io/crates/v/transform-gizmo.svg)](https://crates.io/crates/transform-gizmo) 4 | [![Documentation](https://docs.rs/transform-gizmo/badge.svg)](https://docs.rs/transform-gizmo) 5 | [![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/urholaukkarinen/transform-gizmo/blob/main/LICENSE-MIT) 6 | [![Apache](https://img.shields.io/badge/license-Apache-blue.svg)](https://github.com/urholaukkarinen/transform-gizmo/blob/main/LICENSE-APACHE) 7 | 8 | `transform-gizmo` is a framework-agnostic Rust crate that provides a feature-rich and customizable 3D transformation gizmo for manipulating the position, rotation and scale of 3D entities. 9 | 10 | [Try it out in a web demo](https://urholaukkarinen.github.io/transform-gizmo/) 11 | 12 | ![All modes](media/all_modes.png) 13 | 14 | ## Usage 15 | 16 | ### Bevy 17 | 18 | [`transform-gizmo-bevy`](https://docs.rs/transform-gizmo-bevy) provides a Plugin for easy integration into the [Bevy Engine](https://bevyengine.org/). 19 | 20 | ### Egui 21 | 22 | [`transform-gizmo-egui`](https://docs.rs/transform-gizmo-egui) enables you to use the Gizmo wherever [Egui](https://github.com/emilk/egui) is used. 23 | 24 | ### Other 25 | 26 | For interacting with the gizmo, all you will need to do is give `Gizmo::update` sufficient 27 | information about user interaction, in the form of `GizmoInteraction`. 28 | 29 | For rendering the gizmo, `Gizmo::draw` provides vertices in viewport coordinates that can be easily rendered 30 | with your favorite graphics APIs. 31 | 32 | ## Other 33 | 34 | The gizmo exposes mathematical types as [mint](https://github.com/kvark/mint) types, which means it is easy to use with types from various crates 35 | such as [nalgebra](https://github.com/dimforge/nalgebra), [glam](https://github.com/bitshifter/glam-rs) 36 | and [cgmath](https://github.com/rustgd/cgmath). You may need to enable a `mint` feature, depending on the math library. 37 | 38 | ## License 39 | 40 | This crate is dual licensed under MIT and Apache 2.0. 41 | 42 | ## Contributing 43 | 44 | Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) -------------------------------------------------------------------------------- /crates/transform-gizmo-bevy/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "transform-gizmo-bevy" 3 | description = "bevy integration for transform-gizmo" 4 | version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | homepage.workspace = true 8 | repository.workspace = true 9 | authors.workspace = true 10 | categories = ["gui", "game-development", "game-engines"] 11 | keywords = ["gizmo", "bevy"] 12 | readme = "../../README.md" 13 | include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "**/*.wgsl", "Cargo.toml"] 14 | 15 | [features] 16 | default = ["gizmo_picking_backend", "mouse_interaction"] 17 | gizmo_picking_backend = ["dep:bevy_picking"] 18 | mouse_interaction = [] 19 | 20 | [dependencies] 21 | transform-gizmo.workspace = true 22 | 23 | bevy_app.workspace = true 24 | bevy_core_pipeline.workspace = true 25 | bevy_reflect.workspace = true 26 | bevy_math.workspace = true 27 | bevy_picking = {workspace = true, optional = true} 28 | bevy_render.workspace = true 29 | bevy_input.workspace = true 30 | bevy_asset.workspace = true 31 | bevy_utils.workspace = true 32 | bevy_platform.workspace = true 33 | bevy_pbr.workspace = true 34 | bevy_ecs.workspace = true 35 | bevy_log.workspace = true 36 | bevy_window.workspace = true 37 | bevy_transform.workspace = true 38 | bevy_derive.workspace = true 39 | bevy_image.workspace = true 40 | bytemuck.workspace = true 41 | uuid.workspace = true 42 | 43 | [dev-dependencies] 44 | bevy = {workspace = true} 45 | 46 | [lints] 47 | workspace = true 48 | -------------------------------------------------------------------------------- /crates/transform-gizmo-bevy/examples/bevy_minimal.rs: -------------------------------------------------------------------------------- 1 | //! A very simple example 2 | //! See the project root's `examples` directory for more examples 3 | 4 | use bevy::color::palettes::css::LIME; 5 | use bevy::prelude::*; 6 | use transform_gizmo_bevy::*; 7 | 8 | fn main() { 9 | App::new() 10 | .add_plugins((DefaultPlugins, TransformGizmoPlugin)) 11 | .add_systems(Startup, setup) 12 | .run(); 13 | } 14 | 15 | fn setup( 16 | mut commands: Commands, 17 | mut materials: ResMut>, 18 | mut meshes: ResMut>, 19 | ) { 20 | // camera 21 | commands.spawn(( 22 | Camera3d::default(), 23 | Transform::from_translation(Vec3::new(1.0, 3.0, -5.0)).looking_at(Vec3::ZERO, Vec3::Y), 24 | Msaa::Sample2, 25 | GizmoCamera, 26 | )); 27 | 28 | // cube 29 | commands.spawn(( 30 | Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), 31 | MeshMaterial3d(materials.add(Color::from(LIME))), 32 | Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), 33 | GizmoTarget::default(), 34 | )); 35 | 36 | // light 37 | commands.spawn(( 38 | PointLight { 39 | shadows_enabled: true, 40 | ..default() 41 | }, 42 | Transform::from_xyz(4.0, 8.0, 4.0), 43 | )); 44 | } 45 | -------------------------------------------------------------------------------- /crates/transform-gizmo-bevy/examples/ui_blocked_gizmo.rs: -------------------------------------------------------------------------------- 1 | //! Gizmo interactions blocked by other picking backends. In this case, UI. 2 | //! See the project root's `examples` directory for more examples 3 | 4 | use bevy::color::palettes::css::LIME; 5 | use bevy::prelude::*; 6 | use transform_gizmo_bevy::*; 7 | 8 | fn main() { 9 | App::new() 10 | .add_plugins((DefaultPlugins, TransformGizmoPlugin)) 11 | .add_systems(Startup, setup) 12 | .add_observer(|trigger: Trigger>| { 13 | info!("Moved over: {}", trigger.target()); 14 | }) 15 | .run(); 16 | } 17 | 18 | fn setup( 19 | mut commands: Commands, 20 | mut materials: ResMut>, 21 | mut meshes: ResMut>, 22 | ) { 23 | commands.spawn(( 24 | Node { 25 | position_type: PositionType::Absolute, 26 | left: Val::Percent(50.0), 27 | top: Val::Percent(50.0), 28 | width: Val::Px(200.0), 29 | height: Val::Px(200.0), 30 | ..default() 31 | }, 32 | BackgroundColor(Srgba::new(0.4, 0.4, 0.6, 1.0).into()), 33 | )); 34 | // camera 35 | commands.spawn(( 36 | Camera3d::default(), 37 | Transform::from_translation(Vec3::new(1.0, 3.0, -5.0)).looking_at(Vec3::ZERO, Vec3::Y), 38 | Msaa::Sample2, 39 | GizmoCamera, 40 | )); 41 | 42 | // cube 43 | commands.spawn(( 44 | Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), 45 | MeshMaterial3d(materials.add(Color::from(LIME))), 46 | Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), 47 | GizmoTarget::default(), 48 | )); 49 | 50 | // light 51 | commands.spawn(( 52 | PointLight { 53 | shadows_enabled: true, 54 | ..default() 55 | }, 56 | Transform::from_xyz(4.0, 8.0, 4.0), 57 | )); 58 | } 59 | -------------------------------------------------------------------------------- /crates/transform-gizmo-bevy/src/gizmo.wgsl: -------------------------------------------------------------------------------- 1 | struct VertexInput { 2 | @location(0) position: vec2, 3 | @location(1) color: vec4, 4 | }; 5 | 6 | struct VertexOutput { 7 | @builtin(position) clip_position: vec4, 8 | @location(0) color: vec4, 9 | }; 10 | 11 | 12 | @vertex 13 | fn vertex(vertex: VertexInput) -> VertexOutput { 14 | var position = vec4(vertex.position.x, -vertex.position.y, 0.5, 1.0); 15 | var color = vertex.color; 16 | 17 | return VertexOutput(position, color); 18 | } 19 | 20 | struct FragmentInput { 21 | @location(0) color: vec4, 22 | }; 23 | 24 | struct FragmentOutput { 25 | @location(0) color: vec4, 26 | }; 27 | 28 | @fragment 29 | fn fragment(in: FragmentInput) -> FragmentOutput { 30 | return FragmentOutput(in.color); 31 | } 32 | -------------------------------------------------------------------------------- /crates/transform-gizmo-bevy/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A 3D transformation Gizmo for the Bevy game engine. 2 | //! 3 | //! transform-gizmo-bevy provides a feature-rich and configurable 3D transformation 4 | //! gizmo that can be used to manipulate entities' transforms (position, rotation, scale) 5 | //! visually. 6 | //! 7 | //! # Usage 8 | //! 9 | //! Add `TransformGizmoPlugin` to your App. 10 | //! 11 | //! ```ignore 12 | //! use bevy::prelude::*; 13 | //! use transform_gizmo_bevy::prelude::*; 14 | //! 15 | //! App::new() 16 | //! .add_plugins(DefaultPlugins) 17 | //! .add_plugins(TransformGizmoPlugin) 18 | //! .run(); 19 | //! ``` 20 | //! 21 | //! Add [`GizmoCamera`] component to your Camera entity. 22 | //! 23 | //! Add [`GizmoTarget`] component to any of your entities that you would like to manipulate the [`Transform`] of. 24 | //! 25 | //! # Configuration 26 | //! 27 | //! You can configure the gizmo by modifying the [`GizmoOptions`] resource. 28 | //! 29 | //! You can either set it up with [`App::insert_resource`] when creating your App, or at any point in a system with [`ResMut`]. 30 | 31 | use bevy_app::prelude::*; 32 | use bevy_asset::{AssetApp, Assets}; 33 | use bevy_ecs::prelude::*; 34 | use bevy_input::prelude::*; 35 | use bevy_math::{DQuat, DVec3, Vec2}; 36 | use bevy_picking::hover::HoverMap; 37 | use bevy_platform::collections::HashMap; 38 | use bevy_render::prelude::*; 39 | use bevy_transform::prelude::*; 40 | use bevy_window::{PrimaryWindow, Window}; 41 | use mouse_interact::MouseGizmoInteractionPlugin; 42 | use picking::TransformGizmoPickingPlugin; 43 | use uuid::Uuid; 44 | 45 | use render::{DrawDataHandles, TransformGizmoRenderPlugin}; 46 | use transform_gizmo::config::{ 47 | DEFAULT_SNAP_ANGLE, DEFAULT_SNAP_DISTANCE, DEFAULT_SNAP_SCALE, GizmoModeKind, 48 | TransformPivotPoint, 49 | }; 50 | pub use transform_gizmo::{ 51 | GizmoConfig, 52 | math::{Pos2, Rect}, 53 | *, 54 | }; 55 | 56 | pub mod mouse_interact; 57 | pub mod picking; 58 | pub mod prelude; 59 | 60 | mod render; 61 | 62 | const GIZMO_GROUP_UUID: Uuid = Uuid::from_u128(0x_1c90_3d44_0152_45e1_b1c9_889a_0203_e90c); 63 | 64 | /// Adds transform gizmos to the App. 65 | /// 66 | /// Gizmos are interactive tools that appear in the scene, allowing users to manipulate 67 | /// entities' transforms (position, rotation, scale) visually. 68 | pub struct TransformGizmoPlugin; 69 | 70 | impl Plugin for TransformGizmoPlugin { 71 | fn build(&self, app: &mut App) { 72 | app.init_asset::() 73 | .init_resource::() 74 | .init_resource::() 75 | .add_event::() 76 | .add_event::() 77 | .add_plugins(TransformGizmoRenderPlugin) 78 | .add_systems( 79 | Last, 80 | (handle_hotkeys, update_gizmos, draw_gizmos, cleanup_old_data).chain(), 81 | ); 82 | 83 | #[cfg(feature = "gizmo_picking_backend")] 84 | app.add_plugins(TransformGizmoPickingPlugin); 85 | #[cfg(feature = "mouse_interaction")] 86 | app.add_plugins(MouseGizmoInteractionPlugin); 87 | } 88 | } 89 | 90 | /// Various options for configuring the transform gizmos. 91 | #[derive(Resource, Copy, Clone, Debug)] 92 | pub struct GizmoOptions { 93 | /// Modes to use in the gizmos. 94 | pub gizmo_modes: EnumSet, 95 | /// Orientation of the gizmo. This affects the behaviour of transformations. 96 | pub gizmo_orientation: GizmoOrientation, 97 | /// Orientation of the gizmo. This affects the behaviour of transformations. 98 | pub pivot_point: TransformPivotPoint, 99 | /// Look and feel of the gizmo. 100 | pub visuals: GizmoVisuals, 101 | /// Whether snapping is enabled in the gizmo transformations. 102 | /// This may be overwritten with hotkeys ([`GizmoHotkeys::enable_snapping`]). 103 | pub snapping: bool, 104 | /// When snapping is enabled, snap twice as often. 105 | /// This may be overwritten with hotkeys ([`GizmoHotkeys::enable_accurate_mode`]). 106 | pub accurate_mode: bool, 107 | /// Angle increment for snapping rotations, in radians. 108 | pub snap_angle: f32, 109 | /// Distance increment for snapping translations. 110 | pub snap_distance: f32, 111 | /// Scale increment for snapping scalings. 112 | pub snap_scale: f32, 113 | /// If `true`, all [`GizmoTarget`]s are transformed 114 | /// using a single gizmo. If `false`, each target 115 | /// has its own gizmo. 116 | pub group_targets: bool, 117 | /// If set, this mode is forced active and other modes are disabled. 118 | /// This may be overwritten with hotkeys. 119 | pub mode_override: Option, 120 | /// Hotkeys for easier interaction with the gizmo. 121 | pub hotkeys: Option, 122 | /// Allows you to provide a custom viewport rect, which will be used to 123 | /// scale the cursor position. By default, this is set to `None` which means 124 | /// the full window size is used as the viewport. 125 | pub viewport_rect: Option, 126 | } 127 | 128 | impl Default for GizmoOptions { 129 | fn default() -> Self { 130 | Self { 131 | gizmo_modes: GizmoMode::all(), 132 | gizmo_orientation: GizmoOrientation::default(), 133 | pivot_point: TransformPivotPoint::default(), 134 | visuals: Default::default(), 135 | snapping: false, 136 | accurate_mode: false, 137 | snap_angle: DEFAULT_SNAP_ANGLE, 138 | snap_distance: DEFAULT_SNAP_DISTANCE, 139 | snap_scale: DEFAULT_SNAP_SCALE, 140 | group_targets: true, 141 | mode_override: None, 142 | hotkeys: None, 143 | viewport_rect: None, 144 | } 145 | } 146 | } 147 | 148 | /// Hotkeys for easier interaction with the gizmo. 149 | #[derive(Debug, Copy, Clone)] 150 | pub struct GizmoHotkeys { 151 | /// When pressed, transformations snap to according to snap values 152 | /// specified in [`GizmoOptions`]. 153 | pub enable_snapping: Option, 154 | /// When pressed, snapping is twice as accurate. 155 | pub enable_accurate_mode: Option, 156 | /// Toggles gizmo to rotate-only mode. 157 | pub toggle_rotate: Option, 158 | /// Toggles gizmo to translate-only mode. 159 | pub toggle_translate: Option, 160 | /// Toggles gizmo to scale-only mode. 161 | pub toggle_scale: Option, 162 | /// Limits overridden gizmo mode to X axis only. 163 | pub toggle_x: Option, 164 | /// Limits overridden gizmo mode to Y axis only. 165 | pub toggle_y: Option, 166 | /// Limits overridden gizmo mode to Z axis only. 167 | pub toggle_z: Option, 168 | /// When pressed, deactivates the gizmo if it 169 | /// was active. 170 | pub deactivate_gizmo: Option, 171 | /// If true, a mouse click deactivates the gizmo if it 172 | /// was active. 173 | pub mouse_click_deactivates: bool, 174 | } 175 | 176 | impl Default for GizmoHotkeys { 177 | fn default() -> Self { 178 | Self { 179 | enable_snapping: Some(KeyCode::ControlLeft), 180 | enable_accurate_mode: Some(KeyCode::ShiftLeft), 181 | toggle_rotate: Some(KeyCode::KeyR), 182 | toggle_translate: Some(KeyCode::KeyG), 183 | toggle_scale: Some(KeyCode::KeyS), 184 | toggle_x: Some(KeyCode::KeyX), 185 | toggle_y: Some(KeyCode::KeyY), 186 | toggle_z: Some(KeyCode::KeyZ), 187 | deactivate_gizmo: Some(KeyCode::Escape), 188 | mouse_click_deactivates: true, 189 | } 190 | } 191 | } 192 | 193 | /// Marks an entity as a gizmo target. 194 | /// 195 | /// When an entity has this component and a [`Transform`], 196 | /// a gizmo is shown, which can be used to manipulate the 197 | /// transform component. 198 | /// 199 | /// If target grouping is enabled in [`GizmoOptions`], 200 | /// a single gizmo is used for all targets. Otherwise 201 | /// a separate gizmo is used for each target entity. 202 | #[derive(Component, Copy, Clone, Debug, Default)] 203 | pub struct GizmoTarget { 204 | /// Whether any part of the gizmo is currently focused. 205 | pub(crate) is_focused: bool, 206 | 207 | /// Whether the gizmo is currently being interacted with. 208 | pub(crate) is_active: bool, 209 | 210 | /// This gets replaced with the result of the most recent 211 | /// gizmo interaction that affected this entity. 212 | pub(crate) latest_result: Option, 213 | } 214 | 215 | impl GizmoTarget { 216 | /// Whether any part of the gizmo is currently focused. 217 | pub fn is_focused(&self) -> bool { 218 | self.is_focused 219 | } 220 | 221 | /// Whether the gizmo is currently being interacted with. 222 | pub fn is_active(&self) -> bool { 223 | self.is_active 224 | } 225 | 226 | /// This gets replaced with the result of the most recent 227 | /// gizmo interaction that affected this entity. 228 | pub fn latest_result(&self) -> Option { 229 | self.latest_result 230 | } 231 | } 232 | 233 | /// Marker used to specify which camera to use for gizmos. 234 | #[derive(Component)] 235 | pub struct GizmoCamera; 236 | 237 | #[derive(Resource, Default)] 238 | struct GizmoStorage { 239 | target_entities: Vec, 240 | entity_gizmo_map: HashMap, 241 | gizmos: HashMap, 242 | } 243 | 244 | fn handle_hotkeys( 245 | mut gizmo_options: ResMut, 246 | keyboard_input: Res>, 247 | mouse_input: Res>, 248 | mut axes: Local>, 249 | ) { 250 | let Some(hotkeys) = gizmo_options.hotkeys else { 251 | // Hotkeys are disabled. 252 | return; 253 | }; 254 | 255 | if let Some(snapping_key) = hotkeys.enable_snapping { 256 | gizmo_options.snapping = keyboard_input.pressed(snapping_key); 257 | } 258 | 259 | if let Some(accurate_mode_key) = hotkeys.enable_accurate_mode { 260 | gizmo_options.accurate_mode = keyboard_input.pressed(accurate_mode_key); 261 | } 262 | 263 | // Modifier for inverting the mode axis selection. 264 | // For example, X would force X axis, but Shift-X would force Y and Z axes. 265 | let invert_modifier = keyboard_input.pressed(KeyCode::ShiftLeft); 266 | 267 | let x_hotkey_pressed = hotkeys 268 | .toggle_x 269 | .is_some_and(|key| keyboard_input.just_pressed(key)); 270 | 271 | let y_hotkey_pressed = hotkeys 272 | .toggle_y 273 | .is_some_and(|key| keyboard_input.just_pressed(key)); 274 | 275 | let z_hotkey_pressed = hotkeys 276 | .toggle_z 277 | .is_some_and(|key| keyboard_input.just_pressed(key)); 278 | 279 | let mut new_axes = EnumSet::empty(); 280 | 281 | if x_hotkey_pressed { 282 | new_axes = if invert_modifier { 283 | enum_set!(GizmoDirection::Y | GizmoDirection::Z) 284 | } else { 285 | enum_set!(GizmoDirection::X) 286 | }; 287 | }; 288 | 289 | if y_hotkey_pressed { 290 | new_axes = if !invert_modifier { 291 | enum_set!(GizmoDirection::Y) 292 | } else { 293 | enum_set!(GizmoDirection::X | GizmoDirection::Z) 294 | }; 295 | }; 296 | 297 | if z_hotkey_pressed { 298 | new_axes = if !invert_modifier { 299 | enum_set!(GizmoDirection::Z) 300 | } else { 301 | enum_set!(GizmoDirection::X | GizmoDirection::Y) 302 | }; 303 | }; 304 | 305 | // Replace the previously chosen axes, if any 306 | if !new_axes.is_empty() { 307 | if *axes == new_axes { 308 | axes.clear(); 309 | } else { 310 | *axes = new_axes; 311 | } 312 | } 313 | 314 | // If we do not have any mode overridden at this point, do not force the axes either. 315 | // This means you will have to first choose the mode and only then choose the axes. 316 | if gizmo_options.mode_override.is_none() { 317 | axes.clear(); 318 | } 319 | 320 | let rotate_hotkey_pressed = hotkeys 321 | .toggle_rotate 322 | .is_some_and(|key| keyboard_input.just_pressed(key)); 323 | let translate_hotkey_pressed = hotkeys 324 | .toggle_translate 325 | .is_some_and(|key| keyboard_input.just_pressed(key)); 326 | let scale_hotkey_pressed = hotkeys 327 | .toggle_scale 328 | .is_some_and(|key| keyboard_input.just_pressed(key)); 329 | 330 | // Determine which mode we should switch to based on what is currently chosen 331 | // and which hotkey we just pressed, if any. 332 | let mode_kind = if rotate_hotkey_pressed { 333 | // Rotation hotkey toggles between arcball and normal rotation 334 | if gizmo_options 335 | .mode_override 336 | .filter(GizmoMode::is_rotate) 337 | .is_some() 338 | { 339 | Some(GizmoModeKind::Arcball) 340 | } else { 341 | Some(GizmoModeKind::Rotate) 342 | } 343 | } else if translate_hotkey_pressed { 344 | Some(GizmoModeKind::Translate) 345 | } else if scale_hotkey_pressed { 346 | Some(GizmoModeKind::Scale) 347 | } else { 348 | gizmo_options.mode_override.map(|mode| mode.kind()) 349 | }; 350 | 351 | if let Some(kind) = mode_kind { 352 | gizmo_options.mode_override = GizmoMode::from_kind_and_axes(kind, *axes) 353 | .filter(|mode| gizmo_options.gizmo_modes.contains(*mode)) 354 | .or_else(|| { 355 | GizmoMode::all_from_kind(kind) 356 | .iter() 357 | .find(|mode| gizmo_options.gizmo_modes.contains(*mode)) 358 | }); 359 | } else { 360 | gizmo_options.mode_override = None; 361 | } 362 | 363 | // Check if gizmo should be deactivated 364 | if (hotkeys.mouse_click_deactivates 365 | && mouse_input.any_just_pressed([MouseButton::Left, MouseButton::Right])) 366 | || hotkeys 367 | .deactivate_gizmo 368 | .is_some_and(|key| keyboard_input.just_pressed(key)) 369 | { 370 | gizmo_options.mode_override = None; 371 | } 372 | } 373 | 374 | #[derive(Debug, Event, Default)] 375 | pub struct GizmoDragStarted; 376 | #[derive(Debug, Event, Default)] 377 | pub struct GizmoDragging; 378 | 379 | #[allow(clippy::too_many_arguments)] 380 | fn update_gizmos( 381 | q_window: Query<&Window, With>, 382 | q_gizmo_camera: Query<(&Camera, &GlobalTransform), With>, 383 | mut q_targets: Query<(Entity, &mut Transform, &mut GizmoTarget), Without>, 384 | mut drag_started: EventReader, 385 | mut dragging: EventReader, 386 | gizmo_options: Res, 387 | mut gizmo_storage: ResMut, 388 | mut last_cursor_pos: Local, 389 | mut last_scaled_cursor_pos: Local, 390 | #[cfg(feature = "gizmo_picking_backend")] hover_map: Res, 391 | ) { 392 | let Ok(window) = q_window.single() else { 393 | // No primary window found. 394 | return; 395 | }; 396 | 397 | let mut cursor_pos = window.cursor_position().unwrap_or_else(|| *last_cursor_pos); 398 | *last_cursor_pos = cursor_pos; 399 | 400 | let scale_factor = window.scale_factor(); 401 | 402 | let (camera, camera_transform) = { 403 | let mut active_camera = None; 404 | 405 | for camera in q_gizmo_camera.iter() { 406 | if !camera.0.is_active { 407 | continue; 408 | } 409 | if active_camera.is_some() { 410 | // multiple active cameras found, warn and skip 411 | bevy_log::warn!("Only one camera with a GizmoCamera component is supported."); 412 | return; 413 | } 414 | active_camera = Some(camera); 415 | } 416 | 417 | match active_camera { 418 | Some(camera) => camera, 419 | None => return, // no active cameras in the scene 420 | } 421 | }; 422 | 423 | let Some(viewport) = camera.logical_viewport_rect() else { 424 | return; 425 | }; 426 | 427 | // scale up the cursor pos from the custom viewport rect, if provided 428 | if let Some(custom_viewport) = gizmo_options.viewport_rect { 429 | let vp_ratio = viewport.size() / custom_viewport.size(); 430 | let mut scaled_cursor_pos = (cursor_pos - (custom_viewport.min - viewport.min)) * vp_ratio; 431 | if !viewport.contains(scaled_cursor_pos) { 432 | scaled_cursor_pos = *last_scaled_cursor_pos; 433 | } 434 | *last_scaled_cursor_pos = scaled_cursor_pos; 435 | cursor_pos = scaled_cursor_pos; 436 | }; 437 | 438 | let viewport = Rect::from_min_max( 439 | Pos2::new(viewport.min.x, viewport.min.y), 440 | Pos2::new(viewport.max.x, viewport.max.y), 441 | ); 442 | 443 | let projection_matrix = camera.clip_from_view(); 444 | 445 | let view_matrix = camera_transform.compute_matrix().inverse(); 446 | 447 | let mut snap_angle = gizmo_options.snap_angle; 448 | let mut snap_distance = gizmo_options.snap_distance; 449 | let mut snap_scale = gizmo_options.snap_scale; 450 | 451 | if gizmo_options.accurate_mode { 452 | snap_angle /= 2.0; 453 | snap_distance /= 2.0; 454 | snap_scale /= 2.0; 455 | } 456 | 457 | let gizmo_config = GizmoConfig { 458 | view_matrix: view_matrix.as_dmat4().into(), 459 | projection_matrix: projection_matrix.as_dmat4().into(), 460 | viewport, 461 | modes: gizmo_options.gizmo_modes, 462 | mode_override: gizmo_options.mode_override, 463 | orientation: gizmo_options.gizmo_orientation, 464 | pivot_point: gizmo_options.pivot_point, 465 | visuals: gizmo_options.visuals, 466 | snapping: gizmo_options.snapping, 467 | snap_angle, 468 | snap_distance, 469 | snap_scale, 470 | pixels_per_point: scale_factor, 471 | }; 472 | 473 | #[cfg(feature = "gizmo_picking_backend")] 474 | // The gizmo picking backend sends hits to the entity the gizmo is targeting. 475 | // We check for those entities in the hover map to. 476 | let any_gizmo_hovered = q_targets 477 | .iter() 478 | .any(|(entity, ..)| hover_map.iter().any(|(_, map)| map.contains_key(&entity))); 479 | #[cfg(not(feature = "gizmo_picking_backend"))] 480 | let any_gizmo_hovered = true; 481 | 482 | let hovered = any_gizmo_hovered || gizmo_options.mode_override.is_some(); 483 | 484 | let gizmo_interaction = GizmoInteraction { 485 | cursor_pos: (cursor_pos.x, cursor_pos.y), 486 | hovered, 487 | drag_started: drag_started.read().len() > 0, 488 | dragging: dragging.read().len() > 0, 489 | }; 490 | 491 | let mut target_entities: Vec = vec![]; 492 | let mut target_transforms: Vec = vec![]; 493 | 494 | for (entity, mut target_transform, mut gizmo_target) in &mut q_targets { 495 | target_entities.push(entity); 496 | target_transforms.push(*target_transform); 497 | 498 | if gizmo_options.group_targets { 499 | gizmo_storage 500 | .entity_gizmo_map 501 | .insert(entity, GIZMO_GROUP_UUID); 502 | continue; 503 | } 504 | 505 | let mut gizmo_uuid = *gizmo_storage 506 | .entity_gizmo_map 507 | .entry(entity) 508 | .or_insert_with(Uuid::new_v4); 509 | 510 | // Group gizmo was used previously 511 | if gizmo_uuid == GIZMO_GROUP_UUID { 512 | gizmo_uuid = Uuid::new_v4(); 513 | gizmo_storage.entity_gizmo_map.insert(entity, gizmo_uuid); 514 | } 515 | 516 | let gizmo = gizmo_storage.gizmos.entry(gizmo_uuid).or_default(); 517 | gizmo.update_config(gizmo_config); 518 | 519 | let gizmo_result = gizmo.update( 520 | gizmo_interaction, 521 | &[math::Transform { 522 | translation: target_transform.translation.as_dvec3().into(), 523 | rotation: target_transform.rotation.as_dquat().into(), 524 | scale: target_transform.scale.as_dvec3().into(), 525 | }], 526 | ); 527 | 528 | let is_focused = gizmo.is_focused(); 529 | 530 | gizmo_target.is_active = gizmo_result.is_some(); 531 | gizmo_target.is_focused = is_focused; 532 | 533 | if let Some((_, updated_targets)) = &gizmo_result { 534 | let Some(result_transform) = updated_targets.first() else { 535 | bevy_log::warn!("No transform found in GizmoResult!"); 536 | continue; 537 | }; 538 | 539 | target_transform.translation = DVec3::from(result_transform.translation).as_vec3(); 540 | target_transform.rotation = DQuat::from(result_transform.rotation).as_quat(); 541 | target_transform.scale = DVec3::from(result_transform.scale).as_vec3(); 542 | } 543 | 544 | gizmo_target.latest_result = gizmo_result.map(|(result, _)| result); 545 | } 546 | 547 | if gizmo_options.group_targets { 548 | let gizmo = gizmo_storage.gizmos.entry(GIZMO_GROUP_UUID).or_default(); 549 | gizmo.update_config(gizmo_config); 550 | 551 | let gizmo_result = gizmo.update( 552 | gizmo_interaction, 553 | target_transforms 554 | .iter() 555 | .map(|transform| transform_gizmo::math::Transform { 556 | translation: transform.translation.as_dvec3().into(), 557 | rotation: transform.rotation.as_dquat().into(), 558 | scale: transform.scale.as_dvec3().into(), 559 | }) 560 | .collect::>() 561 | .as_slice(), 562 | ); 563 | 564 | let is_focused = gizmo.is_focused(); 565 | 566 | for (i, (_, mut target_transform, mut gizmo_target)) in q_targets.iter_mut().enumerate() { 567 | gizmo_target.is_active = gizmo_result.is_some(); 568 | gizmo_target.is_focused = is_focused; 569 | 570 | if let Some((_, updated_targets)) = &gizmo_result { 571 | let Some(result_transform) = updated_targets.get(i) else { 572 | bevy_log::warn!("No transform {i} found in GizmoResult!"); 573 | continue; 574 | }; 575 | 576 | target_transform.translation = DVec3::from(result_transform.translation).as_vec3(); 577 | target_transform.rotation = DQuat::from(result_transform.rotation).as_quat(); 578 | target_transform.scale = DVec3::from(result_transform.scale).as_vec3(); 579 | } 580 | 581 | gizmo_target.latest_result = gizmo_result.as_ref().map(|(result, _)| *result); 582 | } 583 | } 584 | 585 | gizmo_storage.target_entities = target_entities; 586 | } 587 | 588 | fn draw_gizmos( 589 | gizmo_storage: Res, 590 | mut draw_data_assets: ResMut>, 591 | mut draw_data_handles: ResMut, 592 | ) { 593 | for (gizmo_uuid, gizmo) in &gizmo_storage.gizmos { 594 | let draw_data = gizmo.draw(); 595 | 596 | let mut bevy_draw_data = render::GizmoDrawData::default(); 597 | 598 | let (asset, is_new_asset) = if let Some(handle) = draw_data_handles.handles.get(gizmo_uuid) 599 | { 600 | (draw_data_assets.get_mut(handle).unwrap(), false) 601 | } else { 602 | (&mut bevy_draw_data, true) 603 | }; 604 | 605 | let viewport = &gizmo.config().viewport; 606 | 607 | asset.0.vertices.clear(); 608 | asset 609 | .0 610 | .vertices 611 | .extend(draw_data.vertices.into_iter().map(|vert| { 612 | [ 613 | ((vert[0] - viewport.left()) / viewport.width()) * 2.0 - 1.0, 614 | ((vert[1] - viewport.top()) / viewport.height()) * 2.0 - 1.0, 615 | ] 616 | })); 617 | 618 | asset.0.colors = draw_data.colors; 619 | asset.0.indices = draw_data.indices; 620 | 621 | if is_new_asset { 622 | let asset = draw_data_assets.add(bevy_draw_data); 623 | 624 | draw_data_handles 625 | .handles 626 | .insert(*gizmo_uuid, asset.clone().into()); 627 | } 628 | } 629 | } 630 | 631 | fn cleanup_old_data( 632 | gizmo_options: Res, 633 | mut gizmo_storage: ResMut, 634 | mut draw_data_handles: ResMut, 635 | ) { 636 | let target_entities = std::mem::take(&mut gizmo_storage.target_entities); 637 | 638 | let mut gizmos_to_keep = vec![]; 639 | 640 | if gizmo_options.group_targets && !target_entities.is_empty() { 641 | gizmos_to_keep.push(GIZMO_GROUP_UUID); 642 | } 643 | 644 | gizmo_storage.entity_gizmo_map.retain(|entity, uuid| { 645 | if !target_entities.contains(entity) { 646 | false 647 | } else { 648 | gizmos_to_keep.push(*uuid); 649 | 650 | true 651 | } 652 | }); 653 | 654 | gizmo_storage 655 | .gizmos 656 | .retain(|uuid, _| gizmos_to_keep.contains(uuid)); 657 | 658 | draw_data_handles 659 | .handles 660 | .retain(|uuid, _| gizmos_to_keep.contains(uuid)); 661 | } 662 | -------------------------------------------------------------------------------- /crates/transform-gizmo-bevy/src/mouse_interact.rs: -------------------------------------------------------------------------------- 1 | use bevy_app::{App, Plugin, Update}; 2 | use bevy_ecs::{event::EventWriter, system::Res}; 3 | use bevy_input::{ButtonInput, mouse::MouseButton}; 4 | 5 | use crate::{GizmoDragStarted, GizmoDragging}; 6 | 7 | pub struct MouseGizmoInteractionPlugin; 8 | impl Plugin for MouseGizmoInteractionPlugin { 9 | fn build(&self, app: &mut App) { 10 | app.add_systems(Update, mouse_interact_gizmo); 11 | } 12 | } 13 | 14 | fn mouse_interact_gizmo( 15 | mouse: Res>, 16 | mut drag_started: EventWriter, 17 | mut dragging: EventWriter, 18 | ) { 19 | if mouse.just_pressed(MouseButton::Left) { 20 | drag_started.write_default(); 21 | } 22 | 23 | if mouse.pressed(MouseButton::Left) { 24 | dragging.write_default(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /crates/transform-gizmo-bevy/src/picking.rs: -------------------------------------------------------------------------------- 1 | use bevy_app::{Plugin, PreUpdate}; 2 | use bevy_ecs::{ 3 | event::EventWriter, 4 | schedule::IntoScheduleConfigs, 5 | system::{Query, Res}, 6 | }; 7 | use bevy_picking::{ 8 | PickSet, 9 | backend::{HitData, PointerHits}, 10 | pointer::{PointerId, PointerLocation}, 11 | }; 12 | 13 | use crate::GizmoStorage; 14 | 15 | pub struct TransformGizmoPickingPlugin; 16 | 17 | impl Plugin for TransformGizmoPickingPlugin { 18 | fn build(&self, app: &mut bevy_app::App) { 19 | app.add_systems(PreUpdate, update_hits.in_set(PickSet::Backend)); 20 | } 21 | } 22 | 23 | fn update_hits( 24 | storage: Res, 25 | mut output: EventWriter, 26 | pointers: Query<(&PointerId, &PointerLocation)>, 27 | ) { 28 | let gizmos = storage 29 | .entity_gizmo_map 30 | .iter() 31 | .filter_map(|(entity, uuid)| storage.gizmos.get(uuid).map(|gizmo| (*entity, gizmo))) 32 | .collect::>(); 33 | 34 | for (pointer_id, pointer_location) in &pointers { 35 | let Some(location) = &pointer_location.location else { 36 | continue; 37 | }; 38 | let hits = gizmos 39 | .iter() 40 | .filter(|(_entity, gizmo)| { 41 | gizmo.pick_preview((location.position.x, location.position.y)) 42 | }) 43 | .map(|(entity, _gizmo)| (*entity, HitData::new(*entity, 0.0, None, None))) 44 | .collect::>(); 45 | 46 | output.write(PointerHits::new(*pointer_id, hits, 0.0)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /crates/transform-gizmo-bevy/src/prelude.rs: -------------------------------------------------------------------------------- 1 | pub use transform_gizmo::prelude::*; 2 | 3 | pub use crate::{GizmoCamera, GizmoOptions, GizmoTarget, TransformGizmoPlugin}; 4 | -------------------------------------------------------------------------------- /crates/transform-gizmo-bevy/src/render.rs: -------------------------------------------------------------------------------- 1 | use bevy_app::{App, Plugin}; 2 | use bevy_asset::{Asset, AssetId, Handle, load_internal_asset, weak_handle}; 3 | use bevy_core_pipeline::core_3d::{CORE_3D_DEPTH_FORMAT, Transparent3d}; 4 | use bevy_core_pipeline::prepass::{ 5 | DeferredPrepass, DepthPrepass, MotionVectorPrepass, NormalPrepass, 6 | }; 7 | use bevy_derive::{Deref, DerefMut}; 8 | use bevy_ecs::prelude::*; 9 | use bevy_ecs::query::ROQueryItem; 10 | use bevy_ecs::system::SystemParamItem; 11 | use bevy_ecs::system::lifetimeless::{Read, SRes}; 12 | use bevy_image::BevyDefault as _; 13 | use bevy_pbr::{MeshPipeline, MeshPipelineKey, SetMeshViewBindGroup}; 14 | use bevy_platform::collections::{HashMap, HashSet}; 15 | use bevy_reflect::{Reflect, TypePath}; 16 | use bevy_render::extract_component::ExtractComponent; 17 | use bevy_render::mesh::PrimitiveTopology; 18 | use bevy_render::prelude::*; 19 | use bevy_render::render_asset::{ 20 | PrepareAssetError, RenderAsset, RenderAssetPlugin, RenderAssetUsages, RenderAssets, 21 | prepare_assets, 22 | }; 23 | use bevy_render::render_phase::{ 24 | AddRenderCommand, DrawFunctions, PhaseItem, PhaseItemExtraIndex, RenderCommand, 25 | RenderCommandResult, SetItemPipeline, TrackedRenderPass, ViewSortedRenderPhases, 26 | }; 27 | use bevy_render::render_resource::{ 28 | BlendState, Buffer, BufferInitDescriptor, BufferUsages, ColorTargetState, ColorWrites, 29 | CompareFunction, DepthBiasState, DepthStencilState, FragmentState, IndexFormat, 30 | MultisampleState, PipelineCache, PrimitiveState, RenderPipelineDescriptor, 31 | SpecializedRenderPipeline, SpecializedRenderPipelines, StencilState, TextureFormat, 32 | VertexAttribute, VertexBufferLayout, VertexFormat, VertexState, VertexStepMode, 33 | }; 34 | use bevy_render::renderer::RenderDevice; 35 | use bevy_render::sync_world::TemporaryRenderEntity; 36 | use bevy_render::view::{ExtractedView, RenderLayers, ViewTarget}; 37 | use bevy_render::{Extract, Render, RenderApp, RenderSet}; 38 | use bytemuck::cast_slice; 39 | use uuid::Uuid; 40 | 41 | use crate::GizmoCamera; 42 | 43 | const GIZMO_SHADER_HANDLE: Handle = weak_handle!("e44be110-cb2b-4a8d-9c0c-965424e6a633"); 44 | 45 | pub(crate) struct TransformGizmoRenderPlugin; 46 | 47 | impl Plugin for TransformGizmoRenderPlugin { 48 | fn build(&self, app: &mut App) { 49 | load_internal_asset!(app, GIZMO_SHADER_HANDLE, "gizmo.wgsl", Shader::from_wgsl); 50 | 51 | app.register_type::() 52 | .init_resource::() 53 | .add_plugins(RenderAssetPlugin::::default()); 54 | 55 | let Some(render_app) = app.get_sub_app_mut(RenderApp) else { 56 | return; 57 | }; 58 | 59 | render_app 60 | .add_render_command::() 61 | .init_resource::>() 62 | .add_systems( 63 | Render, 64 | queue_transform_gizmos 65 | .in_set(RenderSet::Queue) 66 | .after(prepare_assets::), 67 | ); 68 | } 69 | 70 | fn finish(&self, app: &mut App) { 71 | let Some(render_app) = app.get_sub_app_mut(RenderApp) else { 72 | return; 73 | }; 74 | 75 | render_app 76 | .add_systems(ExtractSchedule, extract_gizmo_data) 77 | .init_resource::(); 78 | } 79 | } 80 | 81 | #[derive(Resource, Default, Reflect)] 82 | #[reflect(Resource)] 83 | pub(crate) struct DrawDataHandles { 84 | pub(crate) handles: HashMap, 85 | } 86 | 87 | #[derive( 88 | Component, Default, Clone, Debug, Deref, DerefMut, Reflect, PartialEq, Eq, ExtractComponent, 89 | )] 90 | #[reflect(Component)] 91 | pub(crate) struct GizmoDrawDataHandle(pub(crate) Handle); 92 | 93 | impl From> for GizmoDrawDataHandle { 94 | fn from(handle: Handle) -> Self { 95 | Self(handle) 96 | } 97 | } 98 | 99 | impl From for AssetId { 100 | fn from(handle: GizmoDrawDataHandle) -> Self { 101 | handle.0.id() 102 | } 103 | } 104 | impl From<&GizmoDrawDataHandle> for AssetId { 105 | fn from(handle: &GizmoDrawDataHandle) -> Self { 106 | handle.0.id() 107 | } 108 | } 109 | 110 | fn extract_gizmo_data(mut commands: Commands, handles: Extract>) { 111 | let handle_weak_refs = handles 112 | .handles 113 | .values() 114 | .map(|handle| handle.clone_weak()) 115 | .collect::>(); 116 | 117 | for handle in handle_weak_refs { 118 | commands.spawn((GizmoDrawDataHandle(handle), TemporaryRenderEntity)); 119 | } 120 | } 121 | 122 | #[derive(Asset, Debug, Default, Clone, TypePath)] 123 | pub(crate) struct GizmoDrawData(pub(crate) transform_gizmo::GizmoDrawData); 124 | 125 | #[derive(Debug, Clone)] 126 | pub(crate) struct GizmoBuffers { 127 | position_buffer: Buffer, 128 | index_buffer: Buffer, 129 | color_buffer: Buffer, 130 | index_count: u32, 131 | } 132 | 133 | impl RenderAsset for GizmoBuffers { 134 | type SourceAsset = GizmoDrawData; 135 | type Param = SRes; 136 | 137 | fn asset_usage(_source_asset: &Self::SourceAsset) -> RenderAssetUsages { 138 | RenderAssetUsages::all() 139 | } 140 | 141 | fn prepare_asset( 142 | source_asset: Self::SourceAsset, 143 | _: AssetId, 144 | render_device: &mut SystemParamItem, 145 | ) -> std::result::Result> { 146 | let position_buffer_data = cast_slice(&source_asset.0.vertices); 147 | let position_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { 148 | usage: BufferUsages::VERTEX, 149 | label: Some("TransformGizmo Position Buffer"), 150 | contents: position_buffer_data, 151 | }); 152 | 153 | let index_buffer_data = cast_slice(&source_asset.0.indices); 154 | let index_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { 155 | usage: BufferUsages::INDEX, 156 | label: Some("TransformGizmo Index Buffer"), 157 | contents: index_buffer_data, 158 | }); 159 | 160 | let color_buffer_data = cast_slice(&source_asset.0.colors); 161 | let color_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { 162 | usage: BufferUsages::VERTEX, 163 | label: Some("TransformGizmo Color Buffer"), 164 | contents: color_buffer_data, 165 | }); 166 | 167 | Ok(Self { 168 | index_buffer, 169 | position_buffer, 170 | color_buffer, 171 | index_count: source_asset.0.indices.len() as u32, 172 | }) 173 | } 174 | } 175 | 176 | struct DrawTransformGizmo; 177 | 178 | impl RenderCommand

for DrawTransformGizmo { 179 | type ViewQuery = (); 180 | type ItemQuery = Read; 181 | type Param = SRes>; 182 | 183 | #[inline] 184 | fn render<'w>( 185 | _item: &P, 186 | _view: ROQueryItem<'w, Self::ViewQuery>, 187 | handle: Option>, 188 | gizmos: SystemParamItem<'w, '_, Self::Param>, 189 | pass: &mut TrackedRenderPass<'w>, 190 | ) -> RenderCommandResult { 191 | let Some(handle) = handle else { 192 | return RenderCommandResult::Failure("No GizmoDrawDataHandle component found"); 193 | }; 194 | 195 | let Some(gizmo) = gizmos.into_inner().get(handle) else { 196 | return RenderCommandResult::Failure("No GizmoDrawDataHandle inner found"); 197 | }; 198 | 199 | if gizmo.index_buffer.size() > 0 { 200 | pass.set_index_buffer(gizmo.index_buffer.slice(..), 0, IndexFormat::Uint32); 201 | pass.set_vertex_buffer(0, gizmo.position_buffer.slice(..)); 202 | pass.set_vertex_buffer(1, gizmo.color_buffer.slice(..)); 203 | 204 | pass.draw_indexed(0..gizmo.index_count, 0, 0..1); 205 | } 206 | 207 | RenderCommandResult::Success 208 | } 209 | } 210 | 211 | #[derive(Clone, Resource)] 212 | struct TransformGizmoPipeline { 213 | mesh_pipeline: MeshPipeline, 214 | } 215 | 216 | impl FromWorld for TransformGizmoPipeline { 217 | fn from_world(render_world: &mut World) -> Self { 218 | Self { 219 | mesh_pipeline: render_world.resource::().clone(), 220 | } 221 | } 222 | } 223 | 224 | #[derive(PartialEq, Eq, Hash, Clone)] 225 | struct TransformGizmoPipelineKey { 226 | view_key: MeshPipelineKey, 227 | perspective: bool, 228 | } 229 | 230 | impl SpecializedRenderPipeline for TransformGizmoPipeline { 231 | type Key = TransformGizmoPipelineKey; 232 | 233 | fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor { 234 | let mut shader_defs = vec![ 235 | // TODO: When is this flag actually used? 236 | // #[cfg(feature = "webgl")] 237 | // "SIXTEEN_BYTE_ALIGNMENT".into(), 238 | ]; 239 | 240 | if key.perspective { 241 | shader_defs.push("PERSPECTIVE".into()); 242 | } 243 | 244 | let format = if key.view_key.contains(MeshPipelineKey::HDR) { 245 | ViewTarget::TEXTURE_FORMAT_HDR 246 | } else { 247 | TextureFormat::bevy_default() 248 | }; 249 | 250 | let view_layout = self 251 | .mesh_pipeline 252 | .get_view_layout(key.view_key.into()) 253 | .clone(); 254 | 255 | RenderPipelineDescriptor { 256 | label: Some("TransformGizmo Pipeline".into()), 257 | zero_initialize_workgroup_memory: true, // ? 258 | vertex: VertexState { 259 | shader: GIZMO_SHADER_HANDLE, 260 | entry_point: "vertex".into(), 261 | shader_defs: shader_defs.clone(), 262 | buffers: vec![ 263 | VertexBufferLayout { 264 | array_stride: VertexFormat::Float32x2.size(), 265 | step_mode: VertexStepMode::Vertex, 266 | attributes: vec![VertexAttribute { 267 | format: VertexFormat::Float32x2, 268 | offset: 0, 269 | shader_location: 0, 270 | }], 271 | }, 272 | VertexBufferLayout { 273 | array_stride: VertexFormat::Float32x4.size(), 274 | step_mode: VertexStepMode::Vertex, 275 | attributes: vec![VertexAttribute { 276 | format: VertexFormat::Float32x4, 277 | offset: 0, 278 | shader_location: 1, 279 | }], 280 | }, 281 | ], 282 | }, 283 | fragment: Some(FragmentState { 284 | shader: GIZMO_SHADER_HANDLE, 285 | shader_defs, 286 | entry_point: "fragment".into(), 287 | targets: vec![Some(ColorTargetState { 288 | format, 289 | blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING), 290 | write_mask: ColorWrites::ALL, 291 | })], 292 | }), 293 | layout: vec![view_layout], 294 | primitive: PrimitiveState { 295 | topology: PrimitiveTopology::TriangleList, 296 | cull_mode: None, 297 | ..PrimitiveState::default() 298 | }, 299 | depth_stencil: Some(DepthStencilState { 300 | format: CORE_3D_DEPTH_FORMAT, 301 | depth_write_enabled: true, 302 | depth_compare: CompareFunction::Always, 303 | stencil: StencilState::default(), 304 | bias: DepthBiasState::default(), 305 | }), 306 | multisample: MultisampleState { 307 | count: key.view_key.msaa_samples(), 308 | mask: !0, 309 | alpha_to_coverage_enabled: false, 310 | }, 311 | push_constant_ranges: vec![], 312 | } 313 | } 314 | } 315 | 316 | type DrawGizmo = (SetItemPipeline, SetMeshViewBindGroup<0>, DrawTransformGizmo); 317 | 318 | #[allow(clippy::too_many_arguments, clippy::type_complexity)] 319 | fn queue_transform_gizmos( 320 | draw_functions: Res>, 321 | pipeline: Res, 322 | mut pipelines: ResMut>, 323 | pipeline_cache: Res, 324 | msaa_q: Query, With>, 325 | transform_gizmos: Query<(Entity, &GizmoDrawDataHandle)>, 326 | transform_gizmo_assets: Res>, 327 | mut views: Query<( 328 | Entity, 329 | &ExtractedView, 330 | Option<&Msaa>, 331 | Option<&RenderLayers>, 332 | ( 333 | Has, 334 | Has, 335 | Has, 336 | Has, 337 | ), 338 | )>, 339 | mut transparent_render_phases: ResMut>, 340 | ) { 341 | let draw_function = draw_functions.read().get_id::().unwrap(); 342 | let camera_msaa = msaa_q.single().ok().flatten(); 343 | for ( 344 | view_entity, 345 | view, 346 | entity_msaa, 347 | _render_layers, 348 | (normal_prepass, depth_prepass, motion_vector_prepass, deferred_prepass), 349 | ) in &mut views 350 | { 351 | let Some(transparent_phase) = transparent_render_phases.get_mut(&view.retained_view_entity) 352 | else { 353 | continue; 354 | }; 355 | 356 | // entity_msaa > camera_msaa > default 357 | let msaa_sample_count = entity_msaa.map_or( 358 | camera_msaa.unwrap_or(&Msaa::default()).samples(), 359 | Msaa::samples, 360 | ); 361 | 362 | let mut view_key = MeshPipelineKey::from_msaa_samples(msaa_sample_count) 363 | | MeshPipelineKey::from_hdr(view.hdr); 364 | 365 | if normal_prepass { 366 | view_key |= MeshPipelineKey::NORMAL_PREPASS; 367 | } 368 | 369 | if depth_prepass { 370 | view_key |= MeshPipelineKey::DEPTH_PREPASS; 371 | } 372 | 373 | if motion_vector_prepass { 374 | view_key |= MeshPipelineKey::MOTION_VECTOR_PREPASS; 375 | } 376 | 377 | if deferred_prepass { 378 | view_key |= MeshPipelineKey::DEFERRED_PREPASS; 379 | } 380 | 381 | for (entity, handle) in &transform_gizmos { 382 | let Some(_) = transform_gizmo_assets.get(handle.0.id()) else { 383 | continue; 384 | }; 385 | 386 | let pipeline = pipelines.specialize( 387 | &pipeline_cache, 388 | &pipeline, 389 | TransformGizmoPipelineKey { 390 | view_key, 391 | perspective: true, 392 | }, 393 | ); 394 | 395 | transparent_phase.add(Transparent3d { 396 | entity: (entity, view_entity.into()), 397 | draw_function, 398 | pipeline, 399 | distance: 0., 400 | batch_range: 0..1, 401 | extra_index: PhaseItemExtraIndex::None, 402 | indexed: false, 403 | }); 404 | } 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /crates/transform-gizmo-egui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "transform-gizmo-egui" 3 | description = "egui integration for transform-gizmo" 4 | version.workspace = true 5 | rust-version = "1.85.0" 6 | edition.workspace = true 7 | license.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | authors.workspace = true 11 | categories = ["gui", "game-development", "game-engines"] 12 | keywords = ["gizmo", "egui"] 13 | readme = "../../README.md" 14 | include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml"] 15 | 16 | [dependencies] 17 | transform-gizmo.workspace = true 18 | egui.workspace = true 19 | 20 | [dev-dependencies] 21 | eframe.workspace = true 22 | 23 | [lints] 24 | workspace = true 25 | -------------------------------------------------------------------------------- /crates/transform-gizmo-egui/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Provides a 3D transformation gizmo for the Egui library. 2 | //! 3 | //! transform-gizmo-egui provides a feature-rich and configurable gizmo 4 | //! that can be used for 3d transformations (translation, rotation, scale). 5 | //! 6 | //! # Usage 7 | //! 8 | //! Create a new `Gizmo` instance once. 9 | //! 10 | //! ``` 11 | //! use transform_gizmo_egui::prelude::*; 12 | //! 13 | //! let gizmo = Gizmo::default(); 14 | //! ``` 15 | //! 16 | //! Update the gizmo configuration as needed, for example, when the camera moves. 17 | //! 18 | //! ```ignore 19 | //! gizmo.update_config(GizmoConfig { 20 | //! view_matrix: view_matrix.into(), 21 | //! projection_matrix: projection_matrix.into(), 22 | //! modes: GizmoMode::all(), 23 | //! orientation: GizmoOrientation::Local, 24 | //! ..Default::default() 25 | //! }); 26 | //! ``` 27 | //! 28 | //! Finally, interact with the gizmo. The function takes a slice of transforms as an 29 | //! input. The result is [`Some`] if the gizmo was successfully interacted with this frame. 30 | //! In the result you can find the modified transforms, in the same order as was given to the function 31 | //! as arguments. 32 | //! 33 | //! ```ignore 34 | //! let mut transform = Transform::from_scale_rotation_translation(scale, rotation, translation); 35 | //! 36 | //! if let Some((result, new_transforms)) = gizmo.interact(ui, &[transform]) { 37 | //! for (new_transform, transform) in 38 | //! new_transforms.iter().zip(std::iter::once(&mut transform)) 39 | //! { 40 | //! // Apply the modified transforms 41 | //! *transform = *new_transform; 42 | //! } 43 | //! } 44 | //! ``` 45 | //! 46 | //! 47 | use egui::{Mesh, PointerButton, Pos2, Rgba, Sense, Ui, Vec2, epaint::Vertex}; 48 | 49 | use transform_gizmo::math::Transform; 50 | pub use transform_gizmo::*; 51 | pub mod prelude; 52 | 53 | pub trait GizmoExt { 54 | /// Interact with the gizmo and draw it to Ui. 55 | /// 56 | /// Returns result of the gizmo interaction. 57 | fn interact(&mut self, ui: &Ui, targets: &[Transform]) 58 | -> Option<(GizmoResult, Vec)>; 59 | } 60 | 61 | impl GizmoExt for Gizmo { 62 | fn interact( 63 | &mut self, 64 | ui: &Ui, 65 | targets: &[Transform], 66 | ) -> Option<(GizmoResult, Vec)> { 67 | let cursor_pos = ui 68 | .input(|input| input.pointer.hover_pos()) 69 | .unwrap_or_default(); 70 | 71 | let mut viewport = self.config().viewport; 72 | if !viewport.is_finite() { 73 | viewport = ui.clip_rect(); 74 | } 75 | 76 | let egui_viewport = Rect { 77 | min: Pos2::new(viewport.min.x, viewport.min.y), 78 | max: Pos2::new(viewport.max.x, viewport.max.y), 79 | }; 80 | 81 | self.update_config(GizmoConfig { 82 | viewport, 83 | pixels_per_point: ui.ctx().pixels_per_point(), 84 | ..*self.config() 85 | }); 86 | 87 | let interaction = ui.interact( 88 | Rect::from_center_size(cursor_pos, Vec2::splat(1.0)), 89 | ui.id().with("_interaction"), 90 | Sense::click_and_drag(), 91 | ); 92 | let hovered = interaction.hovered(); 93 | 94 | let gizmo_result = self.update( 95 | GizmoInteraction { 96 | cursor_pos: (cursor_pos.x, cursor_pos.y), 97 | hovered, 98 | drag_started: ui 99 | .input(|input| input.pointer.button_pressed(PointerButton::Primary)), 100 | dragging: ui.input(|input| input.pointer.button_down(PointerButton::Primary)), 101 | }, 102 | targets, 103 | ); 104 | 105 | let draw_data = self.draw(); 106 | 107 | egui::Painter::new(ui.ctx().clone(), ui.layer_id(), egui_viewport).add(Mesh { 108 | indices: draw_data.indices, 109 | vertices: draw_data 110 | .vertices 111 | .into_iter() 112 | .zip(draw_data.colors) 113 | .map(|(pos, [r, g, b, a])| Vertex { 114 | pos: pos.into(), 115 | uv: Pos2::default(), 116 | color: Rgba::from_rgba_premultiplied(r, g, b, a).into(), 117 | }) 118 | .collect(), 119 | ..Default::default() 120 | }); 121 | 122 | gizmo_result 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /crates/transform-gizmo-egui/src/prelude.rs: -------------------------------------------------------------------------------- 1 | pub use transform_gizmo::prelude::*; 2 | 3 | pub use crate::GizmoExt; 4 | -------------------------------------------------------------------------------- /crates/transform-gizmo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "transform-gizmo" 3 | description = "3D transformation gizmo" 4 | version.workspace = true 5 | rust-version = "1.85.0" 6 | edition.workspace = true 7 | license.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | authors.workspace = true 11 | categories = ["gui", "game-development", "game-engines"] 12 | keywords = ["gizmo"] 13 | readme = "../../README.md" 14 | include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml"] 15 | 16 | [dependencies] 17 | emath.workspace = true 18 | epaint.workspace = true 19 | ecolor.workspace = true 20 | glam.workspace = true 21 | mint.workspace = true 22 | enum_dispatch.workspace = true 23 | ahash.workspace = true 24 | enumset.workspace = true 25 | 26 | [lints] 27 | workspace = true 28 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::ops::{Deref, DerefMut}; 2 | 3 | pub use ecolor::Color32; 4 | 5 | use emath::Rect; 6 | use enumset::{EnumSet, EnumSetType, enum_set}; 7 | 8 | use crate::math::{ 9 | DMat4, DQuat, DVec3, DVec4, Transform, Vec4Swizzles, screen_to_world, world_to_screen, 10 | }; 11 | 12 | /// The default snapping distance for rotation in radians 13 | pub const DEFAULT_SNAP_ANGLE: f32 = std::f32::consts::PI / 32.0; 14 | /// The default snapping distance for translation 15 | pub const DEFAULT_SNAP_DISTANCE: f32 = 0.1; 16 | /// The default snapping distance for scale 17 | pub const DEFAULT_SNAP_SCALE: f32 = 0.1; 18 | 19 | /// Configuration of a gizmo. 20 | /// 21 | /// Defines how the gizmo is drawn to the screen and 22 | /// how it can be interacted with. 23 | #[derive(Debug, Copy, Clone)] 24 | pub struct GizmoConfig { 25 | /// View matrix for the gizmo, aligning it with the camera's viewpoint. 26 | pub view_matrix: mint::RowMatrix4, 27 | /// Projection matrix for the gizmo, determining how it is projected onto the screen. 28 | pub projection_matrix: mint::RowMatrix4, 29 | /// Screen area where the gizmo is displayed. 30 | pub viewport: Rect, 31 | /// The gizmo's operation modes. 32 | pub modes: EnumSet, 33 | /// If set, this mode is forced active and other modes are disabled 34 | pub mode_override: Option, 35 | /// Determines the gizmo's orientation relative to global or local axes. 36 | pub orientation: GizmoOrientation, 37 | /// Pivot point for transformations 38 | pub pivot_point: TransformPivotPoint, 39 | /// Toggles snapping to predefined increments during transformations for precision. 40 | pub snapping: bool, 41 | /// Angle increment for snapping rotations, in radians. 42 | pub snap_angle: f32, 43 | /// Distance increment for snapping translations. 44 | pub snap_distance: f32, 45 | /// Scale increment for snapping scalings. 46 | pub snap_scale: f32, 47 | /// Visual settings for the gizmo, affecting appearance and visibility. 48 | pub visuals: GizmoVisuals, 49 | /// Ratio of window's physical size to logical size. 50 | pub pixels_per_point: f32, 51 | } 52 | 53 | impl Default for GizmoConfig { 54 | fn default() -> Self { 55 | Self { 56 | view_matrix: DMat4::IDENTITY.into(), 57 | projection_matrix: DMat4::IDENTITY.into(), 58 | viewport: Rect::NOTHING, 59 | modes: GizmoMode::all(), 60 | mode_override: None, 61 | orientation: GizmoOrientation::default(), 62 | pivot_point: TransformPivotPoint::default(), 63 | snapping: false, 64 | snap_angle: DEFAULT_SNAP_ANGLE, 65 | snap_distance: DEFAULT_SNAP_DISTANCE, 66 | snap_scale: DEFAULT_SNAP_SCALE, 67 | visuals: GizmoVisuals::default(), 68 | pixels_per_point: 1.0, 69 | } 70 | } 71 | } 72 | 73 | impl GizmoConfig { 74 | /// Forward vector of the view camera 75 | pub(crate) fn view_forward(&self) -> DVec3 { 76 | DVec4::from(self.view_matrix.z).xyz() 77 | } 78 | 79 | /// Up vector of the view camera 80 | pub(crate) fn view_up(&self) -> DVec3 { 81 | DVec4::from(self.view_matrix.y).xyz() 82 | } 83 | 84 | /// Right vector of the view camera 85 | pub(crate) fn view_right(&self) -> DVec3 { 86 | DVec4::from(self.view_matrix.x).xyz() 87 | } 88 | 89 | /// Whether local orientation is used 90 | pub(crate) fn local_space(&self) -> bool { 91 | self.orientation() == GizmoOrientation::Local 92 | } 93 | 94 | /// Transform orientation of the gizmo 95 | pub(crate) fn orientation(&self) -> GizmoOrientation { 96 | self.orientation 97 | } 98 | 99 | /// Whether the modes have changed, compared to given other config 100 | pub(crate) fn modes_changed(&self, other: &Self) -> bool { 101 | (self.modes != other.modes && self.mode_override.is_none()) 102 | || (self.mode_override != other.mode_override) 103 | } 104 | } 105 | 106 | #[derive(Debug, Copy, Clone, Default)] 107 | pub(crate) struct PreparedGizmoConfig { 108 | config: GizmoConfig, 109 | /// Rotation of the gizmo 110 | pub(crate) rotation: DQuat, 111 | /// Translation of the gizmo 112 | pub(crate) translation: DVec3, 113 | /// Scale of the gizmo 114 | pub(crate) scale: DVec3, 115 | /// Combined view-projection matrix 116 | pub(crate) view_projection: DMat4, 117 | /// Model matrix from targets 118 | pub(crate) model_matrix: DMat4, 119 | /// Combined model-view-projection matrix 120 | pub(crate) mvp: DMat4, 121 | /// Scale factor for the gizmo rendering 122 | pub(crate) scale_factor: f32, 123 | /// How close the mouse pointer needs to be to a subgizmo before it is focused 124 | pub(crate) focus_distance: f32, 125 | /// Whether left-handed projection is used 126 | pub(crate) left_handed: bool, 127 | /// Direction from the camera to the gizmo in world space 128 | pub(crate) eye_to_model_dir: DVec3, 129 | } 130 | 131 | impl Deref for PreparedGizmoConfig { 132 | type Target = GizmoConfig; 133 | 134 | fn deref(&self) -> &Self::Target { 135 | &self.config 136 | } 137 | } 138 | 139 | impl DerefMut for PreparedGizmoConfig { 140 | fn deref_mut(&mut self) -> &mut Self::Target { 141 | &mut self.config 142 | } 143 | } 144 | 145 | impl PreparedGizmoConfig { 146 | pub(crate) fn update_for_config(&mut self, config: GizmoConfig) { 147 | let projection_matrix = DMat4::from(config.projection_matrix); 148 | let view_matrix = DMat4::from(config.view_matrix); 149 | 150 | let view_projection = projection_matrix * view_matrix; 151 | 152 | let left_handed = if projection_matrix.z_axis.w == 0.0 { 153 | projection_matrix.z_axis.z > 0.0 154 | } else { 155 | projection_matrix.z_axis.w > 0.0 156 | }; 157 | 158 | self.config = config; 159 | self.view_projection = view_projection; 160 | self.left_handed = left_handed; 161 | 162 | self.update_transform(Transform { 163 | scale: self.scale.into(), 164 | rotation: self.rotation.into(), 165 | translation: self.translation.into(), 166 | }); 167 | } 168 | 169 | pub(crate) fn update_for_targets(&mut self, targets: &[Transform]) { 170 | let mut scale = DVec3::ZERO; 171 | let mut translation = DVec3::ZERO; 172 | let mut rotation = DQuat::IDENTITY; 173 | 174 | let mut target_count = 0; 175 | for target in targets { 176 | scale += DVec3::from(target.scale); 177 | translation += DVec3::from(target.translation); 178 | rotation = DQuat::from(target.rotation); 179 | 180 | target_count += 1; 181 | } 182 | 183 | if target_count == 0 { 184 | scale = DVec3::ONE; 185 | } else { 186 | translation /= target_count as f64; 187 | scale /= target_count as f64; 188 | } 189 | 190 | self.update_transform(Transform { 191 | scale: scale.into(), 192 | rotation: rotation.into(), 193 | translation: translation.into(), 194 | }); 195 | } 196 | 197 | pub(crate) fn update_transform(&mut self, transform: Transform) { 198 | self.translation = transform.translation.into(); 199 | self.rotation = transform.rotation.into(); 200 | self.scale = transform.scale.into(); 201 | self.model_matrix = 202 | DMat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation); 203 | self.mvp = self.view_projection * self.model_matrix; 204 | 205 | self.scale_factor = self.mvp.as_ref()[15] as f32 206 | / self.projection_matrix.x.x as f32 207 | / self.config.viewport.width() 208 | * 2.0; 209 | 210 | let gizmo_screen_pos = 211 | world_to_screen(self.config.viewport, self.mvp, self.translation).unwrap_or_default(); 212 | 213 | let gizmo_view_near = screen_to_world( 214 | self.config.viewport, 215 | self.view_projection.inverse(), 216 | gizmo_screen_pos, 217 | -1.0, 218 | ); 219 | 220 | self.focus_distance = self.scale_factor * (self.config.visuals.stroke_width / 2.0 + 5.0); 221 | 222 | self.eye_to_model_dir = (gizmo_view_near - self.translation).normalize_or_zero(); 223 | } 224 | 225 | pub(crate) fn as_transform(&self) -> Transform { 226 | Transform { 227 | scale: self.scale.into(), 228 | rotation: self.rotation.into(), 229 | translation: self.translation.into(), 230 | } 231 | } 232 | } 233 | 234 | /// Operation mode of a gizmo. 235 | #[derive(Debug, EnumSetType, Hash)] 236 | pub enum GizmoMode { 237 | /// Rotate around the view forward axis 238 | RotateView, 239 | /// Rotate around the X axis 240 | RotateX, 241 | /// Rotate around the Y axis 242 | RotateY, 243 | /// Rotate around the Z axis 244 | RotateZ, 245 | /// Translate along the view forward axis 246 | TranslateView, 247 | /// Translate along the X axis 248 | TranslateX, 249 | /// Translate along the Y axis 250 | TranslateY, 251 | /// Translate along the Z axis 252 | TranslateZ, 253 | /// Translate along the XY plane 254 | TranslateXY, 255 | /// Translate along the XZ plane 256 | TranslateXZ, 257 | /// Translate along the YZ plane 258 | TranslateYZ, 259 | /// Scale uniformly in all directions 260 | ScaleUniform, 261 | /// Scale along the X axis 262 | ScaleX, 263 | /// Scale along the Y axis 264 | ScaleY, 265 | /// Scale along the Z axis 266 | ScaleZ, 267 | /// Scale along the XY plane 268 | ScaleXY, 269 | /// Scale along the XZ plane 270 | ScaleXZ, 271 | /// Scale along the YZ plane 272 | ScaleYZ, 273 | /// Rotate using an arcball (trackball) 274 | Arcball, 275 | } 276 | 277 | impl GizmoMode { 278 | /// All modes 279 | pub fn all() -> EnumSet { 280 | EnumSet::all() 281 | } 282 | 283 | /// All rotation modes 284 | pub const fn all_rotate() -> EnumSet { 285 | enum_set!(Self::RotateX | Self::RotateY | Self::RotateZ | Self::RotateView) 286 | } 287 | 288 | /// All translation modes 289 | pub const fn all_translate() -> EnumSet { 290 | enum_set!( 291 | Self::TranslateX 292 | | Self::TranslateY 293 | | Self::TranslateZ 294 | | Self::TranslateXY 295 | | Self::TranslateXZ 296 | | Self::TranslateYZ 297 | | Self::TranslateView 298 | ) 299 | } 300 | 301 | /// All scaling modes 302 | pub const fn all_scale() -> EnumSet { 303 | enum_set!( 304 | Self::ScaleX 305 | | Self::ScaleY 306 | | Self::ScaleZ 307 | | Self::ScaleXY 308 | | Self::ScaleXZ 309 | | Self::ScaleYZ 310 | | Self::ScaleUniform 311 | ) 312 | } 313 | 314 | /// Is this mode for rotation 315 | pub fn is_rotate(&self) -> bool { 316 | self.kind() == GizmoModeKind::Rotate 317 | } 318 | 319 | /// Is this mode for translation 320 | pub fn is_translate(&self) -> bool { 321 | self.kind() == GizmoModeKind::Translate 322 | } 323 | 324 | /// Is this mode for scaling 325 | pub fn is_scale(&self) -> bool { 326 | self.kind() == GizmoModeKind::Scale 327 | } 328 | 329 | /// Axes this mode acts on 330 | pub fn axes(&self) -> EnumSet { 331 | match self { 332 | Self::RotateX | Self::TranslateX | Self::ScaleX => { 333 | enum_set!(GizmoDirection::X) 334 | } 335 | Self::RotateY | Self::TranslateY | Self::ScaleY => { 336 | enum_set!(GizmoDirection::Y) 337 | } 338 | Self::RotateZ | Self::TranslateZ | Self::ScaleZ => { 339 | enum_set!(GizmoDirection::Z) 340 | } 341 | Self::RotateView | Self::TranslateView => { 342 | enum_set!(GizmoDirection::View) 343 | } 344 | Self::ScaleUniform | Self::Arcball => { 345 | enum_set!(GizmoDirection::X | GizmoDirection::Y | GizmoDirection::Z) 346 | } 347 | Self::TranslateXY | Self::ScaleXY => { 348 | enum_set!(GizmoDirection::X | GizmoDirection::Y) 349 | } 350 | Self::TranslateXZ | Self::ScaleXZ => { 351 | enum_set!(GizmoDirection::X | GizmoDirection::Z) 352 | } 353 | Self::TranslateYZ | Self::ScaleYZ => { 354 | enum_set!(GizmoDirection::Y | GizmoDirection::Z) 355 | } 356 | } 357 | } 358 | 359 | /// Returns the modes that match to given axes exactly 360 | pub fn all_from_axes(axes: EnumSet) -> EnumSet { 361 | EnumSet::::all() 362 | .iter() 363 | .filter(|mode| mode.axes() == axes) 364 | .collect() 365 | } 366 | 367 | pub fn kind(&self) -> GizmoModeKind { 368 | match self { 369 | Self::RotateX | Self::RotateY | Self::RotateZ | Self::RotateView => { 370 | GizmoModeKind::Rotate 371 | } 372 | Self::TranslateX 373 | | Self::TranslateY 374 | | Self::TranslateZ 375 | | Self::TranslateXY 376 | | Self::TranslateXZ 377 | | Self::TranslateYZ 378 | | Self::TranslateView => GizmoModeKind::Translate, 379 | Self::ScaleX 380 | | Self::ScaleY 381 | | Self::ScaleZ 382 | | Self::ScaleXY 383 | | Self::ScaleXZ 384 | | Self::ScaleYZ 385 | | Self::ScaleUniform => GizmoModeKind::Scale, 386 | Self::Arcball => GizmoModeKind::Arcball, 387 | } 388 | } 389 | 390 | pub fn all_from_kind(kind: GizmoModeKind) -> EnumSet { 391 | EnumSet::::all() 392 | .iter() 393 | .filter(|mode| mode.kind() == kind) 394 | .collect() 395 | } 396 | 397 | pub fn from_kind_and_axes(kind: GizmoModeKind, axes: EnumSet) -> Option { 398 | EnumSet::::all() 399 | .iter() 400 | .find(|mode| mode.kind() == kind && mode.axes() == axes) 401 | } 402 | } 403 | 404 | #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)] 405 | pub enum GizmoModeKind { 406 | Rotate, 407 | Translate, 408 | Scale, 409 | Arcball, 410 | } 411 | 412 | /// The point in space around which all rotations are centered. 413 | #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] 414 | pub enum TransformPivotPoint { 415 | /// Pivot around the median point of targets 416 | #[default] 417 | MedianPoint, 418 | /// Pivot around each target's own origin 419 | IndividualOrigins, 420 | } 421 | 422 | /// Orientation of a gizmo. 423 | #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] 424 | pub enum GizmoOrientation { 425 | /// Transformation axes are aligned to world space. 426 | #[default] 427 | Global, 428 | /// Transformation axes are aligned to the last target's orientation. 429 | Local, 430 | } 431 | 432 | #[derive(Debug, EnumSetType, Hash)] 433 | pub enum GizmoDirection { 434 | /// Gizmo points in the X-direction 435 | X, 436 | /// Gizmo points in the Y-direction 437 | Y, 438 | /// Gizmo points in the Z-direction 439 | Z, 440 | /// Gizmo points in the view direction 441 | View, 442 | } 443 | 444 | /// Controls the visual style of the gizmo 445 | #[derive(Debug, Copy, Clone)] 446 | pub struct GizmoVisuals { 447 | /// Color of the x axis 448 | pub x_color: Color32, 449 | /// Color of the y axis 450 | pub y_color: Color32, 451 | /// Color of the z axis 452 | pub z_color: Color32, 453 | /// Color of the forward axis 454 | pub s_color: Color32, 455 | /// Alpha of the gizmo color when inactive 456 | pub inactive_alpha: f32, 457 | /// Alpha of the gizmo color when highlighted/active 458 | pub highlight_alpha: f32, 459 | /// Color to use for highlighted and active axes. By default, the axis color is used with `highlight_alpha` 460 | pub highlight_color: Option, 461 | /// Width (thickness) of the gizmo strokes 462 | pub stroke_width: f32, 463 | /// Gizmo size in pixels 464 | pub gizmo_size: f32, 465 | } 466 | 467 | impl Default for GizmoVisuals { 468 | fn default() -> Self { 469 | Self { 470 | x_color: Color32::from_rgb(255, 0, 125), 471 | y_color: Color32::from_rgb(0, 255, 125), 472 | z_color: Color32::from_rgb(0, 125, 255), 473 | s_color: Color32::from_rgb(255, 255, 255), 474 | inactive_alpha: 0.7, 475 | highlight_alpha: 1.0, 476 | highlight_color: None, 477 | stroke_width: 4.0, 478 | gizmo_size: 75.0, 479 | } 480 | } 481 | } 482 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/gizmo.rs: -------------------------------------------------------------------------------- 1 | use ecolor::Rgba; 2 | use emath::Pos2; 3 | use enumset::EnumSet; 4 | use std::ops::{Add, AddAssign, Sub}; 5 | 6 | use crate::GizmoOrientation; 7 | use crate::config::{ 8 | GizmoConfig, GizmoDirection, GizmoMode, PreparedGizmoConfig, TransformPivotPoint, 9 | }; 10 | use crate::math::{Transform, screen_to_world}; 11 | use epaint::Mesh; 12 | use glam::{DMat4, DQuat, DVec3}; 13 | 14 | use crate::subgizmo::rotation::RotationParams; 15 | use crate::subgizmo::scale::ScaleParams; 16 | use crate::subgizmo::translation::TranslationParams; 17 | use crate::subgizmo::{ 18 | ArcballSubGizmo, RotationSubGizmo, ScaleSubGizmo, SubGizmo, SubGizmoControl, 19 | TranslationSubGizmo, common::TransformKind, 20 | }; 21 | 22 | /// A 3D transformation gizmo. 23 | #[derive(Clone, Debug, Default)] 24 | pub struct Gizmo { 25 | /// Prepared configuration of the gizmo. 26 | /// Includes the original [`GizmoConfig`] as well as 27 | /// various other values calculated from it, used for 28 | /// interaction and drawing the gizmo. 29 | config: PreparedGizmoConfig, 30 | /// Subgizmos used in the gizmo. 31 | subgizmos: Vec, 32 | active_subgizmo_id: Option, 33 | 34 | target_start_transforms: Vec, 35 | 36 | gizmo_start_transform: Transform, 37 | } 38 | 39 | impl Gizmo { 40 | /// Creates a new gizmo from given configuration 41 | pub fn new(config: GizmoConfig) -> Self { 42 | let mut gizmo = Self::default(); 43 | gizmo.update_config(config); 44 | gizmo 45 | } 46 | 47 | /// Current configuration used by the gizmo. 48 | pub fn config(&self) -> &GizmoConfig { 49 | &self.config 50 | } 51 | 52 | /// Updates the configuration used by the gizmo. 53 | pub fn update_config(&mut self, config: GizmoConfig) { 54 | if config.modes_changed(&self.config) { 55 | self.subgizmos.clear(); 56 | self.active_subgizmo_id = None; 57 | } 58 | 59 | self.config.update_for_config(config); 60 | 61 | if self.subgizmos.is_empty() { 62 | self.add_rotation(); 63 | self.add_translation(); 64 | self.add_scale(); 65 | } 66 | } 67 | 68 | /// Was this gizmo focused after the latest [`Gizmo::update`] call. 69 | pub fn is_focused(&self) -> bool { 70 | self.subgizmos.iter().any(|subgizmo| subgizmo.is_focused()) 71 | } 72 | 73 | /// Updates the gizmo based on given interaction information. 74 | /// 75 | /// # Examples 76 | /// 77 | /// ``` 78 | /// # // Dummy values 79 | /// # use transform_gizmo::GizmoInteraction; 80 | /// # let mut gizmo = transform_gizmo::Gizmo::default(); 81 | /// # let cursor_pos = Default::default(); 82 | /// # let drag_started = true; 83 | /// # let dragging = true; 84 | /// # let hovered = true; 85 | /// # let mut transforms = vec![]; 86 | /// 87 | /// let interaction = GizmoInteraction { 88 | /// cursor_pos, 89 | /// hovered, 90 | /// drag_started, 91 | /// dragging 92 | /// }; 93 | /// 94 | /// if let Some((_result, new_transforms)) = gizmo.update(interaction, &transforms) { 95 | /// for (new_transform, transform) in 96 | /// // Update transforms 97 | /// new_transforms.iter().zip(&mut transforms) 98 | /// { 99 | /// *transform = *new_transform; 100 | /// } 101 | /// } 102 | /// ``` 103 | /// 104 | /// Returns the result of the interaction with the updated transformation. 105 | /// 106 | /// [`Some`] is returned when any of the subgizmos is being dragged, [`None`] otherwise. 107 | pub fn update( 108 | &mut self, 109 | interaction: GizmoInteraction, 110 | targets: &[Transform], 111 | ) -> Option<(GizmoResult, Vec)> { 112 | if !self.config.viewport.is_finite() { 113 | return None; 114 | } 115 | 116 | // Update the gizmo based on the given target transforms, 117 | // unless the gizmo is currently being interacted with. 118 | if self.active_subgizmo_id.is_none() { 119 | self.config.update_for_targets(targets); 120 | } 121 | 122 | for subgizmo in &mut self.subgizmos { 123 | // Update current configuration to each subgizmo. 124 | subgizmo.update_config(self.config); 125 | // All subgizmos are initially considered unfocused. 126 | subgizmo.set_focused(false); 127 | } 128 | 129 | let force_active = self.config.mode_override.is_some(); 130 | 131 | let pointer_ray = self.pointer_ray(Pos2::from(interaction.cursor_pos)); 132 | 133 | // If there is no active subgizmo, find which one of them 134 | // is under the mouse pointer, if any. 135 | if self.active_subgizmo_id.is_none() && interaction.hovered { 136 | if let Some(subgizmo) = self.pick_subgizmo(pointer_ray) { 137 | subgizmo.set_focused(true); 138 | 139 | // If we started dragging from one of the subgizmos, mark it as active. 140 | if interaction.drag_started || force_active { 141 | self.active_subgizmo_id = Some(subgizmo.id()); 142 | self.target_start_transforms = targets.to_vec(); 143 | self.gizmo_start_transform = self.config.as_transform(); 144 | } 145 | } 146 | } 147 | 148 | let mut result = None; 149 | 150 | if let Some(subgizmo) = self.active_subgizmo_mut() { 151 | if interaction.dragging || force_active { 152 | subgizmo.set_active(true); 153 | subgizmo.set_focused(true); 154 | result = subgizmo.update(pointer_ray); 155 | } else { 156 | subgizmo.set_active(false); 157 | subgizmo.set_focused(false); 158 | self.active_subgizmo_id = None; 159 | } 160 | } 161 | 162 | let Some(result) = result else { 163 | // No interaction, no result. 164 | 165 | self.config.update_for_targets(targets); 166 | 167 | for subgizmo in &mut self.subgizmos { 168 | subgizmo.update_config(self.config); 169 | } 170 | 171 | return None; 172 | }; 173 | 174 | self.update_config_with_result(result); 175 | 176 | let updated_targets = 177 | self.update_transforms_with_result(result, targets, &self.target_start_transforms); 178 | 179 | Some((result, updated_targets)) 180 | } 181 | 182 | /// Return all the necessary data to draw the latest gizmo interaction. 183 | /// 184 | /// The gizmo draw data consists of vertices in viewport coordinates. 185 | pub fn draw(&self) -> GizmoDrawData { 186 | if !self.config.viewport.is_finite() { 187 | return GizmoDrawData::default(); 188 | } 189 | 190 | let mut draw_data = GizmoDrawData::default(); 191 | for subgizmo in &self.subgizmos { 192 | if self.active_subgizmo_id.is_none() || subgizmo.is_active() { 193 | draw_data += subgizmo.draw(); 194 | } 195 | } 196 | 197 | draw_data 198 | } 199 | 200 | /// Checks all sub-gizmos for intersections with the cursor. If there is one, return true. 201 | pub fn pick_preview(&self, cursor_pos: (f32, f32)) -> bool { 202 | let pointer_ray = self.pointer_ray(Pos2::from(cursor_pos)); 203 | self.subgizmos.iter().any(|x| x.pick_preview(pointer_ray)) 204 | } 205 | 206 | fn active_subgizmo_mut(&mut self) -> Option<&mut SubGizmo> { 207 | self.active_subgizmo_id.and_then(|id| { 208 | self.subgizmos 209 | .iter_mut() 210 | .find(|subgizmo| subgizmo.id() == id) 211 | }) 212 | } 213 | 214 | fn update_transforms_with_result( 215 | &self, 216 | result: GizmoResult, 217 | transforms: &[Transform], 218 | start_transforms: &[Transform], 219 | ) -> Vec { 220 | transforms 221 | .iter() 222 | .zip(start_transforms) 223 | .map(|(transform, start_transform)| match result { 224 | GizmoResult::Rotation { 225 | axis, 226 | delta, 227 | total: _, 228 | is_view_axis, 229 | } => self.update_rotation(transform, axis, delta, is_view_axis), 230 | GizmoResult::Translation { delta, total: _ } => { 231 | self.update_translation(delta, transform, start_transform) 232 | } 233 | GizmoResult::Scale { total } => { 234 | self.update_scale(transform, start_transform, total) 235 | } 236 | GizmoResult::Arcball { delta, total: _ } => { 237 | self.update_rotation_quat(transform, delta.into()) 238 | } 239 | }) 240 | .collect() 241 | } 242 | 243 | fn update_rotation( 244 | &self, 245 | transform: &Transform, 246 | axis: mint::Vector3, 247 | delta: f64, 248 | is_view_axis: bool, 249 | ) -> Transform { 250 | let axis = match self.config.orientation() { 251 | GizmoOrientation::Local if !is_view_axis => { 252 | (DQuat::from(transform.rotation) * DVec3::from(axis)).normalize() 253 | } 254 | _ => DVec3::from(axis), 255 | }; 256 | 257 | let delta = DQuat::from_axis_angle(axis, delta); 258 | 259 | self.update_rotation_quat(transform, delta) 260 | } 261 | 262 | fn update_rotation_quat(&self, transform: &Transform, delta: DQuat) -> Transform { 263 | let translation = match self.config.pivot_point { 264 | TransformPivotPoint::MedianPoint => (self.config.translation 265 | + delta * (DVec3::from(transform.translation) - self.config.translation)) 266 | .into(), 267 | TransformPivotPoint::IndividualOrigins => transform.translation, 268 | }; 269 | 270 | let new_rotation = (delta * DQuat::from(transform.rotation)).normalize(); 271 | 272 | Transform { 273 | scale: transform.scale, 274 | rotation: new_rotation.into(), 275 | translation, 276 | } 277 | } 278 | 279 | fn update_translation( 280 | &self, 281 | delta: mint::Vector3, 282 | transform: &Transform, 283 | start_transform: &Transform, 284 | ) -> Transform { 285 | let delta = match self.config.orientation() { 286 | GizmoOrientation::Global => DVec3::from(delta), 287 | GizmoOrientation::Local => DQuat::from(start_transform.rotation) * DVec3::from(delta), 288 | }; 289 | 290 | Transform { 291 | scale: start_transform.scale, 292 | rotation: start_transform.rotation, 293 | translation: (delta + DVec3::from(transform.translation)).into(), 294 | } 295 | } 296 | 297 | fn update_scale( 298 | &self, 299 | transform: &Transform, 300 | start_transform: &Transform, 301 | scale: mint::Vector3, 302 | ) -> Transform { 303 | let new_scale = match self.config.orientation() { 304 | GizmoOrientation::Global => { 305 | let scaled_transform_mat = DMat4::from_scale(scale.into()) 306 | * DMat4::from_scale_rotation_translation( 307 | DVec3::from(start_transform.scale), 308 | DQuat::from(start_transform.rotation), 309 | DVec3::from(start_transform.translation), 310 | ); 311 | let (scale, _, _) = scaled_transform_mat.to_scale_rotation_translation(); 312 | scale 313 | } 314 | GizmoOrientation::Local => DVec3::from(start_transform.scale) * DVec3::from(scale), 315 | }; 316 | 317 | Transform { 318 | scale: new_scale.into(), 319 | ..*transform 320 | } 321 | } 322 | 323 | fn update_config_with_result(&mut self, result: GizmoResult) { 324 | let new_config_transform = self.update_transforms_with_result( 325 | result, 326 | &[self.config.as_transform()], 327 | &[self.gizmo_start_transform], 328 | )[0]; 329 | 330 | self.config.update_transform(new_config_transform); 331 | } 332 | 333 | /// Picks the subgizmo that is closest to the given world space ray. 334 | #[allow(clippy::manual_inspect)] 335 | fn pick_subgizmo(&mut self, ray: Ray) -> Option<&mut SubGizmo> { 336 | // If mode is overridden, assume we only have that mode, and choose it. 337 | if self.config.mode_override.is_some() { 338 | return self.subgizmos.first_mut().map(|subgizmo| { 339 | subgizmo.pick(ray); 340 | 341 | subgizmo 342 | }); 343 | } 344 | 345 | self.subgizmos 346 | .iter_mut() 347 | .filter_map(|subgizmo| subgizmo.pick(ray).map(|t| (t, subgizmo))) 348 | .min_by(|(first, _), (second, _)| { 349 | first 350 | .partial_cmp(second) 351 | .unwrap_or(std::cmp::Ordering::Equal) 352 | }) 353 | .map(|(_, subgizmo)| subgizmo) 354 | } 355 | 356 | /// Get all modes that are currently enabled 357 | fn enabled_modes(&self) -> EnumSet { 358 | self.config 359 | .mode_override 360 | .map_or(self.config.modes, EnumSet::only) 361 | } 362 | 363 | /// Adds rotation subgizmos 364 | fn add_rotation(&mut self) { 365 | let modes = self.enabled_modes(); 366 | 367 | if modes.contains(GizmoMode::RotateX) { 368 | self.subgizmos.push( 369 | RotationSubGizmo::new( 370 | self.config, 371 | RotationParams { 372 | direction: GizmoDirection::X, 373 | }, 374 | ) 375 | .into(), 376 | ); 377 | } 378 | 379 | if modes.contains(GizmoMode::RotateY) { 380 | self.subgizmos.push( 381 | RotationSubGizmo::new( 382 | self.config, 383 | RotationParams { 384 | direction: GizmoDirection::Y, 385 | }, 386 | ) 387 | .into(), 388 | ); 389 | } 390 | 391 | if modes.contains(GizmoMode::RotateZ) { 392 | self.subgizmos.push( 393 | RotationSubGizmo::new( 394 | self.config, 395 | RotationParams { 396 | direction: GizmoDirection::Z, 397 | }, 398 | ) 399 | .into(), 400 | ); 401 | } 402 | 403 | if modes.contains(GizmoMode::RotateView) { 404 | self.subgizmos.push( 405 | RotationSubGizmo::new( 406 | self.config, 407 | RotationParams { 408 | direction: GizmoDirection::View, 409 | }, 410 | ) 411 | .into(), 412 | ); 413 | } 414 | 415 | if modes.contains(GizmoMode::Arcball) { 416 | self.subgizmos 417 | .push(ArcballSubGizmo::new(self.config, ()).into()); 418 | } 419 | } 420 | 421 | /// Adds translation subgizmos 422 | fn add_translation(&mut self) { 423 | let modes = self.enabled_modes(); 424 | 425 | if modes.contains(GizmoMode::TranslateX) { 426 | self.subgizmos.push( 427 | TranslationSubGizmo::new( 428 | self.config, 429 | TranslationParams { 430 | mode: GizmoMode::TranslateX, 431 | direction: GizmoDirection::X, 432 | transform_kind: TransformKind::Axis, 433 | }, 434 | ) 435 | .into(), 436 | ); 437 | } 438 | 439 | if modes.contains(GizmoMode::TranslateY) { 440 | self.subgizmos.push( 441 | TranslationSubGizmo::new( 442 | self.config, 443 | TranslationParams { 444 | mode: GizmoMode::TranslateY, 445 | direction: GizmoDirection::Y, 446 | transform_kind: TransformKind::Axis, 447 | }, 448 | ) 449 | .into(), 450 | ); 451 | } 452 | 453 | if modes.contains(GizmoMode::TranslateZ) { 454 | self.subgizmos.push( 455 | TranslationSubGizmo::new( 456 | self.config, 457 | TranslationParams { 458 | mode: GizmoMode::TranslateZ, 459 | direction: GizmoDirection::Z, 460 | transform_kind: TransformKind::Axis, 461 | }, 462 | ) 463 | .into(), 464 | ); 465 | } 466 | 467 | if modes.contains(GizmoMode::TranslateView) { 468 | self.subgizmos.push( 469 | TranslationSubGizmo::new( 470 | self.config, 471 | TranslationParams { 472 | mode: GizmoMode::TranslateView, 473 | direction: GizmoDirection::View, 474 | transform_kind: TransformKind::Plane, 475 | }, 476 | ) 477 | .into(), 478 | ); 479 | } 480 | 481 | if modes.contains(GizmoMode::TranslateXY) { 482 | self.subgizmos.push( 483 | TranslationSubGizmo::new( 484 | self.config, 485 | TranslationParams { 486 | mode: GizmoMode::TranslateXY, 487 | direction: GizmoDirection::X, 488 | transform_kind: TransformKind::Plane, 489 | }, 490 | ) 491 | .into(), 492 | ); 493 | } 494 | 495 | if modes.contains(GizmoMode::TranslateXZ) { 496 | self.subgizmos.push( 497 | TranslationSubGizmo::new( 498 | self.config, 499 | TranslationParams { 500 | mode: GizmoMode::TranslateXZ, 501 | direction: GizmoDirection::Y, 502 | transform_kind: TransformKind::Plane, 503 | }, 504 | ) 505 | .into(), 506 | ); 507 | } 508 | 509 | if modes.contains(GizmoMode::TranslateYZ) { 510 | self.subgizmos.push( 511 | TranslationSubGizmo::new( 512 | self.config, 513 | TranslationParams { 514 | mode: GizmoMode::TranslateYZ, 515 | direction: GizmoDirection::Z, 516 | transform_kind: TransformKind::Plane, 517 | }, 518 | ) 519 | .into(), 520 | ); 521 | } 522 | } 523 | 524 | /// Adds scale subgizmos 525 | fn add_scale(&mut self) { 526 | let modes = self.enabled_modes(); 527 | 528 | if modes.contains(GizmoMode::ScaleX) { 529 | self.subgizmos.push( 530 | ScaleSubGizmo::new( 531 | self.config, 532 | ScaleParams { 533 | mode: GizmoMode::ScaleX, 534 | direction: GizmoDirection::X, 535 | transform_kind: TransformKind::Axis, 536 | }, 537 | ) 538 | .into(), 539 | ); 540 | } 541 | 542 | if modes.contains(GizmoMode::ScaleY) { 543 | self.subgizmos.push( 544 | ScaleSubGizmo::new( 545 | self.config, 546 | ScaleParams { 547 | mode: GizmoMode::ScaleY, 548 | direction: GizmoDirection::Y, 549 | transform_kind: TransformKind::Axis, 550 | }, 551 | ) 552 | .into(), 553 | ); 554 | } 555 | 556 | if modes.contains(GizmoMode::ScaleZ) { 557 | self.subgizmos.push( 558 | ScaleSubGizmo::new( 559 | self.config, 560 | ScaleParams { 561 | mode: GizmoMode::ScaleZ, 562 | direction: GizmoDirection::Z, 563 | transform_kind: TransformKind::Axis, 564 | }, 565 | ) 566 | .into(), 567 | ); 568 | } 569 | 570 | if modes.contains(GizmoMode::ScaleUniform) && !modes.contains(GizmoMode::RotateView) { 571 | self.subgizmos.push( 572 | ScaleSubGizmo::new( 573 | self.config, 574 | ScaleParams { 575 | mode: GizmoMode::ScaleUniform, 576 | direction: GizmoDirection::View, 577 | transform_kind: TransformKind::Plane, 578 | }, 579 | ) 580 | .into(), 581 | ); 582 | } 583 | 584 | if modes.contains(GizmoMode::ScaleXY) && !modes.contains(GizmoMode::TranslateXY) { 585 | self.subgizmos.push( 586 | ScaleSubGizmo::new( 587 | self.config, 588 | ScaleParams { 589 | mode: GizmoMode::ScaleXY, 590 | direction: GizmoDirection::X, 591 | transform_kind: TransformKind::Plane, 592 | }, 593 | ) 594 | .into(), 595 | ); 596 | } 597 | 598 | if modes.contains(GizmoMode::ScaleXZ) && !modes.contains(GizmoMode::TranslateXZ) { 599 | self.subgizmos.push( 600 | ScaleSubGizmo::new( 601 | self.config, 602 | ScaleParams { 603 | mode: GizmoMode::ScaleXZ, 604 | direction: GizmoDirection::Y, 605 | transform_kind: TransformKind::Plane, 606 | }, 607 | ) 608 | .into(), 609 | ); 610 | } 611 | 612 | if modes.contains(GizmoMode::ScaleYZ) && !modes.contains(GizmoMode::TranslateYZ) { 613 | self.subgizmos.push( 614 | ScaleSubGizmo::new( 615 | self.config, 616 | ScaleParams { 617 | mode: GizmoMode::ScaleYZ, 618 | direction: GizmoDirection::Z, 619 | transform_kind: TransformKind::Plane, 620 | }, 621 | ) 622 | .into(), 623 | ); 624 | } 625 | } 626 | 627 | /// Calculate a world space ray from given screen space position 628 | fn pointer_ray(&self, screen_pos: Pos2) -> Ray { 629 | let mat = self.config.view_projection.inverse(); 630 | let origin = screen_to_world(self.config.viewport, mat, screen_pos, -1.0); 631 | let target = screen_to_world(self.config.viewport, mat, screen_pos, 1.0); 632 | 633 | let direction = target.sub(origin).normalize(); 634 | 635 | Ray { 636 | screen_pos, 637 | origin, 638 | direction, 639 | } 640 | } 641 | } 642 | 643 | /// Information needed for interacting with the gizmo. 644 | #[derive(Default, Clone, Copy, Debug)] 645 | pub struct GizmoInteraction { 646 | /// Current cursor position in window coordinates. 647 | pub cursor_pos: (f32, f32), 648 | /// Whether the gizmo is hovered this frame. 649 | /// Some other UI element might be covering the gizmo, 650 | /// and in such case you may not want the gizmo to be 651 | /// interactable. 652 | pub hovered: bool, 653 | /// Whether dragging was started this frame. 654 | /// Usually this is set to true if the primary mouse 655 | /// button was just pressed. 656 | pub drag_started: bool, 657 | /// Whether the user is currently dragging. 658 | /// Usually this is set to true whenever the primary mouse 659 | /// button is being pressed. 660 | pub dragging: bool, 661 | } 662 | 663 | /// Result of a gizmo transformation 664 | #[derive(Debug, Copy, Clone)] 665 | pub enum GizmoResult { 666 | Rotation { 667 | /// The rotation axis, 668 | axis: mint::Vector3, 669 | /// The latest rotation angle delta 670 | delta: f64, 671 | /// Total rotation angle of the gizmo interaction 672 | total: f64, 673 | /// Whether we are rotating along the view axis 674 | is_view_axis: bool, 675 | }, 676 | Translation { 677 | /// The latest translation delta 678 | delta: mint::Vector3, 679 | /// Total translation of the gizmo interaction 680 | total: mint::Vector3, 681 | }, 682 | Scale { 683 | /// Total scale of the gizmo interaction 684 | total: mint::Vector3, 685 | }, 686 | Arcball { 687 | /// The latest rotation delta 688 | delta: mint::Quaternion, 689 | /// Total rotation of the gizmo interaction 690 | total: mint::Quaternion, 691 | }, 692 | } 693 | 694 | /// Data used to draw [`Gizmo`]. 695 | #[derive(Default, Clone, Debug)] 696 | pub struct GizmoDrawData { 697 | /// Vertices in viewport space. 698 | pub vertices: Vec<[f32; 2]>, 699 | /// Linear RGBA colors. 700 | pub colors: Vec<[f32; 4]>, 701 | /// Indices to the vertex data. 702 | pub indices: Vec, 703 | } 704 | 705 | impl From for GizmoDrawData { 706 | fn from(mesh: Mesh) -> Self { 707 | let (vertices, colors): (Vec<_>, Vec<_>) = mesh 708 | .vertices 709 | .iter() 710 | .map(|vertex| { 711 | ( 712 | [vertex.pos.x, vertex.pos.y], 713 | Rgba::from(vertex.color).to_array(), 714 | ) 715 | }) 716 | .unzip(); 717 | 718 | Self { 719 | vertices, 720 | colors, 721 | indices: mesh.indices, 722 | } 723 | } 724 | } 725 | 726 | impl AddAssign for GizmoDrawData { 727 | fn add_assign(&mut self, rhs: Self) { 728 | let index_offset = self.vertices.len() as u32; 729 | self.vertices.extend(rhs.vertices); 730 | self.colors.extend(rhs.colors); 731 | self.indices 732 | .extend(rhs.indices.into_iter().map(|idx| index_offset + idx)); 733 | } 734 | } 735 | 736 | impl Add for GizmoDrawData { 737 | type Output = Self; 738 | 739 | fn add(mut self, rhs: Self) -> Self::Output { 740 | self += rhs; 741 | self 742 | } 743 | } 744 | 745 | #[derive(Debug, Copy, Clone)] 746 | pub(crate) struct Ray { 747 | pub(crate) screen_pos: Pos2, 748 | pub(crate) origin: DVec3, 749 | pub(crate) direction: DVec3, 750 | } 751 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Provides a feature-rich and configurable gizmo that can be used for 3d transformations (translation, rotation, scale). 2 | //! 3 | //! Such gizmos are commonly used in applications such as game engines and 3d modeling software. 4 | //! 5 | //! # Usage 6 | //! 7 | //! If you are using the [Bevy](https://bevyengine.org/) game engine or [Egui](https://github.com/emilk/egui) library in your 8 | //! application, you will most likely want to use [transform-gizmo-bevy](https://docs.rs/transform-gizmo-bevy) 9 | //! or [transform-gizmo-egui](https://docs.rs/transform-gizmo-egui). 10 | //! 11 | //! Alternatively, this library can be easily used with any framework. For interacting with the gizmo, 12 | //! all you will need to do is give [`Gizmo::update`] sufficient 13 | //! information about user interaction, in the form of [`GizmoInteraction`]. 14 | //! 15 | //! For rendering the gizmo, [`Gizmo::draw`] provides vertices in viewport coordinates that can be easily rendered 16 | //! with your favorite graphics APIs. 17 | //! 18 | //! For a more complete example, see the online demo at . 19 | //! The demo sources can be found at . 20 | 21 | mod shape; 22 | mod subgizmo; 23 | 24 | pub mod config; 25 | pub mod gizmo; 26 | pub mod math; 27 | 28 | pub mod prelude; 29 | 30 | pub use prelude::*; 31 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/math.rs: -------------------------------------------------------------------------------- 1 | pub use emath::{Pos2, Rect, Vec2}; 2 | pub use glam::{DMat3, DMat4, DQuat, DVec2, DVec3, DVec4, Mat4, Quat, Vec3, Vec4Swizzles}; 3 | 4 | #[derive(Clone, Copy, Debug, PartialEq, PartialOrd)] 5 | pub struct Transform { 6 | pub scale: mint::Vector3, 7 | pub rotation: mint::Quaternion, 8 | pub translation: mint::Vector3, 9 | } 10 | 11 | impl Default for Transform { 12 | fn default() -> Self { 13 | Self { 14 | scale: DVec3::ONE.into(), 15 | rotation: DQuat::IDENTITY.into(), 16 | translation: DVec3::ZERO.into(), 17 | } 18 | } 19 | } 20 | 21 | impl Transform { 22 | pub fn from_scale_rotation_translation( 23 | scale: impl Into>, 24 | rotation: impl Into>, 25 | translation: impl Into>, 26 | ) -> Self { 27 | Self { 28 | scale: scale.into(), 29 | rotation: rotation.into(), 30 | translation: translation.into(), 31 | } 32 | } 33 | } 34 | 35 | /// Creates a matrix that represents rotation between two 3d vectors 36 | /// 37 | /// Credit: 38 | pub(crate) fn rotation_align(from: DVec3, to: DVec3) -> DMat3 { 39 | let v = from.cross(to); 40 | let c = from.dot(to); 41 | let k = 1.0 / (1.0 + c); 42 | 43 | DMat3::from_cols_array(&[ 44 | v.x * v.x * k + c, 45 | v.x * v.y * k + v.z, 46 | v.x * v.z * k - v.y, 47 | v.y * v.x * k - v.z, 48 | v.y * v.y * k + c, 49 | v.y * v.z * k + v.x, 50 | v.z * v.x * k + v.y, 51 | v.z * v.y * k - v.x, 52 | v.z * v.z * k + c, 53 | ]) 54 | } 55 | 56 | /// Finds points on two rays that are closest to each other. 57 | /// This can be used to determine the shortest distance between those two rays. 58 | /// 59 | /// Credit: Practical Geometry Algorithms by Daniel Sunday: 60 | pub(crate) fn ray_to_ray(a1: DVec3, adir: DVec3, b1: DVec3, bdir: DVec3) -> (f64, f64) { 61 | let b = adir.dot(bdir); 62 | let w = a1 - b1; 63 | let d = adir.dot(w); 64 | let e = bdir.dot(w); 65 | let dot = 1.0 - b * b; 66 | let ta; 67 | let tb; 68 | 69 | if dot < 1e-8 { 70 | ta = 0.0; 71 | tb = e; 72 | } else { 73 | ta = (b * e - d) / dot; 74 | tb = (e - b * d) / dot; 75 | } 76 | 77 | (ta, tb) 78 | } 79 | 80 | /// Finds points on two segments that are closest to each other. 81 | /// This can be used to determine the shortest distance between those two segments. 82 | /// 83 | /// Credit: Practical Geometry Algorithms by Daniel Sunday: 84 | pub(crate) fn segment_to_segment(a1: DVec3, a2: DVec3, b1: DVec3, b2: DVec3) -> (f64, f64) { 85 | let da = a2 - a1; 86 | let db = b2 - b1; 87 | let la = da.length_squared(); 88 | let lb = db.length_squared(); 89 | let dd = da.dot(db); 90 | let d1 = a1 - b1; 91 | let d = da.dot(d1); 92 | let e = db.dot(d1); 93 | let n = la * lb - dd * dd; 94 | 95 | let mut sn; 96 | let mut tn; 97 | let mut sd = n; 98 | let mut td = n; 99 | 100 | if n < 1e-8 { 101 | sn = 0.0; 102 | sd = 1.0; 103 | tn = e; 104 | td = lb; 105 | } else { 106 | sn = dd * e - lb * d; 107 | tn = la * e - dd * d; 108 | if sn < 0.0 { 109 | sn = 0.0; 110 | tn = e; 111 | td = lb; 112 | } else if sn > sd { 113 | sn = sd; 114 | tn = e + dd; 115 | td = lb; 116 | } 117 | } 118 | 119 | if tn < 0.0 { 120 | tn = 0.0; 121 | if -d < 0.0 { 122 | sn = 0.0; 123 | } else if -d > la { 124 | sn = sd; 125 | } else { 126 | sn = -d; 127 | sd = la; 128 | } 129 | } else if tn > td { 130 | tn = td; 131 | if (-d + dd) < 0.0 { 132 | sn = 0.0; 133 | } else if (-d + dd) > la { 134 | sn = sd; 135 | } else { 136 | sn = -d + dd; 137 | sd = la; 138 | } 139 | } 140 | 141 | let ta = if sn.abs() < 1e-8 { 0.0 } else { sn / sd }; 142 | let tb = if tn.abs() < 1e-8 { 0.0 } else { tn / td }; 143 | 144 | (ta, tb) 145 | } 146 | 147 | /// Finds the intersection point of a ray and a plane 148 | pub(crate) fn intersect_plane( 149 | plane_normal: DVec3, 150 | plane_origin: DVec3, 151 | ray_origin: DVec3, 152 | ray_dir: DVec3, 153 | t: &mut f64, 154 | ) -> bool { 155 | let denom = plane_normal.dot(ray_dir); 156 | 157 | if denom.abs() < 10e-8 { 158 | false 159 | } else { 160 | *t = (plane_origin - ray_origin).dot(plane_normal) / denom; 161 | *t >= 0.0 162 | } 163 | } 164 | 165 | /// Finds the intersection point of a ray and a plane 166 | /// and distance from the intersection to the plane origin 167 | pub(crate) fn ray_to_plane_origin( 168 | disc_normal: DVec3, 169 | disc_origin: DVec3, 170 | ray_origin: DVec3, 171 | ray_dir: DVec3, 172 | ) -> (f64, f64) { 173 | let mut t = 0.0; 174 | if intersect_plane(disc_normal, disc_origin, ray_origin, ray_dir, &mut t) { 175 | let p = ray_origin + ray_dir * t; 176 | let v = p - disc_origin; 177 | let d2 = v.dot(v); 178 | (t, f64::sqrt(d2)) 179 | } else { 180 | (t, f64::MAX) 181 | } 182 | } 183 | 184 | /// Rounds given value to the nearest interval 185 | pub(crate) fn round_to_interval(val: f64, interval: f64) -> f64 { 186 | (val / interval).round() * interval 187 | } 188 | 189 | /// Calculates 2d screen coordinates from 3d world coordinates 190 | pub(crate) fn world_to_screen(viewport: Rect, mvp: DMat4, pos: DVec3) -> Option { 191 | let mut pos = mvp * DVec4::from((pos, 1.0)); 192 | 193 | if pos.w < 1e-10 { 194 | return None; 195 | } 196 | 197 | pos /= pos.w; 198 | pos.y *= -1.0; 199 | 200 | let center = viewport.center(); 201 | 202 | Some(Pos2::new( 203 | (center.x as f64 + pos.x * viewport.width() as f64 / 2.0) as f32, 204 | (center.y as f64 + pos.y * viewport.height() as f64 / 2.0) as f32, 205 | )) 206 | } 207 | 208 | /// Calculates 3d world coordinates from 2d screen coordinates 209 | pub(crate) fn screen_to_world(viewport: Rect, mat: DMat4, pos: Pos2, z: f64) -> DVec3 { 210 | let x = (((pos.x - viewport.min.x) / viewport.width()) * 2.0 - 1.0) as f64; 211 | let y = (((pos.y - viewport.min.y) / viewport.height()) * 2.0 - 1.0) as f64; 212 | 213 | let mut world_pos = mat * DVec4::new(x, -y, z, 1.0); 214 | 215 | // w is zero when far plane is set to infinity 216 | if world_pos.w.abs() < 1e-7 { 217 | world_pos.w = 1e-7; 218 | } 219 | 220 | world_pos /= world_pos.w; 221 | 222 | world_pos.xyz() 223 | } 224 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/prelude.rs: -------------------------------------------------------------------------------- 1 | pub use crate::config::{GizmoConfig, GizmoDirection, GizmoMode, GizmoOrientation, GizmoVisuals}; 2 | pub use crate::gizmo::{Gizmo, GizmoDrawData, GizmoInteraction, GizmoResult}; 3 | 4 | pub use enumset::{EnumSet, enum_set}; 5 | 6 | pub use mint; 7 | 8 | pub use ecolor::Color32; 9 | pub use emath::Rect; 10 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/shape.rs: -------------------------------------------------------------------------------- 1 | use std::f64::consts::TAU; 2 | 3 | use crate::math::{Pos2, Rect}; 4 | use ecolor::Color32; 5 | use epaint::{Mesh, PathStroke, TessellationOptions, Tessellator, TextureId}; 6 | pub(crate) use epaint::{Shape, Stroke}; 7 | use glam::{DMat4, DVec3}; 8 | 9 | use crate::math::world_to_screen; 10 | 11 | const STEPS_PER_RAD: f64 = 20.0; 12 | 13 | pub(crate) struct ShapeBuilder { 14 | mvp: DMat4, 15 | viewport: Rect, 16 | pixels_per_point: f32, 17 | } 18 | 19 | impl ShapeBuilder { 20 | pub(crate) fn new(mvp: DMat4, viewport: Rect, pixels_per_point: f32) -> Self { 21 | Self { 22 | mvp, 23 | viewport, 24 | pixels_per_point, 25 | } 26 | } 27 | 28 | fn tessellate_shape(&self, shape: Shape) -> Mesh { 29 | let mut tessellator = Tessellator::new( 30 | self.pixels_per_point, 31 | TessellationOptions { 32 | feathering: true, 33 | ..Default::default() 34 | }, 35 | Default::default(), 36 | Default::default(), 37 | ); 38 | 39 | let mut mesh = Mesh::default(); 40 | tessellator.tessellate_shape(shape, &mut mesh); 41 | 42 | mesh.texture_id = TextureId::default(); 43 | mesh 44 | } 45 | 46 | fn arc_points(&self, radius: f64, start_angle: f64, end_angle: f64) -> Vec { 47 | let angle = f64::clamp(end_angle - start_angle, -TAU, TAU); 48 | 49 | let step_count = steps(angle); 50 | let mut points = Vec::with_capacity(step_count); 51 | 52 | let step_size = angle / (step_count - 1) as f64; 53 | 54 | for step in (0..step_count).map(|i| step_size * i as f64) { 55 | let x = f64::cos(start_angle + step) * radius; 56 | let z = f64::sin(start_angle + step) * radius; 57 | 58 | points.push(DVec3::new(x, 0.0, z)); 59 | } 60 | 61 | points 62 | .into_iter() 63 | .filter_map(|point| self.vec3_to_pos2(point)) 64 | .collect::>() 65 | } 66 | 67 | pub(crate) fn arc( 68 | &self, 69 | radius: f64, 70 | start_angle: f64, 71 | end_angle: f64, 72 | stroke: impl Into, 73 | ) -> Mesh { 74 | let mut points = self.arc_points(radius, start_angle, end_angle); 75 | 76 | let closed = points 77 | .first() 78 | .zip(points.last()) 79 | .filter(|(first, last)| first.distance(**last) < 1e-2) 80 | .is_some(); 81 | 82 | self.tessellate_shape(if closed { 83 | points.pop(); 84 | Shape::closed_line(points, stroke.into()) 85 | } else { 86 | Shape::line(points, stroke.into()) 87 | }) 88 | } 89 | 90 | pub(crate) fn circle(&self, radius: f64, stroke: impl Into) -> Mesh { 91 | self.arc(radius, 0.0, TAU, stroke) 92 | } 93 | 94 | pub(crate) fn filled_circle( 95 | &self, 96 | radius: f64, 97 | color: Color32, 98 | stroke: impl Into, 99 | ) -> Mesh { 100 | let mut points = self.arc_points(radius, 0.0, TAU); 101 | points.pop(); 102 | 103 | self.tessellate_shape(Shape::convex_polygon(points, color, stroke.into())) 104 | } 105 | 106 | pub(crate) fn line_segment(&self, from: DVec3, to: DVec3, stroke: impl Into) -> Mesh { 107 | let mut points: [Pos2; 2] = Default::default(); 108 | 109 | for (i, point) in points.iter_mut().enumerate() { 110 | if let Some(pos) = world_to_screen(self.viewport, self.mvp, [from, to][i]) { 111 | *point = pos; 112 | } else { 113 | return Mesh::default(); 114 | } 115 | } 116 | 117 | self.tessellate_shape(Shape::LineSegment { 118 | points, 119 | stroke: stroke.into(), 120 | }) 121 | } 122 | 123 | pub(crate) fn arrow(&self, from: DVec3, to: DVec3, stroke: impl Into) -> Mesh { 124 | let stroke = stroke.into(); 125 | let arrow_start = world_to_screen(self.viewport, self.mvp, from); 126 | let arrow_end = world_to_screen(self.viewport, self.mvp, to); 127 | 128 | self.tessellate_shape(if let Some((start, end)) = arrow_start.zip(arrow_end) { 129 | let cross = (end - start).normalized().rot90() * stroke.width / 2.0; 130 | 131 | Shape::convex_polygon( 132 | vec![start - cross, start + cross, end], 133 | stroke.color, 134 | Stroke::NONE, 135 | ) 136 | } else { 137 | Shape::Noop 138 | }) 139 | } 140 | 141 | pub(crate) fn polygon( 142 | &self, 143 | points: &[DVec3], 144 | fill: impl Into, 145 | stroke: impl Into, 146 | ) -> Mesh { 147 | let points = points 148 | .iter() 149 | .filter_map(|pos| world_to_screen(self.viewport, self.mvp, *pos)) 150 | .collect::>(); 151 | 152 | self.tessellate_shape(if points.len() > 2 { 153 | Shape::convex_polygon(points, fill, stroke) 154 | } else { 155 | Shape::Noop 156 | }) 157 | } 158 | 159 | pub(crate) fn polyline(&self, points: &[DVec3], stroke: impl Into) -> Mesh { 160 | let points = points 161 | .iter() 162 | .filter_map(|pos| world_to_screen(self.viewport, self.mvp, *pos)) 163 | .collect::>(); 164 | 165 | self.tessellate_shape(if points.len() > 1 { 166 | Shape::line(points, stroke) 167 | } else { 168 | Shape::Noop 169 | }) 170 | } 171 | 172 | pub(crate) fn sector( 173 | &self, 174 | radius: f64, 175 | start_angle: f64, 176 | end_angle: f64, 177 | fill: impl Into, 178 | stroke: impl Into, 179 | ) -> Mesh { 180 | let angle_delta = end_angle - start_angle; 181 | let step_count = steps(angle_delta.abs()); 182 | 183 | if step_count < 2 { 184 | return Mesh::default(); 185 | } 186 | 187 | let mut points = Vec::with_capacity(step_count + 1); 188 | 189 | let step_size = angle_delta / (step_count - 1) as f64; 190 | 191 | if ((start_angle - end_angle).abs() - TAU).abs() < step_size.abs() { 192 | return self.filled_circle(radius, fill.into(), stroke); 193 | } 194 | 195 | points.push(DVec3::new(0.0, 0.0, 0.0)); 196 | 197 | let (sin_step, cos_step) = step_size.sin_cos(); 198 | let (mut sin_angle, mut cos_angle) = start_angle.sin_cos(); 199 | 200 | for _ in 0..step_count { 201 | let x = cos_angle * radius; 202 | let z = sin_angle * radius; 203 | 204 | points.push(DVec3::new(x, 0.0, z)); 205 | 206 | let new_sin = sin_angle * cos_step + cos_angle * sin_step; 207 | let new_cos = cos_angle * cos_step - sin_angle * sin_step; 208 | 209 | sin_angle = new_sin; 210 | cos_angle = new_cos; 211 | } 212 | 213 | let points = points 214 | .into_iter() 215 | .filter_map(|point| self.vec3_to_pos2(point)) 216 | .collect::>(); 217 | 218 | self.tessellate_shape(Shape::convex_polygon(points, fill, stroke)) 219 | } 220 | 221 | fn vec3_to_pos2(&self, vec: DVec3) -> Option { 222 | world_to_screen(self.viewport, self.mvp, vec) 223 | } 224 | } 225 | 226 | fn steps(angle: f64) -> usize { 227 | (STEPS_PER_RAD * angle.abs()).ceil().max(1.0) as usize 228 | } 229 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/subgizmo.rs: -------------------------------------------------------------------------------- 1 | use std::any::Any; 2 | use std::fmt::Debug; 3 | use std::hash::{BuildHasher, Hash, Hasher}; 4 | use std::ops::Deref; 5 | 6 | use enum_dispatch::enum_dispatch; 7 | 8 | use crate::{GizmoDrawData, GizmoResult, config::PreparedGizmoConfig, gizmo::Ray}; 9 | 10 | pub(crate) use arcball::ArcballSubGizmo; 11 | pub(crate) use rotation::RotationSubGizmo; 12 | pub(crate) use scale::ScaleSubGizmo; 13 | pub(crate) use translation::TranslationSubGizmo; 14 | 15 | pub(crate) mod arcball; 16 | pub(crate) mod common; 17 | pub(crate) mod rotation; 18 | pub(crate) mod scale; 19 | pub(crate) mod translation; 20 | 21 | #[derive(Clone, Debug)] 22 | /// Enumeration of different subgizmo types. 23 | #[enum_dispatch(SubGizmoControl)] 24 | pub(crate) enum SubGizmo { 25 | Rotate(RotationSubGizmo), 26 | Translate(TranslationSubGizmo), 27 | Scale(ScaleSubGizmo), 28 | Arcball(ArcballSubGizmo), 29 | } 30 | 31 | #[enum_dispatch] 32 | pub(crate) trait SubGizmoControl { 33 | /// Unique identifier for this subgizmo. 34 | fn id(&self) -> u64; 35 | /// Update the configuration used by the gizmo. 36 | fn update_config(&mut self, config: PreparedGizmoConfig); 37 | /// Sets whether this subgizmo is currently focused. 38 | fn set_focused(&mut self, focused: bool); 39 | /// Sets whether this subgizmo is currently active. 40 | fn set_active(&mut self, active: bool); 41 | /// Returns true if this subgizmo is currently focused. 42 | fn is_focused(&self) -> bool; 43 | /// Returns true if this subgizmo is currently active. 44 | fn is_active(&self) -> bool; 45 | /// Pick the subgizmo based on pointer ray. If it is close enough to 46 | /// the mouse pointer, distance from camera to the subgizmo is returned. 47 | fn pick(&mut self, ray: Ray) -> Option; 48 | /// Update the subgizmo based on pointer ray and interaction. 49 | fn update(&mut self, ray: Ray) -> Option; 50 | /// Draw the subgizmo. 51 | fn draw(&self) -> GizmoDrawData; 52 | fn pick_preview(&self, ray: Ray) -> bool; 53 | } 54 | 55 | pub(crate) trait SubGizmoKind: 'static { 56 | type Params: Debug + Copy + Hash; 57 | type State: Debug + Copy + Clone + Send + Sync + Default + 'static; 58 | type PickPreview: Picked + 'static; 59 | 60 | fn pick(subgizmo: &mut SubGizmoConfig, ray: Ray) -> Option 61 | where 62 | Self: Sized; 63 | fn update(subgizmo: &mut SubGizmoConfig, ray: Ray) -> Option 64 | where 65 | Self: Sized; 66 | fn draw(subgizmo: &SubGizmoConfig) -> GizmoDrawData 67 | where 68 | Self: Sized; 69 | 70 | fn pick_preview(subgizmo: &SubGizmoConfig, ray: Ray) -> Self::PickPreview 71 | where 72 | Self: Sized; 73 | } 74 | 75 | pub trait Picked { 76 | fn picked(&self) -> bool; 77 | } 78 | 79 | #[derive(Clone, Debug)] 80 | pub(crate) struct SubGizmoConfig { 81 | id: u64, 82 | /// Additional parameters depending on the subgizmo kind. 83 | params: T::Params, 84 | 85 | /// Configuration of the full gizmo 86 | pub(crate) config: PreparedGizmoConfig, 87 | /// Whether this subgizmo is focused this frame 88 | pub(crate) focused: bool, 89 | /// Whether this subgizmo is active this frame 90 | pub(crate) active: bool, 91 | /// Implementation-specific state of the subgizmo. 92 | pub(crate) state: T::State, 93 | } 94 | 95 | impl Deref for SubGizmoConfig { 96 | type Target = T::Params; 97 | 98 | fn deref(&self) -> &Self::Target { 99 | &self.params 100 | } 101 | } 102 | 103 | impl SubGizmoConfig 104 | where 105 | T: SubGizmoKind, 106 | { 107 | pub(crate) fn new(config: PreparedGizmoConfig, params: T::Params) -> Self { 108 | let mut hasher = ahash::RandomState::with_seeds(1, 2, 3, 4).build_hasher(); 109 | params.type_id().hash(&mut hasher); 110 | params.hash(&mut hasher); 111 | let id = hasher.finish(); 112 | 113 | Self { 114 | id, 115 | params, 116 | config, 117 | focused: false, 118 | active: false, 119 | state: Default::default(), 120 | } 121 | } 122 | } 123 | 124 | impl SubGizmoControl for SubGizmoConfig 125 | where 126 | T: SubGizmoKind, 127 | { 128 | fn id(&self) -> u64 { 129 | self.id 130 | } 131 | fn update_config(&mut self, config: PreparedGizmoConfig) { 132 | self.config = config; 133 | } 134 | 135 | fn set_focused(&mut self, focused: bool) { 136 | self.focused = focused; 137 | } 138 | 139 | fn set_active(&mut self, active: bool) { 140 | self.active = active; 141 | } 142 | 143 | fn is_focused(&self) -> bool { 144 | self.focused 145 | } 146 | 147 | fn is_active(&self) -> bool { 148 | self.active 149 | } 150 | 151 | fn pick(&mut self, ray: Ray) -> Option { 152 | T::pick(self, ray) 153 | } 154 | 155 | fn update(&mut self, ray: Ray) -> Option { 156 | T::update(self, ray) 157 | } 158 | 159 | fn draw(&self) -> GizmoDrawData { 160 | T::draw(self) 161 | } 162 | 163 | fn pick_preview(&self, ray: Ray) -> bool { 164 | T::pick_preview(self, ray).picked() 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/subgizmo/arcball.rs: -------------------------------------------------------------------------------- 1 | use crate::math::{DQuat, Pos2, screen_to_world}; 2 | use crate::subgizmo::common::{draw_circle, pick_circle}; 3 | use crate::subgizmo::{SubGizmoConfig, SubGizmoKind}; 4 | use crate::{GizmoDrawData, GizmoResult, config::PreparedGizmoConfig, gizmo::Ray}; 5 | use ecolor::Color32; 6 | 7 | use super::common::PickResult; 8 | 9 | pub(crate) type ArcballSubGizmo = SubGizmoConfig; 10 | 11 | #[derive(Default, Debug, Copy, Clone)] 12 | pub(crate) struct ArcballState { 13 | last_pos: Pos2, 14 | total_rotation: DQuat, 15 | } 16 | 17 | #[derive(Default, Debug, Copy, Clone)] 18 | pub(crate) struct Arcball; 19 | 20 | impl SubGizmoKind for Arcball { 21 | type Params = (); 22 | type State = ArcballState; 23 | type PickPreview = PickResult; 24 | 25 | fn pick_preview(subgizmo: &SubGizmoConfig, ray: Ray) -> super::common::PickResult 26 | where 27 | Self: Sized, 28 | { 29 | pick_circle( 30 | &subgizmo.config, 31 | ray, 32 | arcball_radius(&subgizmo.config), 33 | true, 34 | ) 35 | } 36 | 37 | fn pick(subgizmo: &mut ArcballSubGizmo, ray: Ray) -> Option { 38 | let pick_result = Self::pick_preview(subgizmo, ray); 39 | 40 | subgizmo.state.last_pos = ray.screen_pos; 41 | 42 | if !pick_result.picked { 43 | return None; 44 | } 45 | 46 | Some(f64::MAX) 47 | } 48 | 49 | fn update(subgizmo: &mut ArcballSubGizmo, ray: Ray) -> Option { 50 | let dir = ray.screen_pos - subgizmo.state.last_pos; 51 | 52 | let rotation_delta = if dir.length_sq() > f32::EPSILON { 53 | let mat = subgizmo.config.view_projection.inverse(); 54 | let a = screen_to_world(subgizmo.config.viewport, mat, ray.screen_pos, 0.0); 55 | let b = screen_to_world(subgizmo.config.viewport, mat, subgizmo.state.last_pos, 0.0); 56 | 57 | let origin = subgizmo.config.view_forward(); 58 | let a = (a - origin).normalize(); 59 | let b = (b - origin).normalize(); 60 | 61 | DQuat::from_axis_angle(a.cross(b).normalize(), a.dot(b).acos() * 10.0) 62 | } else { 63 | DQuat::IDENTITY 64 | }; 65 | 66 | subgizmo.state.last_pos = ray.screen_pos; 67 | subgizmo.state.total_rotation = rotation_delta.mul_quat(subgizmo.state.total_rotation); 68 | 69 | Some(GizmoResult::Arcball { 70 | delta: rotation_delta.into(), 71 | total: subgizmo.state.total_rotation.into(), 72 | }) 73 | } 74 | 75 | fn draw(subgizmo: &ArcballSubGizmo) -> GizmoDrawData { 76 | draw_circle( 77 | &subgizmo.config, 78 | Color32::WHITE.gamma_multiply(if subgizmo.focused { 0.10 } else { 0.0 }), 79 | arcball_radius(&subgizmo.config), 80 | true, 81 | ) 82 | } 83 | } 84 | 85 | /// Radius to use for outer circle subgizmos 86 | pub(crate) fn arcball_radius(config: &PreparedGizmoConfig) -> f64 { 87 | (config.scale_factor * (config.visuals.gizmo_size + config.visuals.stroke_width - 5.0)) as f64 88 | } 89 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/subgizmo/common.rs: -------------------------------------------------------------------------------- 1 | use crate::GizmoMode; 2 | use crate::math::{ray_to_plane_origin, segment_to_segment}; 3 | use ecolor::Color32; 4 | use enumset::EnumSet; 5 | use std::ops::{Add, RangeInclusive}; 6 | 7 | use crate::shape::ShapeBuilder; 8 | use crate::{GizmoDirection, GizmoDrawData, config::PreparedGizmoConfig, gizmo::Ray}; 9 | use glam::{DMat3, DMat4, DQuat, DVec3}; 10 | 11 | use super::Picked; 12 | 13 | const ARROW_FADE: RangeInclusive = 0.95..=0.99; 14 | const PLANE_FADE: RangeInclusive = 0.70..=0.86; 15 | 16 | #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] 17 | pub(crate) enum TransformKind { 18 | Axis, 19 | Plane, 20 | } 21 | 22 | #[derive(Debug, Copy, Clone)] 23 | pub(crate) struct PickResult { 24 | pub subgizmo_point: DVec3, 25 | pub picked: bool, 26 | pub t: f64, 27 | } 28 | 29 | impl Picked for PickResult { 30 | fn picked(&self) -> bool { 31 | self.picked 32 | } 33 | } 34 | 35 | struct ArrowParams { 36 | start: DVec3, 37 | end: DVec3, 38 | direction: DVec3, 39 | length: f64, 40 | } 41 | 42 | fn arrow_modes_overlapping(mode: GizmoMode, other_modes: EnumSet) -> bool { 43 | (mode == GizmoMode::TranslateX && other_modes.contains(GizmoMode::ScaleX)) 44 | || (mode == GizmoMode::TranslateY && other_modes.contains(GizmoMode::ScaleY)) 45 | || (mode == GizmoMode::TranslateZ && other_modes.contains(GizmoMode::ScaleZ)) 46 | || (mode == GizmoMode::ScaleX && other_modes.contains(GizmoMode::TranslateX)) 47 | || (mode == GizmoMode::ScaleY && other_modes.contains(GizmoMode::TranslateY)) 48 | || (mode == GizmoMode::ScaleZ && other_modes.contains(GizmoMode::TranslateZ)) 49 | } 50 | 51 | fn arrow_params(config: &PreparedGizmoConfig, direction: DVec3, mode: GizmoMode) -> ArrowParams { 52 | let width = (config.scale_factor * config.visuals.stroke_width) as f64; 53 | 54 | let (start, length) = if mode.is_translate() && arrow_modes_overlapping(mode, config.modes) { 55 | // Modes contain both translate and scale. Use a bit different translate arrow, so the modes do not overlap. 56 | let length = (config.scale_factor * config.visuals.gizmo_size) as f64; 57 | let start = direction * (length + (width * 3.0)); 58 | 59 | let length = length * 0.2 + width; 60 | 61 | (start, length) 62 | } else { 63 | let start = direction * (width * 0.5 + inner_circle_radius(config)); 64 | let mut length = (config.scale_factor * config.visuals.gizmo_size) as f64 - start.length(); 65 | 66 | if config.modes.len() > 1 { 67 | length -= width * 2.0; 68 | } 69 | 70 | (start, length) 71 | }; 72 | 73 | ArrowParams { 74 | start, 75 | end: start + direction * length, 76 | direction, 77 | length, 78 | } 79 | } 80 | 81 | fn arrow_visibility(config: &PreparedGizmoConfig, direction: DVec3) -> f64 { 82 | let dot = config.eye_to_model_dir.dot(direction).abs(); 83 | (1.0 - (dot - *ARROW_FADE.start()) / (*ARROW_FADE.end() - *ARROW_FADE.start())).min(1.0) 84 | } 85 | 86 | pub(crate) fn pick_arrow( 87 | config: &PreparedGizmoConfig, 88 | ray: Ray, 89 | direction: GizmoDirection, 90 | mode: GizmoMode, 91 | ) -> PickResult { 92 | let ray_length = 1e+14; 93 | 94 | let direction = gizmo_normal(config, direction); 95 | 96 | let mut arrow_params = arrow_params(config, direction, mode); 97 | arrow_params.start += config.translation; 98 | arrow_params.end += config.translation; 99 | 100 | let (ray_t, subgizmo_t) = segment_to_segment( 101 | ray.origin, 102 | ray.origin + ray.direction * ray_length, 103 | arrow_params.start, 104 | arrow_params.end, 105 | ); 106 | 107 | let ray_point = ray.origin + ray.direction * ray_length * ray_t; 108 | let subgizmo_point = 109 | arrow_params.start + arrow_params.direction * arrow_params.length * subgizmo_t; 110 | let dist = (ray_point - subgizmo_point).length(); 111 | 112 | let visibility = arrow_visibility(config, direction); 113 | 114 | let picked = visibility > 0.0 && dist <= config.focus_distance as f64; 115 | 116 | PickResult { 117 | subgizmo_point, 118 | picked, 119 | t: ray_t, 120 | } 121 | } 122 | 123 | pub(crate) fn pick_plane( 124 | config: &PreparedGizmoConfig, 125 | ray: Ray, 126 | direction: GizmoDirection, 127 | ) -> PickResult { 128 | let origin = plane_global_origin(config, direction); 129 | 130 | let normal = gizmo_normal(config, direction); 131 | 132 | let (t, dist_from_origin) = ray_to_plane_origin(normal, origin, ray.origin, ray.direction); 133 | 134 | let ray_point = ray.origin + ray.direction * t; 135 | 136 | let visibility = plane_visibility(config, direction); 137 | 138 | let picked = visibility > 0.0 && dist_from_origin <= plane_size(config); 139 | 140 | PickResult { 141 | subgizmo_point: ray_point, 142 | picked, 143 | t, 144 | } 145 | } 146 | 147 | pub(crate) fn pick_circle( 148 | config: &PreparedGizmoConfig, 149 | ray: Ray, 150 | radius: f64, 151 | filled: bool, 152 | ) -> PickResult { 153 | let origin = config.translation; 154 | let normal = -config.view_forward(); 155 | 156 | let (t, dist_from_gizmo_origin) = 157 | ray_to_plane_origin(normal, origin, ray.origin, ray.direction); 158 | 159 | let hit_pos = ray.origin + ray.direction * t; 160 | 161 | let picked = if filled { 162 | dist_from_gizmo_origin <= radius + config.focus_distance as f64 163 | } else { 164 | (dist_from_gizmo_origin - radius).abs() <= config.focus_distance as f64 165 | }; 166 | 167 | PickResult { 168 | subgizmo_point: hit_pos, 169 | picked, 170 | t, 171 | } 172 | } 173 | 174 | pub(crate) fn draw_arrow( 175 | config: &PreparedGizmoConfig, 176 | focused: bool, 177 | direction: GizmoDirection, 178 | mode: GizmoMode, 179 | ) -> GizmoDrawData { 180 | let opacity = arrow_visibility(config, gizmo_normal(config, direction)); 181 | 182 | if opacity <= 1e-4 { 183 | return GizmoDrawData::default(); 184 | } 185 | 186 | let color = gizmo_color(config, focused, direction).gamma_multiply(opacity as _); 187 | 188 | let transform = if config.local_space() { 189 | DMat4::from_rotation_translation(config.rotation, config.translation) 190 | } else { 191 | DMat4::from_translation(config.translation) 192 | }; 193 | 194 | let shape_builder = ShapeBuilder::new( 195 | config.view_projection * transform, 196 | config.viewport, 197 | config.pixels_per_point, 198 | ); 199 | 200 | let direction = gizmo_local_normal(config, direction); 201 | 202 | let arrow_params = arrow_params(config, direction, mode); 203 | 204 | let tip_stroke_width = 2.4 * config.visuals.stroke_width; 205 | let tip_length = (tip_stroke_width * config.scale_factor) as f64; 206 | 207 | let tip_start = arrow_params.end - arrow_params.direction * tip_length; 208 | 209 | let mut draw_data = GizmoDrawData::default(); 210 | draw_data = draw_data.add( 211 | shape_builder 212 | .line_segment( 213 | arrow_params.start, 214 | tip_start, 215 | (config.visuals.stroke_width, color), 216 | ) 217 | .into(), 218 | ); 219 | 220 | if mode.is_scale() { 221 | draw_data = draw_data.add( 222 | shape_builder 223 | .line_segment(tip_start, arrow_params.end, (tip_stroke_width, color)) 224 | .into(), 225 | ); 226 | } else if mode.is_translate() { 227 | draw_data = draw_data.add( 228 | shape_builder 229 | .arrow(tip_start, arrow_params.end, (tip_stroke_width, color)) 230 | .into(), 231 | ); 232 | } 233 | 234 | draw_data 235 | } 236 | 237 | pub(crate) fn draw_plane( 238 | config: &PreparedGizmoConfig, 239 | focused: bool, 240 | direction: GizmoDirection, 241 | ) -> GizmoDrawData { 242 | let opacity = plane_visibility(config, direction); 243 | 244 | if opacity <= 1e-4 { 245 | return GizmoDrawData::default(); 246 | } 247 | 248 | let color = gizmo_color(config, focused, direction).gamma_multiply(opacity as _); 249 | 250 | let transform = if config.local_space() { 251 | DMat4::from_rotation_translation(config.rotation, config.translation) 252 | } else { 253 | DMat4::from_translation(config.translation) 254 | }; 255 | 256 | let shape_builder = ShapeBuilder::new( 257 | config.view_projection * transform, 258 | config.viewport, 259 | config.pixels_per_point, 260 | ); 261 | 262 | let scale = plane_size(config) * 0.5; 263 | let a = plane_bitangent(direction) * scale; 264 | let b = plane_tangent(direction) * scale; 265 | let origin = plane_local_origin(config, direction); 266 | 267 | let mut draw_data = GizmoDrawData::default(); 268 | draw_data = draw_data.add( 269 | shape_builder 270 | .polygon( 271 | &[ 272 | origin - b - a, 273 | origin + b - a, 274 | origin + b + a, 275 | origin - b + a, 276 | ], 277 | color, 278 | (0.0, Color32::TRANSPARENT), 279 | ) 280 | .into(), 281 | ); 282 | draw_data 283 | } 284 | 285 | pub(crate) fn draw_circle( 286 | config: &PreparedGizmoConfig, 287 | color: Color32, 288 | radius: f64, 289 | filled: bool, 290 | ) -> GizmoDrawData { 291 | if color.a() == 0 { 292 | return GizmoDrawData::default(); 293 | } 294 | 295 | let rotation = { 296 | let forward = config.view_forward(); 297 | let right = config.view_right(); 298 | let up = config.view_up(); 299 | 300 | DQuat::from_mat3(&DMat3::from_cols(up, -forward, -right)) 301 | }; 302 | 303 | let transform = DMat4::from_rotation_translation(rotation, config.translation); 304 | 305 | let shape_builder = ShapeBuilder::new( 306 | config.view_projection * transform, 307 | config.viewport, 308 | config.pixels_per_point, 309 | ); 310 | 311 | let mut draw_data = GizmoDrawData::default(); 312 | if filled { 313 | draw_data = draw_data.add( 314 | shape_builder 315 | .filled_circle(radius, color, (0.0, Color32::TRANSPARENT)) 316 | .into(), 317 | ); 318 | } else { 319 | draw_data = draw_data.add( 320 | shape_builder 321 | .circle(radius, (config.visuals.stroke_width, color)) 322 | .into(), 323 | ); 324 | } 325 | draw_data 326 | } 327 | 328 | pub(crate) const fn plane_bitangent(direction: GizmoDirection) -> DVec3 { 329 | match direction { 330 | GizmoDirection::X => DVec3::Y, 331 | GizmoDirection::Y => DVec3::Z, 332 | GizmoDirection::Z => DVec3::X, 333 | GizmoDirection::View => DVec3::ZERO, // Unused 334 | } 335 | } 336 | 337 | pub(crate) const fn plane_tangent(direction: GizmoDirection) -> DVec3 { 338 | match direction { 339 | GizmoDirection::X => DVec3::Z, 340 | GizmoDirection::Y => DVec3::X, 341 | GizmoDirection::Z => DVec3::Y, 342 | GizmoDirection::View => DVec3::ZERO, // Unused 343 | } 344 | } 345 | 346 | pub(crate) fn plane_size(config: &PreparedGizmoConfig) -> f64 { 347 | (config.scale_factor * (config.visuals.gizmo_size * 0.1 + config.visuals.stroke_width * 2.0)) 348 | as f64 349 | } 350 | 351 | pub(crate) fn plane_local_origin(config: &PreparedGizmoConfig, direction: GizmoDirection) -> DVec3 { 352 | let offset = config.scale_factor * config.visuals.gizmo_size * 0.5; 353 | 354 | let a = plane_bitangent(direction); 355 | let b = plane_tangent(direction); 356 | (a + b) * offset as f64 357 | } 358 | 359 | pub(crate) fn plane_global_origin( 360 | config: &PreparedGizmoConfig, 361 | direction: GizmoDirection, 362 | ) -> DVec3 { 363 | let mut origin = plane_local_origin(config, direction); 364 | if config.local_space() { 365 | origin = config.rotation * origin; 366 | } 367 | origin + config.translation 368 | } 369 | 370 | pub(crate) fn plane_visibility(config: &PreparedGizmoConfig, direction: GizmoDirection) -> f64 { 371 | let dot = config 372 | .eye_to_model_dir 373 | .dot(gizmo_normal(config, direction)) 374 | .abs(); 375 | (1.0 - ((1.0 - dot) - *PLANE_FADE.start()) / (*PLANE_FADE.end() - *PLANE_FADE.start())).min(1.0) 376 | } 377 | 378 | /// Radius to use for inner circle subgizmos 379 | pub(crate) fn inner_circle_radius(config: &PreparedGizmoConfig) -> f64 { 380 | (config.scale_factor * config.visuals.gizmo_size) as f64 * 0.2 381 | } 382 | 383 | /// Radius to use for outer circle subgizmos 384 | pub(crate) fn outer_circle_radius(config: &PreparedGizmoConfig) -> f64 { 385 | (config.scale_factor * (config.visuals.gizmo_size + config.visuals.stroke_width + 5.0)) as f64 386 | } 387 | 388 | pub(crate) fn gizmo_local_normal(config: &PreparedGizmoConfig, direction: GizmoDirection) -> DVec3 { 389 | match direction { 390 | GizmoDirection::X => DVec3::X, 391 | GizmoDirection::Y => DVec3::Y, 392 | GizmoDirection::Z => DVec3::Z, 393 | GizmoDirection::View => -config.view_forward(), 394 | } 395 | } 396 | 397 | pub(crate) fn gizmo_normal(config: &PreparedGizmoConfig, direction: GizmoDirection) -> DVec3 { 398 | let mut normal = gizmo_local_normal(config, direction); 399 | 400 | if config.local_space() && direction != GizmoDirection::View { 401 | normal = config.rotation * normal; 402 | } 403 | 404 | normal 405 | } 406 | 407 | pub(crate) fn gizmo_color( 408 | config: &PreparedGizmoConfig, 409 | focused: bool, 410 | direction: GizmoDirection, 411 | ) -> Color32 { 412 | let color = match direction { 413 | GizmoDirection::X => config.visuals.x_color, 414 | GizmoDirection::Y => config.visuals.y_color, 415 | GizmoDirection::Z => config.visuals.z_color, 416 | GizmoDirection::View => config.visuals.s_color, 417 | }; 418 | 419 | let color = if focused { 420 | config.visuals.highlight_color.unwrap_or(color) 421 | } else { 422 | color 423 | }; 424 | 425 | let alpha = if focused { 426 | config.visuals.highlight_alpha 427 | } else { 428 | config.visuals.inactive_alpha 429 | }; 430 | 431 | color.linear_multiply(alpha) 432 | } 433 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/subgizmo/rotation.rs: -------------------------------------------------------------------------------- 1 | use std::f64::consts::{FRAC_PI_2, PI, TAU}; 2 | 3 | use ecolor::Color32; 4 | 5 | use crate::math::{ 6 | DMat3, DMat4, DQuat, DVec2, DVec3, Pos2, ray_to_plane_origin, rotation_align, 7 | round_to_interval, world_to_screen, 8 | }; 9 | use crate::shape::ShapeBuilder; 10 | use crate::subgizmo::common::{gizmo_color, gizmo_local_normal, gizmo_normal, outer_circle_radius}; 11 | use crate::subgizmo::{SubGizmoConfig, SubGizmoKind}; 12 | use crate::{GizmoDirection, GizmoDrawData, GizmoResult, gizmo::Ray}; 13 | 14 | use super::Picked; 15 | 16 | pub(crate) type RotationSubGizmo = SubGizmoConfig; 17 | 18 | #[derive(Debug, Copy, Clone, Hash)] 19 | pub(crate) struct RotationParams { 20 | pub direction: GizmoDirection, 21 | } 22 | 23 | #[derive(Default, Debug, Copy, Clone)] 24 | pub(crate) struct RotationState { 25 | start_axis_angle: f64, 26 | start_rotation_angle: f64, 27 | last_rotation_angle: f64, 28 | current_delta: f64, 29 | } 30 | 31 | #[derive(Default, Debug, Copy, Clone)] 32 | pub(crate) struct Rotation; 33 | 34 | pub struct RotationPickResult { 35 | angle: f64, 36 | rotation_angle: f64, 37 | picked: bool, 38 | t: f64, 39 | } 40 | impl Picked for RotationPickResult { 41 | fn picked(&self) -> bool { 42 | self.picked 43 | } 44 | } 45 | 46 | impl SubGizmoKind for Rotation { 47 | type Params = RotationParams; 48 | type State = RotationState; 49 | type PickPreview = RotationPickResult; 50 | 51 | fn pick_preview(subgizmo: &SubGizmoConfig, ray: Ray) -> Self::PickPreview 52 | where 53 | Self: Sized, 54 | { 55 | let radius = arc_radius(subgizmo); 56 | let config = subgizmo.config; 57 | let origin = config.translation; 58 | let normal = gizmo_normal(&subgizmo.config, subgizmo.direction); 59 | let tangent = tangent(subgizmo); 60 | 61 | let (t, dist_from_gizmo_origin) = 62 | ray_to_plane_origin(normal, origin, ray.origin, ray.direction); 63 | let dist_from_gizmo_edge = (dist_from_gizmo_origin - radius).abs(); 64 | 65 | let hit_pos = ray.origin + ray.direction * t; 66 | let dir_to_origin = (origin - hit_pos).normalize(); 67 | let nearest_circle_pos = hit_pos + dir_to_origin * (dist_from_gizmo_origin - radius); 68 | 69 | let offset = (nearest_circle_pos - origin).normalize(); 70 | 71 | let angle = if subgizmo.direction == GizmoDirection::View { 72 | f64::atan2(tangent.cross(normal).dot(offset), tangent.dot(offset)) 73 | } else { 74 | let mut forward = config.view_forward(); 75 | if config.left_handed { 76 | forward *= -1.0; 77 | } 78 | f64::atan2(offset.cross(forward).dot(normal), offset.dot(forward)) 79 | }; 80 | 81 | let picked = dist_from_gizmo_edge <= config.focus_distance as f64 82 | && angle.abs() < arc_angle(subgizmo); 83 | 84 | let rotation_angle = rotation_angle(subgizmo, ray.screen_pos).unwrap_or(0.0); 85 | 86 | Self::PickPreview { 87 | angle, 88 | rotation_angle, 89 | picked, 90 | t, 91 | } 92 | } 93 | 94 | fn pick(subgizmo: &mut RotationSubGizmo, ray: Ray) -> Option { 95 | let pick_result = Self::pick_preview(subgizmo, ray); 96 | 97 | subgizmo.state.start_axis_angle = pick_result.angle; 98 | subgizmo.state.start_rotation_angle = pick_result.rotation_angle; 99 | subgizmo.state.last_rotation_angle = pick_result.rotation_angle; 100 | subgizmo.state.current_delta = 0.0; 101 | 102 | if pick_result.picked { 103 | Some(pick_result.t) 104 | } else { 105 | None 106 | } 107 | } 108 | 109 | fn update(subgizmo: &mut RotationSubGizmo, ray: Ray) -> Option { 110 | let config = subgizmo.config; 111 | 112 | let mut rotation_angle = rotation_angle(subgizmo, ray.screen_pos)?; 113 | if config.snapping { 114 | rotation_angle = round_to_interval( 115 | rotation_angle - subgizmo.state.start_rotation_angle, 116 | config.snap_angle as f64, 117 | ) + subgizmo.state.start_rotation_angle; 118 | } 119 | 120 | let mut angle_delta = rotation_angle - subgizmo.state.last_rotation_angle; 121 | 122 | // Always take the smallest angle, e.g. -10° instead of 350° 123 | if angle_delta > PI { 124 | angle_delta -= TAU; 125 | } else if angle_delta < -PI { 126 | angle_delta += TAU; 127 | } 128 | 129 | subgizmo.state.last_rotation_angle = rotation_angle; 130 | subgizmo.state.current_delta += angle_delta; 131 | 132 | let normal = gizmo_local_normal(&subgizmo.config, subgizmo.direction); 133 | 134 | Some(GizmoResult::Rotation { 135 | axis: normal.into(), 136 | delta: -angle_delta, 137 | total: subgizmo.state.current_delta, 138 | is_view_axis: subgizmo.direction == GizmoDirection::View, 139 | }) 140 | } 141 | 142 | fn draw(subgizmo: &RotationSubGizmo) -> GizmoDrawData { 143 | let config = subgizmo.config; 144 | 145 | let transform = rotation_matrix(subgizmo); 146 | let shape_builder = ShapeBuilder::new( 147 | config.view_projection * transform, 148 | config.viewport, 149 | config.pixels_per_point, 150 | ); 151 | 152 | let color = gizmo_color(&subgizmo.config, subgizmo.focused, subgizmo.direction); 153 | let stroke = (config.visuals.stroke_width, color); 154 | 155 | let radius = arc_radius(subgizmo); 156 | 157 | let mut draw_data = GizmoDrawData::default(); 158 | 159 | if !subgizmo.active { 160 | let angle = arc_angle(subgizmo); 161 | draw_data += shape_builder 162 | .arc(radius, FRAC_PI_2 - angle, FRAC_PI_2 + angle, stroke) 163 | .into(); 164 | } else { 165 | let mut start_angle = subgizmo.state.start_axis_angle + FRAC_PI_2; 166 | let mut end_angle = start_angle + subgizmo.state.current_delta; 167 | 168 | if start_angle > end_angle { 169 | // First make it so that end angle is always greater than start angle 170 | std::mem::swap(&mut start_angle, &mut end_angle); 171 | } 172 | 173 | // The polyline does not get rendered correctly if 174 | // the start and end lines are exactly the same 175 | end_angle += 1e-5; 176 | 177 | let total_angle = end_angle - start_angle; 178 | 179 | let full_circles = (total_angle / std::f64::consts::TAU).abs() as u32; 180 | 181 | end_angle -= TAU * full_circles as f64; 182 | 183 | let mut start_angle_2 = end_angle; 184 | let mut end_angle_2 = start_angle + TAU; 185 | 186 | if config 187 | .view_forward() 188 | .dot(gizmo_normal(&config, subgizmo.direction)) 189 | < 0.0 190 | { 191 | // Swap start and end angles based on the view direction relative to gizmo normal. 192 | // Otherwise the filled sector gets drawn incorrectly. 193 | std::mem::swap(&mut start_angle, &mut end_angle); 194 | std::mem::swap(&mut start_angle_2, &mut end_angle_2); 195 | } 196 | 197 | draw_data += shape_builder 198 | .polyline( 199 | &[ 200 | DVec3::new(start_angle.cos() * radius, 0.0, start_angle.sin() * radius), 201 | DVec3::new(0.0, 0.0, 0.0), 202 | DVec3::new(end_angle.cos() * radius, 0.0, end_angle.sin() * radius), 203 | ], 204 | stroke, 205 | ) 206 | .into(); 207 | 208 | if full_circles > 0 { 209 | draw_data += shape_builder 210 | .sector( 211 | radius, 212 | start_angle_2, 213 | end_angle_2, 214 | color.linear_multiply((0.25 * full_circles as f32).min(1.0)), 215 | (0.0, Color32::TRANSPARENT), 216 | ) 217 | .into(); 218 | } 219 | 220 | draw_data += shape_builder 221 | .sector( 222 | radius, 223 | start_angle, 224 | end_angle, 225 | color.linear_multiply((0.25 * (full_circles + 1) as f32).min(1.0)), 226 | (0.0, Color32::TRANSPARENT), 227 | ) 228 | .into(); 229 | 230 | draw_data += shape_builder.circle(radius, stroke).into(); 231 | 232 | // Draw snapping ticks 233 | if config.snapping { 234 | let stroke_width = stroke.0 / 2.0; 235 | for i in 0..((TAU / config.snap_angle as f64) as usize + 1) { 236 | let angle = i as f64 * config.snap_angle as f64 + end_angle; 237 | let pos = DVec3::new(angle.cos(), 0.0, angle.sin()); 238 | draw_data += shape_builder 239 | .line_segment( 240 | pos * radius * 1.1, 241 | pos * radius * 1.2, 242 | (stroke_width, stroke.1), 243 | ) 244 | .into(); 245 | } 246 | } 247 | } 248 | 249 | draw_data 250 | } 251 | } 252 | 253 | /// Calculates angle of the rotation axis arc. 254 | /// The arc is a semicircle, which turns into a full circle when viewed 255 | /// directly from the front. 256 | fn arc_angle(subgizmo: &SubGizmoConfig) -> f64 { 257 | let dot = gizmo_normal(&subgizmo.config, subgizmo.direction) 258 | .dot(subgizmo.config.view_forward()) 259 | .abs(); 260 | let min_dot = 0.990; 261 | let max_dot = 0.995; 262 | 263 | let mut angle = 264 | f64::min(1.0, f64::max(0.0, dot - min_dot) / (max_dot - min_dot)) * FRAC_PI_2 + FRAC_PI_2; 265 | if (angle - PI).abs() < 1e-2 { 266 | angle = PI; 267 | } 268 | angle 269 | } 270 | 271 | /// Calculates a matrix used when rendering the rotation axis. 272 | fn rotation_matrix(subgizmo: &SubGizmoConfig) -> DMat4 { 273 | if subgizmo.direction == GizmoDirection::View { 274 | let forward = subgizmo.config.view_forward(); 275 | let right = subgizmo.config.view_right(); 276 | let up = subgizmo.config.view_up(); 277 | 278 | let rotation = DQuat::from_mat3(&DMat3::from_cols(up, -forward, -right)); 279 | 280 | return DMat4::from_rotation_translation(rotation, subgizmo.config.translation); 281 | } 282 | 283 | // First rotate towards the gizmo normal 284 | let local_normal = gizmo_local_normal(&subgizmo.config, subgizmo.direction); 285 | let rotation = rotation_align(DVec3::Y, local_normal); 286 | let mut rotation = DQuat::from_mat3(&rotation); 287 | let config = subgizmo.config; 288 | 289 | if config.local_space() { 290 | rotation = config.rotation * rotation; 291 | } 292 | 293 | let tangent = tangent(subgizmo); 294 | let normal = gizmo_normal(&subgizmo.config, subgizmo.direction); 295 | let mut forward = config.view_forward(); 296 | if config.left_handed { 297 | forward *= -1.0; 298 | } 299 | let angle = f64::atan2(tangent.cross(forward).dot(normal), tangent.dot(forward)); 300 | 301 | // Rotate towards the camera, along the rotation axis. 302 | rotation = DQuat::from_axis_angle(normal, angle) * rotation; 303 | 304 | DMat4::from_rotation_translation(rotation, config.translation) 305 | } 306 | 307 | fn rotation_angle(subgizmo: &SubGizmoConfig, cursor_pos: Pos2) -> Option { 308 | let viewport = subgizmo.config.viewport; 309 | let gizmo_pos = world_to_screen(viewport, subgizmo.config.mvp, DVec3::new(0.0, 0.0, 0.0))?; 310 | let delta = DVec2::new( 311 | cursor_pos.x as f64 - gizmo_pos.x as f64, 312 | cursor_pos.y as f64 - gizmo_pos.y as f64, 313 | ) 314 | .normalize(); 315 | 316 | if delta.is_nan() { 317 | return None; 318 | } 319 | 320 | let mut angle = f64::atan2(delta.y, delta.x); 321 | if subgizmo 322 | .config 323 | .view_forward() 324 | .dot(gizmo_normal(&subgizmo.config, subgizmo.direction)) 325 | < 0.0 326 | { 327 | angle *= -1.0; 328 | } 329 | 330 | Some(angle) 331 | } 332 | 333 | fn tangent(subgizmo: &SubGizmoConfig) -> DVec3 { 334 | let mut tangent = match subgizmo.direction { 335 | GizmoDirection::X | GizmoDirection::Y => DVec3::Z, 336 | GizmoDirection::Z => -DVec3::Y, 337 | GizmoDirection::View => -subgizmo.config.view_right(), 338 | }; 339 | 340 | if subgizmo.config.local_space() && subgizmo.direction != GizmoDirection::View { 341 | tangent = subgizmo.config.rotation * tangent; 342 | } 343 | 344 | tangent 345 | } 346 | 347 | fn arc_radius(subgizmo: &SubGizmoConfig) -> f64 { 348 | if subgizmo.direction == GizmoDirection::View { 349 | outer_circle_radius(&subgizmo.config) 350 | } else { 351 | (subgizmo.config.scale_factor * subgizmo.config.visuals.gizmo_size) as f64 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/subgizmo/scale.rs: -------------------------------------------------------------------------------- 1 | use glam::DVec3; 2 | 3 | use crate::math::{Pos2, round_to_interval, world_to_screen}; 4 | 5 | use crate::subgizmo::common::{ 6 | draw_arrow, draw_circle, draw_plane, gizmo_color, gizmo_local_normal, outer_circle_radius, 7 | pick_arrow, pick_circle, pick_plane, plane_bitangent, plane_tangent, 8 | }; 9 | use crate::subgizmo::{SubGizmoConfig, SubGizmoKind, common::TransformKind}; 10 | use crate::{GizmoDirection, GizmoDrawData, GizmoMode, GizmoResult, gizmo::Ray}; 11 | 12 | use super::common::PickResult; 13 | 14 | pub(crate) type ScaleSubGizmo = SubGizmoConfig; 15 | 16 | #[derive(Debug, Copy, Clone, Hash)] 17 | pub(crate) struct ScaleParams { 18 | pub mode: GizmoMode, 19 | pub direction: GizmoDirection, 20 | pub transform_kind: TransformKind, 21 | } 22 | 23 | #[derive(Default, Debug, Copy, Clone)] 24 | pub(crate) struct ScaleState { 25 | start_delta: f64, 26 | } 27 | 28 | #[derive(Default, Debug, Copy, Clone)] 29 | pub(crate) struct Scale; 30 | 31 | impl SubGizmoKind for Scale { 32 | type Params = ScaleParams; 33 | type State = ScaleState; 34 | type PickPreview = PickResult; 35 | 36 | fn pick_preview(subgizmo: &SubGizmoConfig, ray: Ray) -> super::common::PickResult 37 | where 38 | Self: Sized, 39 | { 40 | match (subgizmo.transform_kind, subgizmo.direction) { 41 | (TransformKind::Plane, GizmoDirection::View) => pick_circle( 42 | &subgizmo.config, 43 | ray, 44 | outer_circle_radius(&subgizmo.config), 45 | false, 46 | ), 47 | (TransformKind::Plane, _) => pick_plane(&subgizmo.config, ray, subgizmo.direction), 48 | (TransformKind::Axis, _) => { 49 | pick_arrow(&subgizmo.config, ray, subgizmo.direction, subgizmo.mode) 50 | } 51 | } 52 | } 53 | 54 | fn pick(subgizmo: &mut ScaleSubGizmo, ray: Ray) -> Option { 55 | let pick_result = Self::pick_preview(subgizmo, ray); 56 | 57 | let start_delta = distance_from_origin_2d(subgizmo, ray.screen_pos)?; 58 | 59 | subgizmo.state.start_delta = start_delta; 60 | 61 | if pick_result.picked { 62 | Some(pick_result.t) 63 | } else { 64 | None 65 | } 66 | } 67 | 68 | fn update(subgizmo: &mut ScaleSubGizmo, ray: Ray) -> Option { 69 | let mut delta = distance_from_origin_2d(subgizmo, ray.screen_pos)?; 70 | delta /= subgizmo.state.start_delta; 71 | 72 | if subgizmo.config.snapping { 73 | delta = round_to_interval(delta, subgizmo.config.snap_scale as f64); 74 | } 75 | delta = delta.max(1e-4) - 1.0; 76 | 77 | let direction = match (subgizmo.transform_kind, subgizmo.direction) { 78 | (TransformKind::Axis, _) => gizmo_local_normal(&subgizmo.config, subgizmo.direction), 79 | (TransformKind::Plane, GizmoDirection::View) => DVec3::ONE, 80 | (TransformKind::Plane, _) => (plane_bitangent(subgizmo.direction) 81 | + plane_tangent(subgizmo.direction)) 82 | .normalize(), 83 | }; 84 | 85 | let scale = DVec3::ONE + (direction * delta); 86 | 87 | Some(GizmoResult::Scale { 88 | total: scale.into(), 89 | }) 90 | } 91 | 92 | fn draw(subgizmo: &ScaleSubGizmo) -> GizmoDrawData { 93 | match (subgizmo.transform_kind, subgizmo.direction) { 94 | (TransformKind::Axis, _) => draw_arrow( 95 | &subgizmo.config, 96 | subgizmo.focused, 97 | subgizmo.direction, 98 | subgizmo.mode, 99 | ), 100 | (TransformKind::Plane, GizmoDirection::View) => draw_circle( 101 | &subgizmo.config, 102 | gizmo_color(&subgizmo.config, subgizmo.focused, subgizmo.direction), 103 | outer_circle_radius(&subgizmo.config), 104 | false, 105 | ), 106 | (TransformKind::Plane, _) => { 107 | draw_plane(&subgizmo.config, subgizmo.focused, subgizmo.direction) 108 | } 109 | } 110 | } 111 | } 112 | 113 | fn distance_from_origin_2d( 114 | subgizmo: &SubGizmoConfig, 115 | cursor_pos: Pos2, 116 | ) -> Option { 117 | let viewport = subgizmo.config.viewport; 118 | let gizmo_pos = world_to_screen(viewport, subgizmo.config.mvp, DVec3::new(0.0, 0.0, 0.0))?; 119 | 120 | Some(cursor_pos.distance(gizmo_pos) as f64) 121 | } 122 | -------------------------------------------------------------------------------- /crates/transform-gizmo/src/subgizmo/translation.rs: -------------------------------------------------------------------------------- 1 | use crate::math::{DVec3, intersect_plane, ray_to_ray, round_to_interval}; 2 | 3 | use crate::subgizmo::common::{ 4 | draw_arrow, draw_circle, draw_plane, gizmo_color, gizmo_normal, inner_circle_radius, 5 | pick_arrow, pick_circle, pick_plane, plane_bitangent, plane_global_origin, plane_tangent, 6 | }; 7 | use crate::subgizmo::{SubGizmoConfig, SubGizmoKind, common::TransformKind}; 8 | use crate::{GizmoDirection, GizmoDrawData, GizmoMode, GizmoOrientation, GizmoResult, gizmo::Ray}; 9 | 10 | use super::common::PickResult; 11 | 12 | pub(crate) type TranslationSubGizmo = SubGizmoConfig; 13 | 14 | #[derive(Debug, Copy, Clone, Hash)] 15 | pub(crate) struct TranslationParams { 16 | pub mode: GizmoMode, 17 | pub direction: GizmoDirection, 18 | pub transform_kind: TransformKind, 19 | } 20 | 21 | #[derive(Default, Debug, Copy, Clone)] 22 | pub(crate) struct TranslationState { 23 | start_view_dir: DVec3, 24 | start_point: DVec3, 25 | last_point: DVec3, 26 | current_delta: DVec3, 27 | } 28 | 29 | #[derive(Default, Debug, Copy, Clone)] 30 | pub(crate) struct Translation; 31 | 32 | impl SubGizmoKind for Translation { 33 | type Params = TranslationParams; 34 | type State = TranslationState; 35 | type PickPreview = PickResult; 36 | 37 | fn pick_preview(subgizmo: &TranslationSubGizmo, ray: Ray) -> PickResult { 38 | match (subgizmo.transform_kind, subgizmo.direction) { 39 | (TransformKind::Plane, GizmoDirection::View) => pick_circle( 40 | &subgizmo.config, 41 | ray, 42 | inner_circle_radius(&subgizmo.config), 43 | true, 44 | ), 45 | (TransformKind::Plane, _) => pick_plane(&subgizmo.config, ray, subgizmo.direction), 46 | (TransformKind::Axis, _) => { 47 | pick_arrow(&subgizmo.config, ray, subgizmo.direction, subgizmo.mode) 48 | } 49 | } 50 | } 51 | 52 | fn pick(subgizmo: &mut TranslationSubGizmo, ray: Ray) -> Option { 53 | let pick_result = Self::pick_preview(subgizmo, ray); 54 | 55 | subgizmo.state.start_view_dir = subgizmo.config.view_forward(); 56 | subgizmo.state.start_point = pick_result.subgizmo_point; 57 | subgizmo.state.last_point = pick_result.subgizmo_point; 58 | subgizmo.state.current_delta = DVec3::ZERO; 59 | 60 | if pick_result.picked { 61 | Some(pick_result.t) 62 | } else { 63 | None 64 | } 65 | } 66 | 67 | fn update(subgizmo: &mut TranslationSubGizmo, ray: Ray) -> Option { 68 | if subgizmo.config.view_forward() != subgizmo.state.start_view_dir { 69 | // If the view_forward direction has changed, i.e. camera has rotated, 70 | // refresh the subgizmo state by calling pick. Feels a bit hacky, but 71 | // fixes the issue where the target starts flying away if camera is rotated 72 | // while view plane translation is active. 73 | Self::pick(subgizmo, ray); 74 | } 75 | 76 | let mut new_point = if subgizmo.transform_kind == TransformKind::Axis { 77 | point_on_axis(subgizmo, ray) 78 | } else { 79 | point_on_plane( 80 | gizmo_normal(&subgizmo.config, subgizmo.direction), 81 | plane_global_origin(&subgizmo.config, subgizmo.direction), 82 | ray, 83 | )? 84 | }; 85 | 86 | let mut new_delta = new_point - subgizmo.state.start_point; 87 | 88 | if subgizmo.config.snapping { 89 | new_delta = if subgizmo.transform_kind == TransformKind::Axis { 90 | snap_translation_vector(subgizmo, new_delta) 91 | } else { 92 | snap_translation_plane(subgizmo, new_delta) 93 | }; 94 | new_point = subgizmo.state.start_point + new_delta; 95 | } 96 | 97 | let mut translation_delta = new_point - subgizmo.state.last_point; 98 | let mut total_translation = new_point - subgizmo.state.start_point; 99 | 100 | if subgizmo.config.orientation() == GizmoOrientation::Local { 101 | let inverse_rotation = subgizmo.config.rotation.inverse(); 102 | translation_delta = inverse_rotation * translation_delta; 103 | total_translation = inverse_rotation * total_translation; 104 | } 105 | 106 | subgizmo.state.last_point = new_point; 107 | subgizmo.state.current_delta = new_delta; 108 | 109 | Some(GizmoResult::Translation { 110 | delta: translation_delta.into(), 111 | total: total_translation.into(), 112 | }) 113 | } 114 | 115 | fn draw(subgizmo: &TranslationSubGizmo) -> GizmoDrawData { 116 | match (subgizmo.transform_kind, subgizmo.direction) { 117 | (TransformKind::Axis, _) => draw_arrow( 118 | &subgizmo.config, 119 | subgizmo.focused, 120 | subgizmo.direction, 121 | subgizmo.mode, 122 | ), 123 | (TransformKind::Plane, GizmoDirection::View) => draw_circle( 124 | &subgizmo.config, 125 | gizmo_color(&subgizmo.config, subgizmo.focused, subgizmo.direction), 126 | inner_circle_radius(&subgizmo.config), 127 | false, 128 | ), 129 | (TransformKind::Plane, _) => { 130 | draw_plane(&subgizmo.config, subgizmo.focused, subgizmo.direction) 131 | } 132 | } 133 | } 134 | } 135 | 136 | /// Finds the nearest point on line that points in translation subgizmo direction 137 | fn point_on_axis(subgizmo: &SubGizmoConfig, ray: Ray) -> DVec3 { 138 | let origin = subgizmo.config.translation; 139 | let direction = gizmo_normal(&subgizmo.config, subgizmo.direction); 140 | 141 | let (_ray_t, subgizmo_t) = ray_to_ray(ray.origin, ray.direction, origin, direction); 142 | 143 | origin + direction * subgizmo_t 144 | } 145 | 146 | fn point_on_plane(plane_normal: DVec3, plane_origin: DVec3, ray: Ray) -> Option { 147 | let mut t = 0.0; 148 | if !intersect_plane( 149 | plane_normal, 150 | plane_origin, 151 | ray.origin, 152 | ray.direction, 153 | &mut t, 154 | ) { 155 | None 156 | } else { 157 | Some(ray.origin + ray.direction * t) 158 | } 159 | } 160 | 161 | fn snap_translation_vector(subgizmo: &SubGizmoConfig, new_delta: DVec3) -> DVec3 { 162 | let delta_length = new_delta.length(); 163 | if delta_length > 1e-5 { 164 | new_delta / delta_length 165 | * round_to_interval(delta_length, subgizmo.config.snap_distance as f64) 166 | } else { 167 | new_delta 168 | } 169 | } 170 | 171 | fn snap_translation_plane(subgizmo: &SubGizmoConfig, new_delta: DVec3) -> DVec3 { 172 | let mut bitangent = plane_bitangent(subgizmo.direction); 173 | let mut tangent = plane_tangent(subgizmo.direction); 174 | if subgizmo.config.local_space() { 175 | bitangent = subgizmo.config.rotation * bitangent; 176 | tangent = subgizmo.config.rotation * tangent; 177 | } 178 | let cb = new_delta.cross(-bitangent); 179 | let ct = new_delta.cross(tangent); 180 | let lb = cb.length(); 181 | let lt = ct.length(); 182 | let n = gizmo_normal(&subgizmo.config, subgizmo.direction); 183 | 184 | if lb > 1e-5 && lt > 1e-5 { 185 | bitangent * round_to_interval(lt, subgizmo.config.snap_distance as f64) * (ct / lt).dot(n) 186 | + tangent 187 | * round_to_interval(lb, subgizmo.config.snap_distance as f64) 188 | * (cb / lb).dot(n) 189 | } else { 190 | new_delta 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /docs/bevy-example_bg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urholaukkarinen/transform-gizmo/131b9b6b469ac00784353054673a4a26bd4bc1ba/docs/bevy-example_bg.wasm -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | transform-gizmo demo 5 | 33 | 34 | 35 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 182 | 183 | -------------------------------------------------------------------------------- /examples/bevy/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bevy-example" 3 | version.workspace = true 4 | rust-version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | homepage.workspace = true 8 | repository.workspace = true 9 | authors.workspace = true 10 | publish = false 11 | 12 | [dependencies] 13 | transform-gizmo-bevy.workspace = true 14 | 15 | bevy.workspace = true 16 | bevy_infinite_grid = "0.15" 17 | bevy_mod_outline = { git = "https://github.com/komadori/bevy_mod_outline.git", rev = "3cf356f58ca1bf30f26456cf854336f9851e6fe4" } 18 | 19 | [dependencies.bevy_egui] 20 | version = "0.34" 21 | features = ["open_url", "default_fonts", "render"] 22 | default-features = false 23 | 24 | [lints] 25 | workspace = true 26 | -------------------------------------------------------------------------------- /examples/bevy/Trunk.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | public_url = "./" 3 | filehash = false 4 | dist = "../../docs" 5 | -------------------------------------------------------------------------------- /examples/bevy/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | transform-gizmo demo 5 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /examples/bevy/src/camera.rs: -------------------------------------------------------------------------------- 1 | use bevy::input::mouse::{MouseMotion, MouseWheel}; 2 | use bevy::math::vec2; 3 | use bevy::prelude::*; 4 | 5 | // https://bevy-cheatbook.github.io/cookbook/pan-orbit-camera.html 6 | pub struct PanOrbitCameraPlugin; 7 | impl Plugin for PanOrbitCameraPlugin { 8 | fn build(&self, app: &mut App) { 9 | app.add_systems(Update, update_camera); 10 | } 11 | } 12 | 13 | #[derive(Component)] 14 | pub struct PanOrbitCamera { 15 | pub focus: Vec3, 16 | pub radius: f32, 17 | pub upside_down: bool, 18 | } 19 | 20 | impl Default for PanOrbitCamera { 21 | fn default() -> Self { 22 | Self { 23 | focus: Vec3::ZERO, 24 | radius: 5.0, 25 | upside_down: false, 26 | } 27 | } 28 | } 29 | 30 | fn update_camera( 31 | window_q: Query<&Window>, 32 | mut ev_motion: EventReader, 33 | mut ev_scroll: EventReader, 34 | input_mouse: Res>, 35 | mut query: Query<(&mut PanOrbitCamera, &mut Transform, &Projection)>, 36 | ) -> Result { 37 | let window = window_q.single()?; 38 | // change input mapping for orbit and panning here 39 | let orbit_button = MouseButton::Right; 40 | let pan_button = MouseButton::Middle; 41 | 42 | let mut pan = Vec2::ZERO; 43 | let mut rotation_move = Vec2::ZERO; 44 | let mut scroll = 0.0; 45 | let mut orbit_button_changed = false; 46 | 47 | if input_mouse.pressed(orbit_button) { 48 | for ev in ev_motion.read() { 49 | rotation_move += ev.delta; 50 | } 51 | } else if input_mouse.pressed(pan_button) { 52 | // Pan only if we're not rotating at the moment 53 | for ev in ev_motion.read() { 54 | pan += ev.delta; 55 | } 56 | } 57 | for ev in ev_scroll.read() { 58 | scroll += ev.y; 59 | 60 | scroll /= if cfg!(target_arch = "wasm32") { 61 | 100.0 62 | } else { 63 | 2.0 64 | }; 65 | } 66 | if input_mouse.just_released(orbit_button) || input_mouse.just_pressed(orbit_button) { 67 | orbit_button_changed = true; 68 | } 69 | 70 | for (mut pan_orbit, mut transform, projection) in &mut query { 71 | if orbit_button_changed { 72 | // only check for upside down when orbiting started or ended this frame 73 | // if the camera is "upside" down, panning horizontally would be inverted, so invert the input to make it correct 74 | let up = transform.rotation * Vec3::Y; 75 | pan_orbit.upside_down = up.y <= 0.0; 76 | } 77 | let window = vec2( 78 | window.physical_width() as f32, 79 | window.physical_height() as f32, 80 | ); 81 | 82 | let mut any = false; 83 | if rotation_move.length_squared() > 0.0 { 84 | any = true; 85 | let delta_x = { 86 | let delta = rotation_move.x / window.x * std::f32::consts::PI * 2.0; 87 | if pan_orbit.upside_down { -delta } else { delta } 88 | }; 89 | let delta_y = rotation_move.y / window.y * std::f32::consts::PI; 90 | let yaw = Quat::from_rotation_y(-delta_x); 91 | let pitch = Quat::from_rotation_x(-delta_y); 92 | transform.rotation = yaw * transform.rotation; // rotate around global y axis 93 | transform.rotation *= pitch; // rotate around local x axis 94 | } else if pan.length_squared() > 0.0 { 95 | any = true; 96 | 97 | if let Projection::Perspective(projection) = projection { 98 | pan *= Vec2::new(projection.fov * projection.aspect_ratio, projection.fov) / window; 99 | } 100 | // translate by local axes 101 | let right = transform.rotation * Vec3::X * -pan.x; 102 | let up = transform.rotation * Vec3::Y * pan.y; 103 | // make panning proportional to distance away from focus point 104 | let translation = (right + up) * pan_orbit.radius; 105 | pan_orbit.focus += translation; 106 | } else if scroll.abs() > 0.0 { 107 | any = true; 108 | pan_orbit.radius -= scroll * pan_orbit.radius * 0.2; 109 | // dont allow zoom to reach zero or you get stuck 110 | pan_orbit.radius = f32::max(pan_orbit.radius, 0.05); 111 | } 112 | 113 | if any { 114 | let rot_matrix = Mat3::from_quat(transform.rotation); 115 | transform.translation = 116 | pan_orbit.focus + rot_matrix.mul_vec3(Vec3::new(0.0, 0.0, pan_orbit.radius)); 117 | } 118 | } 119 | 120 | ev_motion.clear(); 121 | 122 | Ok(()) 123 | } 124 | -------------------------------------------------------------------------------- /examples/bevy/src/grid.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | app::{Plugin, Startup}, 3 | ecs::system::Commands, 4 | prelude::default, 5 | }; 6 | use bevy_infinite_grid::{InfiniteGridBundle, InfiniteGridPlugin, InfiniteGridSettings}; 7 | 8 | pub struct GridPlugin; 9 | impl Plugin for GridPlugin { 10 | fn build(&self, app: &mut bevy::prelude::App) { 11 | app.add_plugins(InfiniteGridPlugin) 12 | .add_systems(Startup, setup); 13 | } 14 | } 15 | 16 | fn setup(mut commands: Commands) { 17 | commands.spawn(InfiniteGridBundle { 18 | settings: InfiniteGridSettings { 19 | fadeout_distance: 40000., 20 | scale: 1.0, 21 | ..default() 22 | }, 23 | ..default() 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /examples/bevy/src/gui.rs: -------------------------------------------------------------------------------- 1 | use bevy::{math::DQuat, prelude::*}; 2 | use bevy_egui::{ 3 | EguiContexts, EguiPlugin, 4 | egui::{self, Layout, RichText, Widget}, 5 | }; 6 | use transform_gizmo_bevy::{config::TransformPivotPoint, prelude::*}; 7 | 8 | pub struct GuiPlugin; 9 | 10 | impl Plugin for GuiPlugin { 11 | fn build(&self, app: &mut App) { 12 | app.add_plugins(EguiPlugin { 13 | enable_multipass_for_primary_context: false, 14 | }) 15 | .add_systems(Update, update_ui); 16 | } 17 | } 18 | 19 | fn update_ui( 20 | mut contexts: EguiContexts, 21 | mut gizmo_options: ResMut, 22 | gizmo_targets: Query<&GizmoTarget>, 23 | ) { 24 | let options_panel = egui::SidePanel::left("options").show(contexts.ctx_mut(), |ui| { 25 | draw_options(ui, &mut gizmo_options); 26 | }); 27 | 28 | egui::Area::new("gizmo_result".into()) 29 | .interactable(false) 30 | .movable(false) 31 | .anchor( 32 | egui::Align2::LEFT_TOP, 33 | options_panel.response.rect.right_top().to_vec2(), 34 | ) 35 | .show(contexts.ctx_mut(), |ui| { 36 | ui.allocate_ui(ui.ctx().available_rect().size(), |ui| { 37 | let latest_gizmo_result = gizmo_targets 38 | .iter() 39 | .find_map(|target| target.latest_result()); 40 | 41 | draw_gizmo_result(ui, latest_gizmo_result); 42 | }); 43 | }); 44 | } 45 | 46 | fn draw_gizmo_result(ui: &mut egui::Ui, gizmo_result: Option) { 47 | if let Some(result) = gizmo_result { 48 | let text = match result { 49 | GizmoResult::Rotation { 50 | axis, 51 | delta: _, 52 | total, 53 | is_view_axis: _, 54 | } => { 55 | format!( 56 | "Rotation axis: ({:.2}, {:.2}, {:.2}), Angle: {:.2} deg", 57 | axis.x, 58 | axis.y, 59 | axis.z, 60 | total.to_degrees() 61 | ) 62 | } 63 | GizmoResult::Translation { delta: _, total } => { 64 | format!( 65 | "Translation: ({:.2}, {:.2}, {:.2})", 66 | total.x, total.y, total.z, 67 | ) 68 | } 69 | GizmoResult::Scale { total } => { 70 | format!("Scale: ({:.2}, {:.2}, {:.2})", total.x, total.y, total.z,) 71 | } 72 | GizmoResult::Arcball { delta: _, total } => { 73 | let (axis, angle) = DQuat::from(total).to_axis_angle(); 74 | format!( 75 | "Rotation axis: ({:.2}, {:.2}, {:.2}), Angle: {:.2} deg", 76 | axis.x, 77 | axis.y, 78 | axis.z, 79 | angle.to_degrees() 80 | ) 81 | } 82 | }; 83 | 84 | egui::Frame::new() 85 | .outer_margin(egui::Margin::same(10)) 86 | .show(ui, |ui| { 87 | ui.label(text); 88 | }); 89 | } 90 | } 91 | 92 | fn draw_options(ui: &mut egui::Ui, gizmo_options: &mut GizmoOptions) { 93 | ui.heading("Options"); 94 | ui.separator(); 95 | 96 | egui::Grid::new("modes_grid").num_columns(7).show(ui, |ui| { 97 | ui.label(RichText::new("Mode").strong()); 98 | ui.label(RichText::new("View").strong()); 99 | ui.label(RichText::new("X").strong()); 100 | ui.label(RichText::new("Y").strong()); 101 | ui.label(RichText::new("Z").strong()); 102 | ui.label(RichText::new("XZ").strong()); 103 | ui.label(RichText::new("XY").strong()); 104 | ui.label(RichText::new("YZ").strong()); 105 | ui.end_row(); 106 | 107 | ui.label("Rotation"); 108 | draw_mode_picker(ui, GizmoMode::RotateView, &mut gizmo_options.gizmo_modes); 109 | draw_mode_picker(ui, GizmoMode::RotateX, &mut gizmo_options.gizmo_modes); 110 | draw_mode_picker(ui, GizmoMode::RotateY, &mut gizmo_options.gizmo_modes); 111 | draw_mode_picker(ui, GizmoMode::RotateZ, &mut gizmo_options.gizmo_modes); 112 | ui.end_row(); 113 | 114 | ui.label("Translation"); 115 | draw_mode_picker(ui, GizmoMode::TranslateView, &mut gizmo_options.gizmo_modes); 116 | draw_mode_picker(ui, GizmoMode::TranslateX, &mut gizmo_options.gizmo_modes); 117 | draw_mode_picker(ui, GizmoMode::TranslateY, &mut gizmo_options.gizmo_modes); 118 | draw_mode_picker(ui, GizmoMode::TranslateZ, &mut gizmo_options.gizmo_modes); 119 | draw_mode_picker(ui, GizmoMode::TranslateXZ, &mut gizmo_options.gizmo_modes); 120 | draw_mode_picker(ui, GizmoMode::TranslateXY, &mut gizmo_options.gizmo_modes); 121 | draw_mode_picker(ui, GizmoMode::TranslateYZ, &mut gizmo_options.gizmo_modes); 122 | ui.end_row(); 123 | 124 | ui.label("Scale"); 125 | ui.add_enabled_ui( 126 | !gizmo_options.gizmo_modes.contains(GizmoMode::RotateView), 127 | |ui| { 128 | draw_mode_picker(ui, GizmoMode::ScaleUniform, &mut gizmo_options.gizmo_modes); 129 | }, 130 | ); 131 | draw_mode_picker(ui, GizmoMode::ScaleX, &mut gizmo_options.gizmo_modes); 132 | draw_mode_picker(ui, GizmoMode::ScaleY, &mut gizmo_options.gizmo_modes); 133 | draw_mode_picker(ui, GizmoMode::ScaleZ, &mut gizmo_options.gizmo_modes); 134 | ui.add_enabled_ui( 135 | !gizmo_options.gizmo_modes.contains(GizmoMode::TranslateXZ), 136 | |ui| { 137 | draw_mode_picker(ui, GizmoMode::ScaleXZ, &mut gizmo_options.gizmo_modes); 138 | }, 139 | ); 140 | ui.add_enabled_ui( 141 | !gizmo_options.gizmo_modes.contains(GizmoMode::TranslateXY), 142 | |ui| { 143 | draw_mode_picker(ui, GizmoMode::ScaleXY, &mut gizmo_options.gizmo_modes); 144 | }, 145 | ); 146 | ui.add_enabled_ui( 147 | !gizmo_options.gizmo_modes.contains(GizmoMode::TranslateYZ), 148 | |ui| { 149 | draw_mode_picker(ui, GizmoMode::ScaleYZ, &mut gizmo_options.gizmo_modes); 150 | }, 151 | ); 152 | ui.end_row(); 153 | 154 | ui.label("Arcball"); 155 | draw_mode_picker(ui, GizmoMode::Arcball, &mut gizmo_options.gizmo_modes); 156 | ui.end_row(); 157 | }); 158 | 159 | ui.separator(); 160 | 161 | egui::Grid::new("options_grid") 162 | .num_columns(2) 163 | .show(ui, |ui| { 164 | ui.label("Orientation"); 165 | egui::ComboBox::from_id_salt("orientation_cb") 166 | .selected_text(format!("{:?}", gizmo_options.gizmo_orientation)) 167 | .show_ui(ui, |ui| { 168 | for orientation in [GizmoOrientation::Global, GizmoOrientation::Local] { 169 | ui.selectable_value( 170 | &mut gizmo_options.gizmo_orientation, 171 | orientation, 172 | format!("{:?}", orientation), 173 | ); 174 | } 175 | }); 176 | ui.end_row(); 177 | 178 | ui.label("Pivot point"); 179 | egui::ComboBox::from_id_salt("pivot_cb") 180 | .selected_text(format!("{:?}", gizmo_options.pivot_point)) 181 | .show_ui(ui, |ui| { 182 | for pivot_point in [ 183 | TransformPivotPoint::MedianPoint, 184 | TransformPivotPoint::IndividualOrigins, 185 | ] { 186 | ui.selectable_value( 187 | &mut gizmo_options.pivot_point, 188 | pivot_point, 189 | format!("{:?}", pivot_point), 190 | ); 191 | } 192 | }); 193 | ui.end_row(); 194 | 195 | ui.label("Group targets"); 196 | egui::Checkbox::without_text(&mut gizmo_options.group_targets).ui(ui); 197 | ui.end_row(); 198 | }); 199 | 200 | ui.separator(); 201 | ui.heading("Visuals"); 202 | ui.separator(); 203 | 204 | egui::Grid::new("visuals_grid") 205 | .num_columns(2) 206 | .show(ui, |ui| { 207 | ui.label("Gizmo size"); 208 | egui::Slider::new(&mut gizmo_options.visuals.gizmo_size, 10.0..=200.0).ui(ui); 209 | ui.end_row(); 210 | 211 | ui.label("Stroke width"); 212 | egui::Slider::new(&mut gizmo_options.visuals.stroke_width, 1.0..=15.0).ui(ui); 213 | ui.end_row(); 214 | 215 | ui.label("Inactive alpha"); 216 | egui::Slider::new(&mut gizmo_options.visuals.inactive_alpha, 0.0..=1.0).ui(ui); 217 | ui.end_row(); 218 | 219 | ui.label("Highlight alpha"); 220 | egui::Slider::new(&mut gizmo_options.visuals.highlight_alpha, 0.0..=1.0).ui(ui); 221 | ui.end_row(); 222 | 223 | ui.label("X axis color"); 224 | draw_color_picker(ui, &mut gizmo_options.visuals.x_color); 225 | ui.end_row(); 226 | 227 | ui.label("Y axis color"); 228 | draw_color_picker(ui, &mut gizmo_options.visuals.y_color); 229 | ui.end_row(); 230 | 231 | ui.label("Z axis color"); 232 | draw_color_picker(ui, &mut gizmo_options.visuals.z_color); 233 | ui.end_row(); 234 | 235 | ui.label("View axis color"); 236 | draw_color_picker(ui, &mut gizmo_options.visuals.s_color); 237 | ui.end_row(); 238 | }); 239 | 240 | ui.separator(); 241 | 242 | ui.with_layout(Layout::bottom_up(egui::Align::Min), |ui| { 243 | egui::Hyperlink::from_label_and_url("(source code)", "https://github.com/urholaukkarinen/transform-gizmo/blob/main/examples/bevy/src/main.rs").ui(ui); 244 | 245 | ui.label(r#"Move and rotate the camera using the middle and right mouse buttons. 246 | Toggle gizmo snapping with left ctrl & shift. 247 | You can enter transform mode for translation, rotation and scale with by pressing G, R or S respectively. 248 | Transform mode can be exited with Esc or by pressing any mouse button."#); 249 | }); 250 | } 251 | 252 | fn draw_mode_picker(ui: &mut egui::Ui, mode: GizmoMode, all_modes: &mut EnumSet) { 253 | let mut checked = all_modes.contains(mode); 254 | 255 | egui::Checkbox::without_text(&mut checked).ui(ui); 256 | 257 | if checked { 258 | all_modes.insert(mode); 259 | } else { 260 | all_modes.remove(mode); 261 | } 262 | } 263 | 264 | fn draw_color_picker(ui: &mut egui::Ui, color: &mut Color32) { 265 | let mut egui_color = 266 | egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), color.a()); 267 | 268 | let color_picker = egui::color_picker::color_edit_button_srgba( 269 | ui, 270 | &mut egui_color, 271 | egui::color_picker::Alpha::Opaque, 272 | ); 273 | 274 | if color_picker.changed() { 275 | *color = Color32::from_rgba_premultiplied( 276 | egui_color.r(), 277 | egui_color.g(), 278 | egui_color.b(), 279 | egui_color.a(), 280 | ); 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /examples/bevy/src/main.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use camera::PanOrbitCameraPlugin; 3 | use gui::GuiPlugin; 4 | use picking::GizmoPickingPlugin; 5 | use scene::ScenePlugin; 6 | use transform_gizmo_bevy::GizmoHotkeys; 7 | 8 | use crate::grid::GridPlugin; 9 | use transform_gizmo_bevy::prelude::*; 10 | 11 | mod camera; 12 | mod grid; 13 | mod gui; 14 | mod picking; 15 | mod scene; 16 | 17 | fn main() { 18 | App::new() 19 | .insert_resource(ClearColor(Color::srgb_u8(20, 20, 20))) 20 | .add_plugins(DefaultPlugins.set(WindowPlugin { 21 | primary_window: Some(Window { 22 | title: "transform-gizmo-demo".into(), 23 | ..default() 24 | }), 25 | ..default() 26 | })) 27 | .add_plugins(GridPlugin) 28 | .add_plugins(GuiPlugin) 29 | .add_plugins(PanOrbitCameraPlugin) 30 | .add_plugins(ScenePlugin) 31 | .add_plugins(TransformGizmoPlugin) 32 | .add_plugins(GizmoPickingPlugin) 33 | .insert_resource(GizmoOptions { 34 | hotkeys: Some(GizmoHotkeys::default()), 35 | ..default() 36 | }) 37 | .run(); 38 | } 39 | -------------------------------------------------------------------------------- /examples/bevy/src/picking.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | picking::pointer::{PointerInteraction, PointerPress}, 3 | prelude::*, 4 | }; 5 | use bevy_mod_outline::*; 6 | use transform_gizmo_bevy::GizmoTarget; 7 | 8 | #[derive(Component, Clone, Copy)] 9 | pub struct PickSelection { 10 | pub is_selected: bool, 11 | } 12 | 13 | /// Integrates picking with gizmo and highlighting. 14 | pub struct GizmoPickingPlugin; 15 | 16 | impl Plugin for GizmoPickingPlugin { 17 | fn build(&self, app: &mut bevy::prelude::App) { 18 | app.add_plugins(OutlinePlugin) 19 | .add_plugins(MeshPickingPlugin) 20 | .add_systems(PreUpdate, toggle_picking_enabled) 21 | .add_systems(Update, update_picking) 22 | .add_systems(Update, manage_selection); 23 | } 24 | } 25 | 26 | fn toggle_picking_enabled( 27 | gizmo_targets: Query<&GizmoTarget>, 28 | mut picking_settings: ResMut, 29 | ) { 30 | // Picking is disabled when any of the gizmos is focused or active. 31 | 32 | picking_settings.is_enabled = gizmo_targets 33 | .iter() 34 | .all(|target| !target.is_focused() && !target.is_active()); 35 | } 36 | 37 | pub fn update_picking( 38 | mut targets: Query< 39 | ( 40 | Entity, 41 | &PickSelection, 42 | &mut OutlineVolume, 43 | Option<&GizmoTarget>, 44 | ), 45 | Changed, 46 | >, 47 | mut commands: Commands, 48 | ) { 49 | for (entity, pick_interaction, mut outline, gizmo_target) in &mut targets { 50 | let mut entity_cmd = commands.entity(entity); 51 | 52 | if pick_interaction.is_selected { 53 | if gizmo_target.is_none() { 54 | entity_cmd.insert(GizmoTarget::default()); 55 | } 56 | 57 | outline.visible = true; 58 | } else { 59 | entity_cmd.remove::(); 60 | 61 | outline.visible = false; 62 | } 63 | } 64 | } 65 | 66 | pub fn manage_selection( 67 | pointers: Query<&PointerInteraction, Changed>, 68 | mouse: Res>, 69 | keys: Res>, 70 | mut pick_selection: Query<&mut PickSelection>, 71 | ) { 72 | // don't continue if the pointer was just pressed. 73 | if !mouse.just_released(MouseButton::Left) { 74 | return; 75 | }; 76 | let pointer = match pointers.single() { 77 | Ok(pointer) => pointer, 78 | Err(err) => match err { 79 | bevy::ecs::query::QuerySingleError::NoEntities(_) => { 80 | // warn!(err); 81 | return; 82 | } 83 | bevy::ecs::query::QuerySingleError::MultipleEntities(_) => { 84 | warn!("demo only works with one pointer. delete extra pointer sources!"); 85 | return; 86 | } 87 | }, 88 | }; 89 | if let Some((e, _)) = pointer.first() { 90 | let Ok(root) = pick_selection.get(*e).map(|n| n.is_selected) else { 91 | return; 92 | }; 93 | 94 | if !keys.pressed(KeyCode::ShiftLeft) { 95 | for mut pick in &mut pick_selection { 96 | pick.is_selected = false; 97 | } 98 | } 99 | 100 | let Ok(mut pick) = pick_selection.get_mut(*e) else { 101 | return; 102 | }; 103 | pick.is_selected = root; 104 | pick.is_selected ^= true; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /examples/bevy/src/scene.rs: -------------------------------------------------------------------------------- 1 | use bevy::color::palettes::css::{BLUE, LIME, RED}; 2 | use bevy::prelude::*; 3 | use bevy_mod_outline::*; 4 | 5 | use transform_gizmo_bevy::GizmoCamera; 6 | 7 | use crate::camera::PanOrbitCamera; 8 | use crate::picking::PickSelection; 9 | 10 | pub struct ScenePlugin; 11 | 12 | impl Plugin for ScenePlugin { 13 | fn build(&self, app: &mut App) { 14 | app.add_systems(Startup, setup_scene); 15 | } 16 | } 17 | 18 | fn setup_scene( 19 | mut commands: Commands, 20 | mut meshes: ResMut>, 21 | mut materials: ResMut>, 22 | ) { 23 | let camera_transform = Transform::from_xyz(5.0, 5.0, 5.0); 24 | 25 | commands.spawn(( 26 | PanOrbitCamera { 27 | radius: camera_transform.translation.length(), 28 | ..Default::default() 29 | }, 30 | Camera3d::default(), 31 | camera_transform.looking_at(Vec3::ZERO, Vec3::Y), 32 | GizmoCamera, 33 | )); 34 | 35 | let cube_mesh = meshes.add(Cuboid::default()); 36 | 37 | let cube_count: i32 = 3; 38 | 39 | let colors: [Color; 3] = [RED.into(), LIME.into(), BLUE.into()]; 40 | 41 | for i in 0..cube_count { 42 | commands.spawn(( 43 | Mesh3d(cube_mesh.clone()), 44 | MeshMaterial3d(materials.add(colors[i as usize % colors.len()])), 45 | Transform::from_xyz(-(cube_count / 2) as f32 * 1.5 + (i as f32 * 1.5), 0.0, 0.0), 46 | // Pick, 47 | OutlineVolume { 48 | visible: false, 49 | colour: Color::WHITE, 50 | width: 2.0, 51 | }, 52 | PickSelection { is_selected: true }, 53 | OutlineStencil::default(), 54 | OutlineMode::default(), 55 | ComputedOutline::default(), 56 | )); 57 | } 58 | commands.spawn(( 59 | PointLight { 60 | shadows_enabled: true, 61 | ..default() 62 | }, 63 | Transform::from_xyz(4.0, 8.0, 4.0), 64 | )); 65 | } 66 | -------------------------------------------------------------------------------- /examples/egui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "egui-example" 3 | version.workspace = true 4 | rust-version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | homepage.workspace = true 8 | repository.workspace = true 9 | authors.workspace = true 10 | publish = false 11 | 12 | [dependencies] 13 | transform-gizmo-egui.workspace = true 14 | eframe.workspace = true 15 | 16 | [lints] 17 | workspace = true 18 | -------------------------------------------------------------------------------- /examples/egui/src/main.rs: -------------------------------------------------------------------------------- 1 | use eframe::{NativeOptions, egui}; 2 | use transform_gizmo_egui::math::{DQuat, Transform}; 3 | use transform_gizmo_egui::{ 4 | math::{DMat4, DVec3}, 5 | *, 6 | }; 7 | 8 | struct ExampleApp { 9 | gizmo: Gizmo, 10 | 11 | gizmo_modes: EnumSet, 12 | gizmo_orientation: GizmoOrientation, 13 | 14 | scale: DVec3, 15 | rotation: DQuat, 16 | translation: DVec3, 17 | } 18 | 19 | impl ExampleApp { 20 | fn new() -> Self { 21 | Self { 22 | gizmo: Gizmo::default(), 23 | gizmo_modes: GizmoMode::all(), 24 | gizmo_orientation: GizmoOrientation::Local, 25 | scale: DVec3::ONE, 26 | rotation: DQuat::IDENTITY, 27 | translation: DVec3::ZERO, 28 | } 29 | } 30 | 31 | fn draw_gizmo(&mut self, ui: &mut egui::Ui) { 32 | // The whole clipping area of the UI is used as viewport 33 | let viewport = ui.clip_rect(); 34 | 35 | let projection_matrix = DMat4::perspective_infinite_reverse_lh( 36 | std::f64::consts::PI / 4.0, 37 | (viewport.width() / viewport.height()).into(), 38 | 0.1, 39 | ); 40 | 41 | // Fixed camera position 42 | let view_matrix = DMat4::look_at_lh(DVec3::splat(5.0), DVec3::ZERO, DVec3::Y); 43 | 44 | // Ctrl toggles snapping 45 | let snapping = ui.input(|input| input.modifiers.ctrl); 46 | 47 | self.gizmo.update_config(GizmoConfig { 48 | view_matrix: view_matrix.into(), 49 | projection_matrix: projection_matrix.into(), 50 | viewport, 51 | modes: self.gizmo_modes, 52 | orientation: self.gizmo_orientation, 53 | snapping, 54 | ..Default::default() 55 | }); 56 | 57 | let mut transform = 58 | Transform::from_scale_rotation_translation(self.scale, self.rotation, self.translation); 59 | 60 | if let Some((result, new_transforms)) = self.gizmo.interact(ui, &[transform]) { 61 | for (new_transform, transform) in 62 | new_transforms.iter().zip(std::iter::once(&mut transform)) 63 | { 64 | *transform = *new_transform; 65 | } 66 | 67 | self.scale = transform.scale.into(); 68 | self.rotation = transform.rotation.into(); 69 | self.translation = transform.translation.into(); 70 | 71 | let text = match result { 72 | GizmoResult::Rotation { 73 | axis, 74 | delta: _, 75 | total, 76 | is_view_axis: _, 77 | } => { 78 | format!( 79 | "Rotation axis: ({:.2}, {:.2}, {:.2}), Angle: {:.2} deg", 80 | axis.x, 81 | axis.y, 82 | axis.z, 83 | total.to_degrees() 84 | ) 85 | } 86 | GizmoResult::Translation { delta: _, total } => { 87 | format!( 88 | "Translation: ({:.2}, {:.2}, {:.2})", 89 | total.x, total.y, total.z, 90 | ) 91 | } 92 | GizmoResult::Scale { total } => { 93 | format!("Scale: ({:.2}, {:.2}, {:.2})", total.x, total.y, total.z,) 94 | } 95 | GizmoResult::Arcball { delta: _, total } => { 96 | let (axis, angle) = DQuat::from(total).to_axis_angle(); 97 | format!( 98 | "Rotation axis: ({:.2}, {:.2}, {:.2}), Angle: {:.2} deg", 99 | axis.x, 100 | axis.y, 101 | axis.z, 102 | angle.to_degrees() 103 | ) 104 | } 105 | }; 106 | 107 | ui.label(text); 108 | } 109 | } 110 | 111 | fn draw_options(&mut self, ui: &mut egui::Ui) { 112 | ui.heading("Options"); 113 | ui.separator(); 114 | 115 | egui::Grid::new("options_grid") 116 | .num_columns(2) 117 | .show(ui, |ui| { 118 | ui.label("Modes"); 119 | egui::ComboBox::from_id_salt("mode_cb") 120 | .selected_text(format!("{}", self.gizmo_modes.len())) 121 | .show_ui(ui, |ui| { 122 | for mode in GizmoMode::all() { 123 | let mut mode_selected = self.gizmo_modes.contains(mode); 124 | ui.toggle_value(&mut mode_selected, format!("{:?}", mode)); 125 | if mode_selected { 126 | self.gizmo_modes.insert(mode); 127 | } else { 128 | self.gizmo_modes.remove(mode); 129 | } 130 | } 131 | }); 132 | ui.end_row(); 133 | 134 | ui.label("Orientation"); 135 | egui::ComboBox::from_id_salt("orientation_cb") 136 | .selected_text(format!("{:?}", self.gizmo_orientation)) 137 | .show_ui(ui, |ui| { 138 | for orientation in [GizmoOrientation::Global, GizmoOrientation::Local] { 139 | ui.selectable_value( 140 | &mut self.gizmo_orientation, 141 | orientation, 142 | format!("{:?}", orientation), 143 | ); 144 | } 145 | }); 146 | ui.end_row(); 147 | }); 148 | } 149 | } 150 | 151 | impl eframe::App for ExampleApp { 152 | fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { 153 | egui::SidePanel::left("options_panel").show(ctx, |ui| { 154 | self.draw_options(ui); 155 | }); 156 | 157 | egui::CentralPanel::default().show(ctx, |ui| { 158 | self.draw_gizmo(ui); 159 | }); 160 | 161 | ctx.request_repaint(); 162 | } 163 | } 164 | 165 | fn main() -> eframe::Result { 166 | eframe::run_native( 167 | "transform_gizmo_egui example", 168 | NativeOptions::default(), 169 | Box::new(|_| Ok(Box::new(ExampleApp::new()))), 170 | ) 171 | } 172 | -------------------------------------------------------------------------------- /media/all_modes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urholaukkarinen/transform-gizmo/131b9b6b469ac00784353054673a4a26bd4bc1ba/media/all_modes.png -------------------------------------------------------------------------------- /media/rotate_translate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urholaukkarinen/transform-gizmo/131b9b6b469ac00784353054673a4a26bd4bc1ba/media/rotate_translate.png -------------------------------------------------------------------------------- /media/rotating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urholaukkarinen/transform-gizmo/131b9b6b469ac00784353054673a4a26bd4bc1ba/media/rotating.png -------------------------------------------------------------------------------- /media/rotation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urholaukkarinen/transform-gizmo/131b9b6b469ac00784353054673a4a26bd4bc1ba/media/rotation.png -------------------------------------------------------------------------------- /media/scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urholaukkarinen/transform-gizmo/131b9b6b469ac00784353054673a4a26bd4bc1ba/media/scale.png -------------------------------------------------------------------------------- /media/translation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urholaukkarinen/transform-gizmo/131b9b6b469ac00784353054673a4a26bd4bc1ba/media/translation.png --------------------------------------------------------------------------------