diff --git a/studio-web/src/lib/transform-toolbar.element.ts b/studio-web/src/lib/transform-toolbar.element.ts index 8a8f4e1..3e5bef7 100644 --- a/studio-web/src/lib/transform-toolbar.element.ts +++ b/studio-web/src/lib/transform-toolbar.element.ts @@ -92,7 +92,6 @@ export class TransformToolbarElement extends LitElement { Scale diff --git a/studio/src/interaction/gizmos/common.rs b/studio/src/interaction/gizmos/common.rs index 6c5e46f..bd1e5e0 100644 --- a/studio/src/interaction/gizmos/common.rs +++ b/studio/src/interaction/gizmos/common.rs @@ -84,3 +84,6 @@ pub(super) fn on_gizmo_element_unhover( let mat = ra_mats.get_mut(q_target.get(ev.target).unwrap()).unwrap(); mat.color *= 0.5; } + +#[derive(Component, Deref)] +pub(super) struct InitialHitPoint(pub Vec3); diff --git a/studio/src/interaction/gizmos/mod.rs b/studio/src/interaction/gizmos/mod.rs index 5ed674d..929721d 100644 --- a/studio/src/interaction/gizmos/mod.rs +++ b/studio/src/interaction/gizmos/mod.rs @@ -9,6 +9,7 @@ use self::{ material::GizmoMaterial, resources::{init_resources, GizmoMats, GizmoMeshes}, rotation::{spawn_rotation_gizmo, update_ss_ring_rotation}, + scale::spawn_scale_gizmo, translation::spawn_translation_gizmo, }; pub use common::TransformGizmo; @@ -21,6 +22,7 @@ mod common; mod material; mod resources; mod rotation; +mod scale; mod translation; const GIZMO_RENDER_LAYER: u8 = 1; @@ -130,6 +132,15 @@ fn spawn_gizmo( &r_mats, ); } + TransformType::SCALE => spawn_scale_gizmo( + &mut cmd, + target, + xform, + r_xform_mode.space, + normalizing_scale, + &r_meshes, + &r_mats, + ), ttype if ttype.contains(TransformType::TRANSLATE | TransformType::ROTATE) => { spawn_combo_translation_rotation_gizmo( &mut cmd, diff --git a/studio/src/interaction/gizmos/resources.rs b/studio/src/interaction/gizmos/resources.rs index bc92151..9bcc486 100644 --- a/studio/src/interaction/gizmos/resources.rs +++ b/studio/src/interaction/gizmos/resources.rs @@ -9,6 +9,8 @@ use super::material::GizmoMaterial; pub(super) struct GizmoMeshes { pub translate_line: Handle, pub translate_arrow: Handle, + pub scale_cube: Handle, + pub uniform_scale_cube: Handle, pub translate_plane: Handle, pub translate_ss: Handle, pub rotate_ring: Handle, @@ -26,6 +28,9 @@ pub(super) struct GizmoMats { pub translate_xz: Handle, pub translate_xy: Handle, pub translate_ss: Handle, + pub scale_x: Handle, + pub scale_y: Handle, + pub scale_z: Handle, pub rotate_x: Handle, pub rotate_y: Handle, pub rotate_z: Handle, @@ -78,6 +83,9 @@ pub(super) fn init_resources( subdivisions_sides: 6, })); + let scale_cube = ra_meshes.add(Mesh::from(shape::Cube { size: 0.2 })); + let uniform_scale_cube = ra_meshes.add(Mesh::from(shape::Cube { size: 0.333 })); + cmd.insert_resource(GizmoMeshes { translate_line, translate_arrow, @@ -86,6 +94,8 @@ pub(super) fn init_resources( rotate_ring, rotate_ball, rotate_ss, + scale_cube, + uniform_scale_cube, }); let translate_x = ra_mats.add(design_system::Color::RED.into()); @@ -104,6 +114,10 @@ pub(super) fn init_resources( let rotate_z = ra_mats.add(design_system::Color::BLUE.into()); let rotate_ss = ra_mats.add(Color::WHITE.into()); + let scale_x = ra_mats.add(design_system::Color::RED.into()); + let scale_y = ra_mats.add(design_system::Color::GREEN.into()); + let scale_z = ra_mats.add(design_system::Color::BLUE.into()); + let rotate_trackball = ra_mats.add(GizmoMaterial { color: Color::WHITE.with_a(0.0), blend_mode: AlphaMode::Blend, @@ -117,6 +131,9 @@ pub(super) fn init_resources( translate_xz, translate_xy, translate_ss, + scale_x, + scale_y, + scale_z, rotate_x, rotate_y, rotate_z, diff --git a/studio/src/interaction/gizmos/rotation.rs b/studio/src/interaction/gizmos/rotation.rs index f2ef516..3c0420e 100644 --- a/studio/src/interaction/gizmos/rotation.rs +++ b/studio/src/interaction/gizmos/rotation.rs @@ -8,7 +8,7 @@ use bevy_mod_picking::prelude::*; use bevy_mod_raycast::prelude::{Raycast, RaycastSettings}; use crate::{ - interaction::TransformSpace, + interaction::{gizmos::InitialHitPoint, TransformSpace}, math::{MatrixExtras, NearlyZero}, MainCamera, }; @@ -25,9 +25,6 @@ pub(super) enum RotationWidget { Trackball, } -#[derive(Component, Deref)] -pub(super) struct InitialHitPoint(pub Vec3); - #[derive(Component, Deref, DerefMut)] pub(super) struct LastHitPoint(pub Vec3); diff --git a/studio/src/interaction/gizmos/scale.rs b/studio/src/interaction/gizmos/scale.rs new file mode 100644 index 0000000..1babb0a --- /dev/null +++ b/studio/src/interaction/gizmos/scale.rs @@ -0,0 +1,342 @@ +use bevy::{math::vec3, pbr::NotShadowCaster, prelude::*, render::view::RenderLayers}; +use bevy_mod_picking::prelude::{Drag, DragEnd, DragStart, Listener, On, Out, Over, Pointer}; + +use crate::{interaction::TransformSpace, math::NearlyEq, MainCamera}; + +use super::{ + on_gizmo_element_hover, on_gizmo_element_unhover, GizmoMats, GizmoMeshes, InitialHitPoint, + InitialTransform, TransformGizmo, TransformGizmoBundle, GIZMO_RENDER_LAYER, +}; + +#[derive(Component)] +pub(super) enum ScaleWidget { + Axis(Vec3), + Plane(Vec3), + UniformScale, +} + +#[inline] +pub(super) fn spawn_scale_gizmo( + cmd: &mut Commands, + target: Entity, + target_global_transform: &GlobalTransform, + space: TransformSpace, + normalizing_scale: f32, + meshes: &GizmoMeshes, + mats: &GizmoMats, +) { + cmd.spawn(( + TransformGizmoBundle::new(target, target_global_transform, space, normalizing_scale), + On::>::run(on_gizmo_element_hover), + On::>::run(on_gizmo_element_unhover), + On::>::run(on_scale_start), + On::>::run(on_scale), + On::>::run(on_scale_end), + )) + .with_children(|cmd| { + use super::{CIRC_45, ROT_X, ROT_Y, ROT_Z}; + + // Center sphere + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::UniformScale, + MaterialMeshBundle { + mesh: meshes.uniform_scale_cube.clone(), + material: mats.translate_ss.clone(), + transform: Transform::IDENTITY, + ..default() + }, + )); + + // X + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::Axis(Vec3::X), + MaterialMeshBundle { + mesh: meshes.translate_line.clone(), + material: mats.scale_x.clone(), + transform: Transform { + rotation: ROT_X, + translation: vec3(0.6, 0., 0.), + ..default() + }, + ..default() + }, + )); + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::Axis(Vec3::X), + MaterialMeshBundle { + mesh: meshes.scale_cube.clone(), + material: mats.scale_x.clone(), + transform: Transform { + rotation: ROT_X, + translation: vec3(1., 0., 0.), + ..default() + }, + ..default() + }, + )); + + // Y + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::Axis(Vec3::Y), + MaterialMeshBundle { + mesh: meshes.translate_line.clone(), + material: mats.scale_y.clone(), + transform: Transform { + rotation: ROT_Y, + translation: vec3(0., 0.6, 0.), + ..default() + }, + ..default() + }, + )); + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::Axis(Vec3::Y), + MaterialMeshBundle { + mesh: meshes.scale_cube.clone(), + material: mats.scale_y.clone(), + transform: Transform { + rotation: ROT_Y, + translation: vec3(0., 1., 0.), + ..default() + }, + ..default() + }, + )); + + // Z + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::Axis(Vec3::Z), + MaterialMeshBundle { + mesh: meshes.translate_line.clone(), + material: mats.scale_z.clone(), + transform: Transform { + rotation: ROT_Z, + translation: vec3(0., 0., 0.6), + ..default() + }, + ..default() + }, + )); + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::Axis(Vec3::Z), + MaterialMeshBundle { + mesh: meshes.scale_cube.clone(), + material: mats.scale_z.clone(), + transform: Transform { + rotation: ROT_Z, + translation: vec3(0., 0., 1.), + ..default() + }, + ..default() + }, + )); + + // YZ Plane + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::Plane(Vec3::X), + MaterialMeshBundle { + mesh: meshes.translate_plane.clone(), + material: mats.translate_yz.clone(), + transform: Transform { + rotation: ROT_X, + translation: vec3(0., CIRC_45, CIRC_45), + ..default() + }, + ..default() + }, + )); + // XZ Plane + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::Plane(Vec3::Y), + MaterialMeshBundle { + mesh: meshes.translate_plane.clone(), + material: mats.translate_xz.clone(), + transform: Transform { + rotation: ROT_Y, + translation: vec3(CIRC_45, 0., CIRC_45), + ..default() + }, + ..default() + }, + )); + // XY Plane + cmd.spawn(( + NotShadowCaster, + RenderLayers::layer(GIZMO_RENDER_LAYER), + ScaleWidget::Plane(Vec3::Z), + MaterialMeshBundle { + mesh: meshes.translate_plane.clone(), + material: mats.translate_xy.clone(), + transform: Transform { + rotation: ROT_Z, + translation: vec3(CIRC_45, CIRC_45, 0.), + ..default() + }, + ..default() + }, + )); + }); +} + +pub(super) fn on_scale_start( + event: Listener>, + mut cmd: Commands, + q_xform: Query<(Entity, &GlobalTransform)>, + q_gizmos: Query<&TransformGizmo>, +) { + for gizmo_data in q_gizmos.iter() { + if let Ok((ent, &world_xform)) = q_xform.get(gizmo_data.target) { + let hit_point = event.hit.position.unwrap(); + + cmd.entity(ent) + .insert((InitialTransform(world_xform), InitialHitPoint(hit_point))); + } + } +} + +pub(super) fn on_scale_end( + _: Listener>, + mut cmd: Commands, + q_initial_transforms: Query>, +) { + for ent in q_initial_transforms.iter() { + cmd.entity(ent).remove::(); + } +} + +pub(super) fn on_scale( + event: Listener>, + mut q_root: Query<(&mut Transform, &InitialTransform, &InitialHitPoint)>, + q_camera: Query<(&Camera, &GlobalTransform), With>, + q_gizmo: Query<(&GlobalTransform, &TransformGizmo), Without>, + q_widget: Query<&ScaleWidget>, +) { + let (gizmo_xform, gizmo_data) = q_gizmo.single(); + let Ok((mut target_transform, init_global_transform, initial_hit_point)) = + q_root.get_mut(gizmo_data.target) + else { + error!("Scale gizmo target not found"); + return; + }; + let (cam, cam_xform) = q_camera.single(); + + // Find the initial origin point in screen-space and add the total screen-space drag distance + let (initial_scale, _, origin_ws) = init_global_transform.to_scale_rotation_translation(); + let origin_ss = cam + .world_to_viewport(cam_xform, **initial_hit_point) + .unwrap(); + let target_ss = origin_ss + event.distance; + + // Create a camera-to-world ray that passes through the screen-space target point + let ray = cam.viewport_to_world(cam_xform, target_ss).unwrap(); + + use ScaleWidget::*; + + match q_widget.get(event.target) { + Ok(Axis(axis)) => { + // Transform the translation axis into world-space + let axis_ws = gizmo_xform.affine().transform_vector3(*axis).normalize(); + + // Find the plane vector that's orthogonal to the translation axis + // and closest to parallel with the view direction + let axis_tan = axis_ws.cross(cam_xform.back()); + let plane_vector = axis_ws.cross(axis_tan); + + // Find the intersection of the camera-to-world ray against that plane + let Some(hit_dist) = ray.intersect_plane(origin_ws, plane_vector) else { + return; + }; + let hit_point = ray.origin + ray.direction * hit_dist; + + // Project the vector from origin -> hit point onto the translation + // axis to constrain the translation + let drag_vector = hit_point - origin_ws; + let projection_length = drag_vector.dot(axis_ws); + let target_ws = origin_ws + axis_ws * projection_length; + + let distance_from_initial_click = initial_hit_point.distance(origin_ws); + let distance_from_current = target_ws.distance(origin_ws); + + let scale_factor = distance_from_current / distance_from_initial_click; + + info!("Scale Factor {scale_factor}"); + + let (target_x, target_y, target_z) = initial_scale.into(); + let new_scale = if axis.nearly_eq(Vec3::Y) { + Vec3::new(target_x, target_y * scale_factor, target_z) + } else if axis.nearly_eq(Vec3::X) { + Vec3::new(target_x * scale_factor, target_y, target_z) + } else if axis.nearly_eq(Vec3::Z) { + Vec3::new(target_x, target_y, target_z * scale_factor) + } else { + unreachable!(); + }; + + info!("New Scale {:?}", new_scale); + + target_transform.scale = new_scale; + } + Ok(Plane(plane_normal)) => { + let normal_ws = gizmo_xform + .affine() + .transform_vector3(*plane_normal) + .normalize(); + let Some(hit_dist) = ray.intersect_plane(origin_ws, normal_ws) else { + return; + }; + let hit_point = ray.origin + ray.direction * hit_dist; + + let distance_from_initial_click = initial_hit_point.distance(origin_ws); + let distance_from_current = hit_point.distance(origin_ws); + + let scale = distance_from_current / distance_from_initial_click; + + let scale_offset = if plane_normal.nearly_eq(Vec3::Z) { + Vec3::new(scale, scale, 1.0) + } else if plane_normal.nearly_eq(Vec3::Y) { + Vec3::new(scale, 1.0, scale) + } else if plane_normal.nearly_eq(Vec3::X) { + Vec3::new(1.0, scale, scale) + } else { + unreachable!(); + }; + + target_transform.scale = initial_scale * scale_offset; + } + Ok(UniformScale) => { + let original_click_ss = cam + .world_to_viewport(cam_xform, **initial_hit_point) + .unwrap(); + + let sign = if original_click_ss.y > target_ss.y { + 1.0 + } else { + -1.0 + }; + let divisor = original_click_ss.y; + let distance_click = original_click_ss.distance(target_ss); + + target_transform.scale = + initial_scale + Vec3::splat((distance_click / divisor * sign) * 1.5); + } + _ => {} + } +}