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);
+ }
+ _ => {}
+ }
+}