diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 0ef0494b..12ccb2b9 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -1,12 +1,18 @@ import { on } from "@storyteller/framework"; import * as studio from "@storyteller/studio"; import { + type EntitySelectEvent, type SceneSavedEvent, type SceneStateEvent, SceneState, StudioMode, + SceneElement, } from "@storyteller/studio"; -import { StudioURLParams } from "@storyteller/studio-web"; +import { + SceneHierarchyElement, + type SceneObject, + StudioURLParams, +} from "@storyteller/studio-web"; import { CanvasProvider } from "@storyteller/studio-web/canvas-provider"; import { match } from "@storyteller/utility"; import { LitElement, html, nothing } from "lit"; @@ -33,6 +39,9 @@ export class AppElement extends LitElement { @state() showHelp = false; @state() loading = true; + @state() selection?: SceneObject; + + #hierarchy = createRef(); #params = new StudioURLParams(); #canvasProvider: Ref = createRef(); @@ -72,6 +81,20 @@ export class AppElement extends LitElement { }); } + @on("entity-select") + onEntitySelected({ detail: id }: EntitySelectEvent): void { + if (!id) { + this.selection = undefined; + } else { + this.selection = this.#hierarchy.value?.entityMap.get(id); + } + } + + attachIkRig(): void { + if (this.selection) + studio.attachIkRig(this.selection.id); + } + toggleHelp(): void { this.showHelp = !this.showHelp; } @@ -102,6 +125,13 @@ export class AppElement extends LitElement { > Save + + Attach IK Rig + ` : nothing} ${this.#params.mode === StudioMode.Editor ? html` - - - + + + ` : nothing} diff --git a/studio-ui/src/lib/button.element.scss b/studio-ui/src/lib/button.element.scss index 2939af91..a171b451 100644 --- a/studio-ui/src/lib/button.element.scss +++ b/studio-ui/src/lib/button.element.scss @@ -31,6 +31,12 @@ color: #FFFF; } +:host(.disabled), +:host(.disabled:hover) { + background: #0003; + color: #FFF6; +} + .icon { fill: currentColor; } diff --git a/studio-ui/src/lib/button.element.ts b/studio-ui/src/lib/button.element.ts index a50ec94d..73d6f5bf 100644 --- a/studio-ui/src/lib/button.element.ts +++ b/studio-ui/src/lib/button.element.ts @@ -1,5 +1,5 @@ import { on } from "@storyteller/framework"; -import { LitElement, html, nothing, unsafeCSS } from "lit"; +import { LitElement, type PropertyValues, html, nothing, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; import styles from "./button.element.scss?inline"; @@ -11,10 +11,21 @@ export class ButtonElement extends LitElement { static override styles = unsafeCSS(styles); @property() icon?: string; + @property({ type: Boolean }) disabled = false; override role = "button"; override tabIndex = 0; + protected override willUpdate(changes: PropertyValues): void { + if (changes.has("disabled")) { + this.ariaDisabled = this.disabled ? "true" : null; + this.tabIndex = this.disabled ? -1 : 0; + this.classList.toggle("disabled", this.disabled); + } + + super.willUpdate(changes); + } + @on("keydown") onKeyDown(event: KeyboardEvent) { if (/^( |Enter)$/.test(event.key)) { diff --git a/studio-ui/src/lib/icon.element.ts b/studio-ui/src/lib/icon.element.ts index 0f10b2ff..27a89681 100644 --- a/studio-ui/src/lib/icon.element.ts +++ b/studio-ui/src/lib/icon.element.ts @@ -28,6 +28,7 @@ import fasCheck from "@fortawesome/fontawesome-pro/svgs/solid/check.svg?raw"; import fasXmark from "@fortawesome/fontawesome-pro/svgs/solid/xmark.svg?raw"; import fasCircleCheck from "@fortawesome/fontawesome-pro/svgs/solid/circle-check.svg?raw"; import fasCircleXmark from "@fortawesome/fontawesome-pro/svgs/solid/circle-xmark.svg?raw"; +import farShareNodes from "@fortawesome/fontawesome-pro/svgs/regular/share-nodes.svg?raw"; import styles from "./icon.element.scss?inline"; @@ -74,6 +75,7 @@ function renderSvg(iconId: string) { "question": () => fasQuestion, "rotate": () => farRotate, "scale": () => farScale, + "share-nodes": () => farShareNodes, "skeleton": () => farSkeleton, "up-down-left-right": () => farUpDownLeftRight, "video": () => farVideo, diff --git a/studio-web/src/lib/scene-hierarchy.element.ts b/studio-web/src/lib/scene-hierarchy.element.ts index 7b14c1a4..5d06e055 100644 --- a/studio-web/src/lib/scene-hierarchy.element.ts +++ b/studio-web/src/lib/scene-hierarchy.element.ts @@ -14,7 +14,7 @@ import styles from "./scene-hierarchy.element.scss?inline"; import "@storyteller/studio-ui/tree"; -interface SceneObject extends Omit { +export interface SceneObject extends Omit { icon?: string; children: SceneObject[]; } diff --git a/studio/src/anim/ik.rs b/studio/src/anim/ik.rs new file mode 100644 index 00000000..99081602 --- /dev/null +++ b/studio/src/anim/ik.rs @@ -0,0 +1,320 @@ +use bevy::{pbr::NotShadowCaster, prelude::*, render::view::RenderLayers}; +use bevy_mod_picking::prelude::*; + +use crate::{ + design_system, + interaction::{gizmos::GizmoMaterial, NoSelectionOutline, Selectable}, + math::geo, +}; + +pub const IK_RIG_RENDER_LAYER: u8 = 1; + +pub struct IkPlugin; + +impl Plugin for IkPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Update, setup_ik_rig.map(bevy::utils::error)); + + #[cfg(feature = "wasm")] + app.add_systems(Update, wasm::insert_ik_component); + } +} + +#[derive(Component)] +pub struct IkRig; + +#[derive(Component)] +pub struct IkRigCamera; + +#[derive(Component, Clone, Copy)] +struct IkBoneChain { + root: Entity, + twist_1: Option, + mid: Entity, + twist_2: Option, + end: Entity, + len_upper: f32, + len_lower: f32, +} + +#[derive(Component)] +pub struct IkPoleTarget; + +#[derive(Component)] +pub struct CopyXformTarget(pub Entity); + +#[derive(Default)] +struct IkBoneChainBuilder { + root: Option, + twist_1: Option, + mid: Option, + twist_2: Option, + end: Option, +} + +impl IkBoneChain { + fn build() -> IkBoneChainBuilder { + IkBoneChainBuilder::default() + } +} + +impl IkBoneChainBuilder { + fn set_root(&mut self, ent: Entity) { + self.root = Some(ent); + } + fn set_twist_1(&mut self, ent: Entity) { + self.twist_1 = Some(ent); + } + fn set_mid(&mut self, ent: Entity) { + self.mid = Some(ent); + } + fn set_twist_2(&mut self, ent: Entity) { + self.twist_2 = Some(ent); + } + fn set_end(&mut self, ent: Entity) { + self.end = Some(ent); + } + fn try_finish(self, q_xforms: &Query<&GlobalTransform>) -> Result { + let root = self.root.ok_or_else(|| "Root bone missing!".to_string())?; + let mid = self.mid.ok_or_else(|| "Mid bone missing!".to_string())?; + let end = self.end.ok_or_else(|| "End bone missing!".to_string())?; + + let root_xform = q_xforms.get(root).map_err(|err| format!("{err}"))?; + let mid_xform = q_xforms.get(mid).map_err(|err| format!("{err}"))?; + let end_xform = q_xforms.get(end).map_err(|err| format!("{err}"))?; + + let len_upper = Vec3::distance(root_xform.translation(), mid_xform.translation()); + let len_lower = Vec3::distance(mid_xform.translation(), end_xform.translation()); + + Ok(IkBoneChain { + root, + twist_1: self.twist_1, + mid, + twist_2: self.twist_2, + end, + len_upper, + len_lower, + }) + } +} + +fn setup_ik_rig( + mut cmd: Commands, + mut ra_meshes: ResMut>, + mut ra_mats: ResMut>, + q_target: Query>, + q_children: Query<&Children>, + q_names: Query<&Name>, + q_world_xforms: Query<&GlobalTransform>, +) -> Result<(), String> { + let Ok(target) = q_target.get_single() else { + return Ok(()); + }; + + let root_xform = q_world_xforms.get(target).map_err(|err| format!("{err}"))?; + + let mut leg_chain_l = IkBoneChain::build(); + let mut leg_chain_r = IkBoneChain::build(); + + let mut arm_chain_l = IkBoneChain::build(); + let mut arm_chain_r = IkBoneChain::build(); + + let mut spine = None; + let mut spine1 = None; + + for ent in q_children.iter_descendants(target) { + let Ok(name) = q_names.get(ent) else { + continue; + }; + + match name.as_str() { + // Spine entities + "DEF-spine" => spine = Some(ent), + "DEF-spine.001" => spine1 = Some(ent), + // Left Leg + "DEF-thigh.L" => leg_chain_l.set_root(ent), + "DEF-thigh.L.001" => leg_chain_l.set_twist_1(ent), + "DEF-shin.L" => leg_chain_l.set_mid(ent), + "DEF-shin.L.001" => leg_chain_l.set_twist_2(ent), + "DEF-foot.L" => leg_chain_l.set_end(ent), + // Right Leg + "DEF-thigh.R" => leg_chain_r.set_root(ent), + "DEF-thigh.R.001" => leg_chain_r.set_twist_1(ent), + "DEF-shin.R" => leg_chain_r.set_mid(ent), + "DEF-shin.R.001" => leg_chain_r.set_twist_2(ent), + "DEF-foot.R" => leg_chain_r.set_end(ent), + // Left Arm + "DEF-upper_arm.L" => arm_chain_l.set_root(ent), + "DEF-upper_arm.L.001" => arm_chain_l.set_twist_1(ent), + "DEF-forearm.L" => arm_chain_l.set_mid(ent), + "DEF-forearm.L.001" => arm_chain_l.set_twist_2(ent), + "DEF-hand.L" => arm_chain_l.set_end(ent), + // Right Arm + "DEF-upper_arm.R" => arm_chain_r.set_root(ent), + "DEF-upper_arm.R.001" => arm_chain_r.set_twist_1(ent), + "DEF-forearm.R" => arm_chain_r.set_mid(ent), + "DEF-forearm.R.001" => arm_chain_r.set_twist_2(ent), + "DEF-hand.R" => arm_chain_r.set_end(ent), + _ => {} + } + } + + if let Some(spine) = spine { + if let Some(spine1) = spine1 { + let spine_name = q_names.get(spine).unwrap(); + let spine_xform = q_world_xforms.get(spine).unwrap(); + let spine1_xform = q_world_xforms.get(spine1).unwrap(); + + cmd.entity(target).with_children(|builder| { + builder + .spawn(( + Selectable, + CopyXformTarget(spine), + SpatialBundle { + transform: spine_xform.reparented_to(root_xform), + ..default() + }, + )) + .with_children(|builder| { + builder.spawn(( + Name::new(format!("TGT_{spine_name}")), + NotShadowCaster, + NoSelectionOutline, + RenderLayers::layer(IK_RIG_RENDER_LAYER), + MaterialMeshBundle { + transform: spine1_xform.reparented_to(spine_xform), + mesh: ra_meshes.add(Mesh::from(shape::Cube { size: 0.25 })), + material: ra_mats.add(GizmoMaterial { + color: design_system::Color::YELLOW.with_a(0.125), + blend_mode: AlphaMode::Blend, + }), + ..default() + }, + PickableBundle::default(), + On::>::run(on_ik_gizmo_hover), + On::>::run(on_ik_gizmo_unhover), + )); + }); + }); + } + } + + for (color, bone_chain) in [ + (design_system::Color::RED, leg_chain_l), + (design_system::Color::BLUE, leg_chain_r), + (design_system::Color::RED, arm_chain_l), + (design_system::Color::BLUE, arm_chain_r), + ] { + let bone_chain = bone_chain.try_finish(&q_world_xforms)?; + let end_xform = q_world_xforms.get(bone_chain.end).unwrap(); + let mid_xform = q_world_xforms.get(bone_chain.mid).unwrap(); + + let end_name = q_names.get(bone_chain.end).unwrap(); + let mid_name = q_names.get(bone_chain.mid).unwrap(); + + cmd.entity(target).with_children(|builder| { + builder.spawn(( + bone_chain, + Name::new(format!("IK_{end_name}")), + NotShadowCaster, + NoSelectionOutline, + RenderLayers::layer(IK_RIG_RENDER_LAYER), + MaterialMeshBundle { + transform: end_xform.reparented_to(root_xform), + mesh: ra_meshes.add(Mesh::from(shape::Cube { size: 0.1 })), + material: ra_mats.add(GizmoMaterial { + color: color.with_a(0.4), + blend_mode: AlphaMode::Blend, + }), + ..default() + }, + PickableBundle::default(), + Selectable, + On::>::run(on_ik_gizmo_hover), + On::>::run(on_ik_gizmo_unhover), + )); + + builder + .spawn(( + Selectable, + IkPoleTarget, + SpatialBundle { + transform: mid_xform.reparented_to(root_xform), + ..default() + }, + )) + .with_children(|builder| { + let transform = Transform::from_xyz(0., 0., -0.1).with_rotation( + Quat::from_axis_angle(Vec3::NEG_X, std::f32::consts::FRAC_PI_2), + ); + + builder.spawn(( + Name::new(format!("IK_{mid_name}")), + NotShadowCaster, + NoSelectionOutline, + RenderLayers::layer(IK_RIG_RENDER_LAYER), + MaterialMeshBundle { + transform, + mesh: ra_meshes.add(Mesh::from(geo::Cone { + radius: 0.05, + length: 0.15, + resolution: 12, + })), + material: ra_mats.add(GizmoMaterial { + color: color.with_a(0.4), + blend_mode: AlphaMode::Blend, + }), + ..default() + }, + PickableBundle::default(), + On::>::run(on_ik_gizmo_hover), + On::>::run(on_ik_gizmo_unhover), + )); + }); + }); + } + + Ok(()) +} + +fn on_ik_gizmo_hover( + ev: Listener>, + mut ra_mats: ResMut>, + q_target: Query<&Handle>, +) { + let mat = ra_mats.get_mut(q_target.get(ev.target).unwrap()).unwrap(); + mat.color *= 1.5; +} + +fn on_ik_gizmo_unhover( + ev: Listener>, + mut ra_mats: ResMut>, + q_target: Query<&Handle>, +) { + let mat = ra_mats.get_mut(q_target.get(ev.target).unwrap()).unwrap(); + mat.color *= 2. / 3.; +} + +#[cfg(feature = "wasm")] +mod wasm { + use bevy::prelude::*; + use wasm_bindgen::prelude::*; + + use crate::wasm::{FromStr, StaticBuffer, WasmMessageBuffer}; + + use super::IkRig; + + static ATTACH_IK_RIG: WasmMessageBuffer = WasmMessageBuffer::new(); + + #[wasm_bindgen(js_name = attachIkRig)] + pub fn attach_ik_rig(entity: &str) { + let entity = Entity::from_str(entity).unwrap(); + ATTACH_IK_RIG.write_message(entity); + } + + pub(super) fn insert_ik_component(mut cmd: Commands) { + if let Some(entity) = ATTACH_IK_RIG.take_message() { + cmd.entity(entity).insert(IkRig); + } + } +} diff --git a/studio/src/anim/mod.rs b/studio/src/anim/mod.rs index ce98564c..ecf52534 100644 --- a/studio/src/anim/mod.rs +++ b/studio/src/anim/mod.rs @@ -10,6 +10,7 @@ use crate::{hierarchy, scene::SceneElement}; pub use retargeting::*; mod camera; +pub mod ik; mod retargeting; pub struct AnimPlugin; @@ -31,7 +32,11 @@ impl Plugin for AnimPlugin { app.add_systems(Update, process_skinned_meshes); app.insert_resource(GlobalSeekTime(0.)); - app.add_plugins((retargeting::RetargetingPlugin, camera::CameraAnimPlugin)); + app.add_plugins(( + retargeting::RetargetingPlugin, + camera::CameraAnimPlugin, + ik::IkPlugin, + )); #[cfg(feature = "wasm")] app.add_systems(Update, wasm::update_global_seek_time); diff --git a/studio/src/design_system.rs b/studio/src/design_system.rs index 0f95db47..f8142ffd 100644 --- a/studio/src/design_system.rs +++ b/studio/src/design_system.rs @@ -12,4 +12,8 @@ impl Color { // original: rgb( 13, 110, 253) // brighter: rgb( 47, 131, 255) pub const BLUE: BevyColor = BevyColor::rgb(0.184314, 0.513724, 1.000000); + + // "Lighten" blend of GREEN + RED + // rgb(255, 187, 100) + pub const YELLOW: BevyColor = BevyColor::rgb(1.000000, 0.733333, 0.392157); } diff --git a/studio/src/inspector/wasm.rs b/studio/src/inspector/wasm.rs index 286619a9..062a56bd 100644 --- a/studio/src/inspector/wasm.rs +++ b/studio/src/inspector/wasm.rs @@ -131,6 +131,7 @@ pub(super) fn notify_entity_spawn_changes( mut l_missing_parents: Local>, q_window: Query<&Window, With>, q_inspectables: Query<(Entity, &SceneElement)>, + q_element_changes: Query>, q_others: Query>, q_names: Query<&Name>, q_parents: Query<&Children>, @@ -146,6 +147,7 @@ pub(super) fn notify_entity_spawn_changes( ent, type_, &q_inspectables, + &q_element_changes, &q_names, &q_parents, &q_children, @@ -166,6 +168,7 @@ pub(super) fn notify_entity_spawn_changes( ent, SceneElement::Generic, &q_inspectables, + &q_element_changes, &q_names, &q_parents, &q_children, @@ -204,11 +207,12 @@ fn build_scene_object( ent: Entity, type_: SceneElement, q_inspectables: &Query<(Entity, &SceneElement)>, + q_element_changes: &Query>, q_names: &Query<&Name>, q_parents: &Query<&Children>, q_children: &Query<&Parent>, ) -> Option { - if notified.contains(&ent) { + if notified.contains(&ent) && !q_element_changes.contains(ent) { return None; } diff --git a/studio/src/interaction/gizmos/camera.rs b/studio/src/interaction/gizmos/camera.rs index 4b6fde67..e7027e65 100644 --- a/studio/src/interaction/gizmos/camera.rs +++ b/studio/src/interaction/gizmos/camera.rs @@ -5,7 +5,10 @@ use bevy::{ }; use space_editor::space_editor_ui::NotShowCamera; -use crate::MainCamera; +use crate::{ + anim::ik::{IkRigCamera, IK_RIG_RENDER_LAYER}, + MainCamera, +}; use super::GIZMO_RENDER_LAYER; @@ -19,6 +22,30 @@ pub(super) fn spawn_gizmo_camera( let ent = q_main_camera.single(); cmd.entity(ent).with_children(|cmd| { + // TODO: This doesn't really belong here, but the render pipeline seems + // to break if we don't spawn these cameras in the correct order. + // We should probably add a dedicated module/plugin for setting up + // the editor cameras. + cmd.spawn(( + Name::new("IK Rig Camera"), + IkRigCamera, + Camera3dBundle { + camera_3d: Camera3d { + clear_color: ClearColorConfig::None, + ..default() + }, + camera: Camera { + hdr: true, + order: 1, + ..default() + }, + ..default() + }, + RenderLayers::layer(IK_RIG_RENDER_LAYER), + Fxaa::default(), + NotShowCamera, + )); + cmd.spawn(( Name::new("Gizmo Camera"), GizmoCamera, @@ -29,7 +56,7 @@ pub(super) fn spawn_gizmo_camera( }, camera: Camera { hdr: true, - order: 1, + order: 2, ..default() }, ..default() @@ -41,17 +68,18 @@ pub(super) fn spawn_gizmo_camera( }); } -pub(super) fn sync_gizmo_and_main_cameras( - q_main_camera: Query<&Camera, (With, Without)>, - mut q_gizmo_camera: Query<&mut Camera, (With, Without)>, +pub(super) fn sync_main_and_aux_cameras( + q_main_camera: Query<(Entity, &Camera), With>, + mut q_other_cameras: Query<&mut Camera, Without>, + q_children: Query<&Children>, ) { - let Ok(main_cam) = q_main_camera.get_single() else { - return; - }; - - let Ok(mut gizmo_cam) = q_gizmo_camera.get_single_mut() else { + let Ok((main_cam_ent, main_cam)) = q_main_camera.get_single() else { return; }; - gizmo_cam.viewport = main_cam.viewport.clone(); + for child in q_children.iter_descendants(main_cam_ent) { + if let Ok(mut cam) = q_other_cameras.get_mut(child) { + cam.viewport = main_cam.viewport.clone(); + } + } } diff --git a/studio/src/interaction/gizmos/mod.rs b/studio/src/interaction/gizmos/mod.rs index 5ed674d1..9abedf60 100644 --- a/studio/src/interaction/gizmos/mod.rs +++ b/studio/src/interaction/gizmos/mod.rs @@ -6,12 +6,11 @@ use self::{ camera::{spawn_gizmo_camera, GizmoCamera}, combo::spawn_combo_translation_rotation_gizmo, common::*, - material::GizmoMaterial, resources::{init_resources, GizmoMats, GizmoMeshes}, rotation::{spawn_rotation_gizmo, update_ss_ring_rotation}, translation::spawn_translation_gizmo, }; -pub use common::TransformGizmo; +pub use self::{common::TransformGizmo, material::GizmoMaterial}; use super::{Selection, TransformMode, TransformType}; @@ -23,7 +22,7 @@ mod resources; mod rotation; mod translation; -const GIZMO_RENDER_LAYER: u8 = 1; +pub const GIZMO_RENDER_LAYER: u8 = 2; /// (sin|cos)(pi / 4) const CIRC_45: f32 = 0.70710677; @@ -67,7 +66,7 @@ impl Plugin for GizmoPlugin { ); //sync camera viewports - app.add_systems(Update, camera::sync_gizmo_and_main_cameras); + app.add_systems(Update, camera::sync_main_and_aux_cameras); app.add_systems( PostUpdate, diff --git a/studio/src/interaction/selection.rs b/studio/src/interaction/selection.rs index 3c42ae95..3e079350 100644 --- a/studio/src/interaction/selection.rs +++ b/studio/src/interaction/selection.rs @@ -61,6 +61,9 @@ pub struct Selected; #[derive(Resource, Deref, DerefMut, Debug)] pub struct Selection(Entity); +#[derive(Component)] +pub struct NoSelectionOutline; + fn sync_editor_selected( mut cmd: Commands, q_new_selected: Query, Without)>, @@ -188,6 +191,7 @@ fn draw_selection_outline( ( With>, Without, + Without, // FIXME: It shouldn't be necessary to filter out SkinnedMesh here Without, ), diff --git a/studio/src/scene.rs b/studio/src/scene.rs index 9cd07dfc..06d5f340 100644 --- a/studio/src/scene.rs +++ b/studio/src/scene.rs @@ -395,17 +395,8 @@ fn tag_scene_elements( mesh_entities: Query>>, ) { for (e, children) in q_named_non_primitives.iter() { - let mut has_mesh = false; - for child in children { - if mesh_entities.contains(*child) { - has_mesh = true; - break; - } - } - if has_mesh { + if children.iter().any(|child| mesh_entities.contains(*child)) { cmd.entity(e).insert(SceneElement::Mesh); - } else { - cmd.entity(e).insert(SceneElement::Generic); } } }