diff --git a/studio-frontend/src/app/anim-editor.element.ts b/studio-frontend/src/app/anim-editor.element.ts index e790518..f0729cf 100644 --- a/studio-frontend/src/app/anim-editor.element.ts +++ b/studio-frontend/src/app/anim-editor.element.ts @@ -149,7 +149,8 @@ export class AnimEditorElement extends LitElement let min = Infinity let max = -Infinity; for (const keyframe of Object.keys(this.keyframes)) { - let time = this.keyframes[keyframe].time; + const current = this.keyframes[keyframe]; + let time = current.time; if (typeof time != "number") { continue; } @@ -159,6 +160,12 @@ export class AnimEditorElement extends LitElement if (time < min) { min = time; } + if ("duration" in current) { + const end_time = time + current.duration; + if (end_time > max) { + max = end_time + } + } } this.clipStart = Math.max(min, 0.); this.clipEnd = Math.max(max, min); @@ -210,13 +217,20 @@ export class AnimEditorElement extends LitElement let closest_keyframe = -1 * Infinity; for (const keyframe of Object.keys(this.keyframes)) { - const time = this.keyframes[keyframe]?.time; + const current = this.keyframes[keyframe]; + const time = current?.time; if (!time || typeof time != "number") { continue; } if (time < pos && time > closest_keyframe) { closest_keyframe = time; } + if ("duration" in current) { + const end_time = time + current.duration; + if (end_time < pos && end_time > closest_keyframe) { + closest_keyframe = end_time; + } + } } if (closest_keyframe < 0) { @@ -233,13 +247,20 @@ export class AnimEditorElement extends LitElement let closest_keyframe = Infinity; for (const keyframe of Object.keys(this.keyframes)) { - const time = this.keyframes[keyframe]?.time; + const current = this.keyframes[keyframe]; + const time = current?.time; if (!time || typeof time != "number") { continue; } if (time > pos && time < closest_keyframe) { closest_keyframe = time; } + if ("duration" in current) { + const end_time = time + current.duration; + if (end_time > pos && end_time < closest_keyframe) { + closest_keyframe = end_time; + } + } } if (closest_keyframe > this.clipEnd) { diff --git a/studio-frontend/src/app/anim-timeline.element.scss b/studio-frontend/src/app/anim-timeline.element.scss index f530c54..77f84e8 100644 --- a/studio-frontend/src/app/anim-timeline.element.scss +++ b/studio-frontend/src/app/anim-timeline.element.scss @@ -136,3 +136,27 @@ background: #0FF; // TODO } } + +.clip { + position: absolute; + transform: translateX(-4.5px); + --clip-name: ""; + height: 12px; + + &::before { + content: var(--clip-name); + display: flex; + width: 100%; + height: 12px; + background: #AAA; + color: black; + font-size: 10px; + font-weight: bold; + justify-content: center; + align-items: center; + } + + &.selected::before { + background: #0FF; // TODO + } +} diff --git a/studio-frontend/src/app/anim-timeline.element.ts b/studio-frontend/src/app/anim-timeline.element.ts index 1b0683f..64f2d4f 100644 --- a/studio-frontend/src/app/anim-timeline.element.ts +++ b/studio-frontend/src/app/anim-timeline.element.ts @@ -406,23 +406,50 @@ export class AnimTimelineElement extends LitElement { ([id]) => id, ([id, keyframe]) => { let row = this.track_animation_target_ordered[keyframe.target]; - return html` -
this.onKeyframeDrag(id, event), - })} - @click=${() => this.selectKeyframe(id)} - >
- `; + if ("clip" in keyframe) { + let clip = keyframe.clip; + let duration = keyframe.duration; + let time = keyframe.time; + + return html` +
this.onKeyframeDrag(id, event), + })} + @click=${() => this.selectKeyframe(id)} + > +
+ `; + } else { + return html` +
this.onKeyframeDrag(id, event), + })} + @click=${() => this.selectKeyframe(id)} + >
+ `; + } } )} diff --git a/studio-frontend/src/app/app.element.scss b/studio-frontend/src/app/app.element.scss index 3f82dac..3c6a0d0 100644 --- a/studio-frontend/src/app/app.element.scss +++ b/studio-frontend/src/app/app.element.scss @@ -9,6 +9,12 @@ $hierarchy-width: 290px; --timeline-height: max-content; } +.debug-mode { + .studio-scene { + z-index: 100; + } +} + .wrapper { position: fixed; top: 0; diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 17c336d..45b04fa 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -56,6 +56,8 @@ export class AppElement extends LitElement { @state() resizingPanel = false; @state() inspectorWidth = 320; + @state() debugMode = false; + @state() sidebars: Array<{ title: string, name: string}> = [{title: 'Hierarchy', name: 'hierarchy'}]; @state() currentSidebar : string = 'hierarchy'; @@ -103,6 +105,11 @@ export class AppElement extends LitElement { }); } + @on("window:debug-mode-toggled") + onDebugModeToggled({detail: { debugModeActive }}: studio.DebugModeToggled ): void { + this.debugMode = debugModeActive; + } + onPanelResizeStart(): void { this.resizingPanel = true; } @@ -133,7 +140,7 @@ export class AppElement extends LitElement { protected override render() { return html` - + diff --git a/studio-web/src/lib/media-library-dialog.element.ts b/studio-web/src/lib/media-library-dialog.element.ts index 4c46841..1f741e1 100644 --- a/studio-web/src/lib/media-library-dialog.element.ts +++ b/studio-web/src/lib/media-library-dialog.element.ts @@ -147,6 +147,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement { MediaFileType.Glb, MediaFileType.Gltf, MediaFileType.ScnRon, + MediaFileType.Mocap ], }); @@ -198,11 +199,11 @@ export class MediaLibraryDialogElement extends DialogBaseElement { this.#transientFileInput = document.createElement("input"); this.#transientFileInput.type = "file"; this.#transientFileInput.accept = match (this._activeCategory.mediaClass, { - [MediaFileClass.Animation]: () => ".bvh,.fbx,.gltf", + [MediaFileClass.Animation]: () => ".bvh,.fbx,.gltf,.glb", [MediaFileClass.Audio]: () => ".wav,.mp3", - [MediaFileClass.Character]: () => ".fbx,.gltf", - [MediaFileClass.Prop]: () => ".fbx,.gltf", - [MediaFileClass.Scene]: () => ".fbx,.gltf,.scn.ron", + [MediaFileClass.Character]: () => ".fbx,.gltf,.glb", + [MediaFileClass.Prop]: () => ".fbx,.gltf,.glb", + [MediaFileClass.Scene]: () => ".fbx,.gltf,.glb,.scn.ron", _: () => "", }); this.#transientFileInput.multiple = false; @@ -267,8 +268,48 @@ export class MediaLibraryDialogElement extends DialogBaseElement { } #addAnimation(): void { - // TODO: Make this actually work - console.warn("Adding animations not yet implemented!"); + const token = this._selected[0]; + if (!this._mediaFiles) { + console.error("Expected `_mediaFiles` map to be present when spawning assets"); + this.dispatchEvent(new CustomEvent("close")); + return; + } + + const mediaFile = this._mediaFiles.get(token); + if (!mediaFile) { + console.error(`Attempted to spawn an invalid token: "${token}"`); + this.dispatchEvent(new CustomEvent("close")); + return; + } + + const ext = mediaFile.extension; + if (!ext) { + console.error("Expected media file to have a non-null `extension` field"); + this.dispatchEvent(new CustomEvent("close")); + return; + } + + const asset = { + path: `remote://${token}${ext}`, + name: mediaFile.displayName, + }; + + const skeletonSelected = this._sceneData.selectedObject?.["id"]; + if (!skeletonSelected) { + console.error(`Attempted to an animation without a selected skeleton`); + this.dispatchEvent(new CustomEvent("close")); + return; + } + + let retargetingType = studio.ClipRetargeting.UseOrigin; + if (mediaFile.subtype === MediaFileSubtype.Mixamo) { + retargetingType = studio.ClipRetargeting.Mixamo; + } else if (mediaFile.subtype === MediaFileSubtype.MocapNet) { + retargetingType = studio.ClipRetargeting.MocapNet + } + + console.log(`Inserting Animation ${asset.name} for skeleton ${skeletonSelected}`) + studio.insertClip(skeletonSelected, asset.path, asset.name, retargetingType); this.dispatchEvent(new CustomEvent("close")); } diff --git a/studio/src/anim/timeline_animation.rs b/studio/src/anim/timeline_animation.rs index 97ae6f9..3b59ed4 100644 --- a/studio/src/anim/timeline_animation.rs +++ b/studio/src/anim/timeline_animation.rs @@ -4,7 +4,8 @@ use bevy::{prelude::*, utils::hashbrown::HashMap}; use bevy_tweening::{ lens::{TransformPositionLens, TransformRotationLens, TransformScaleLens}, - Animator, EaseMethod, Sequence, Targetable, Tracks, Tween, TweenCompleted, + Animator, BoxedTweenable, Delay, EaseMethod, Sequence, Targetable, Tracks, Tween, + TweenCompleted, }; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -16,6 +17,8 @@ use crate::{ scene::SceneElement, }; +use super::Skeleton; + pub struct TimelinePlugin; impl Plugin for TimelinePlugin { @@ -30,6 +33,8 @@ impl Plugin for TimelinePlugin { .editor_registry::() .register_type::() .editor_registry::() + .register_type::() + .editor_registry::() .register_type::() .register_type::() .add_event::() @@ -43,7 +48,10 @@ impl Plugin for TimelinePlugin { }) .add_event::(); - app.add_systems(First, generate_transform_animation); + app.add_systems( + First, + (generate_transform_animation, generate_clip_animation), + ); app.add_systems(PreUpdate, (process_timeline_events, update_tick).chain()); app.add_systems( @@ -52,10 +60,12 @@ impl Plugin for TimelinePlugin { process_auto_key.run_if(in_state(KeyingMode::Auto)), process_keyframe_events, timeline_bound_component_animator_system::, + timeline_bound_component_animator_system::, + update_timed_animation_clip_duration, ), ); - // app.add_systems(PostUpdate, generate_transform_animation); + app.add_systems(PostUpdate, retarget_timed_animation_clip); #[cfg(feature = "wasm")] { @@ -91,11 +101,24 @@ pub enum TimelineInteractionEvent { } #[allow(dead_code)] -#[derive(Event, Clone, Copy, Debug)] +#[derive(Event, Clone, Debug)] pub enum KeyframeInteractionEvent { - RecordKeyframe { target: Entity }, - MoveKeyframeTime { target: Entity, time: f32 }, - AddEntityTrack { target: Entity }, + RecordKeyframe { + target: Entity, + }, + MoveKeyframeTime { + target: Entity, + time: f32, + }, + AddEntityTrack { + target: Entity, + }, + InsertAnimationClip { + target: Entity, + clip_path: String, + display_name: String, + retargeting_type: ClipRetargeting, + }, } #[derive(Resource, Clone, Copy, Debug, Default, Reflect, Serialize, Deserialize)] @@ -170,16 +193,40 @@ impl Default for Keyframe { #[reflect(Component)] pub struct TimedAnimationClipAsset { clip: Handle, - duration: f32, speed: f32, } +#[derive(Component, Clone, Debug, PartialEq)] +pub struct TimedAnimationClipDuration(f32); + +#[derive(Component, Clone, Debug)] +enum RetargetedClipReference { + UseOrigin, + RenamedRoot { + retargeted: Handle, + }, + #[allow(dead_code)] + Mixamo { + retargeted: Handle, + }, + #[allow(dead_code)] + MocapNet { + retargeted: Handle, + }, +} + +#[derive(Component, Clone, Debug, Reflect, PartialEq, Default, Serialize, Deserialize)] +#[reflect(Component)] +#[cfg_attr(all(feature = "wasm"), wasm_bindgen::prelude::wasm_bindgen)] +pub enum ClipRetargeting { + #[default] + UseOrigin, + Mixamo, + MocapNet, +} + impl PartialOrd for TimedAnimationClipAsset { fn partial_cmp(&self, other: &Self) -> Option { - match self.duration.partial_cmp(&other.duration) { - Some(core::cmp::Ordering::Equal) => {} - ord => return ord, - } match self.speed.partial_cmp(&other.speed) { Some(core::cmp::Ordering::Equal) => {} ord => return ord, @@ -192,7 +239,6 @@ impl Default for TimedAnimationClipAsset { fn default() -> Self { Self { clip: Default::default(), - duration: Default::default(), speed: 1.0, } } @@ -303,6 +349,7 @@ fn process_keyframe_events( q_animation_targets: Query<(Entity, &Transform, &AnimationTarget)>, q_potential_targets: Query, Without)>, mut q_keyframes: Query<(Entity, &mut Keyframe)>, + asset_server: Res, ) { for event in e_timeline_event.read() { info!("Processing Keyframe Event {event:?}"); @@ -359,6 +406,34 @@ fn process_keyframe_events( warn!("Can't add animation target to entity"); } } + KeyframeInteractionEvent::InsertAnimationClip { + target, + clip_path, + display_name, + retargeting_type, + } => { + if let Ok((_target, transform, animation_target)) = q_animation_targets.get(*target) + { + commands.spawn(( + Name::new(display_name.to_owned()), + PrefabMarker, + SpatialBundle { + transform: *transform, + ..Default::default() + }, + Selectable, + Keyframe { + time: r_global_seek_time.time, + target: *animation_target, + }, + TimedAnimationClipAsset { + clip: asset_server.load(format!("{clip_path}#Animation0")), + speed: 1.0, + }, + retargeting_type.clone(), + )); + } + } } } } @@ -600,6 +675,280 @@ pub fn timeline_bound_component_animator_system( } } +#[derive(Component)] +struct TimedAnimationClipLens { + clip: Handle, + duration: f32, +} + +impl bevy_tweening::lens::Lens for TimedAnimationClipLens { + fn lerp(&mut self, target: &mut AnimationPlayer, ratio: f32) { + let inner_time = self.duration * ratio; + if target.animation_clip() != &self.clip { + target.play(self.clip.clone()); + target.pause(); + target.seek_to(inner_time); + } else { + target.seek_to(inner_time); + } + } +} + +fn generate_clip_animation( + mut commands: Commands, + keyframes: Query< + ( + &Keyframe, + &TimedAnimationClipAsset, + &TimedAnimationClipDuration, + &RetargetedClipReference, + Entity, + ), + Without, + >, + valid_targets: Query<( + Entity, + &AnimationTarget, + Option<&Name>, + Option<&SceneElement>, + &Skeleton, + )>, + mut e_timeline_event: EventReader, +) { + let mut playing = false; + for e in e_timeline_event.read() { + if matches!( + e, + TimelineInteractionEvent::Play | TimelineInteractionEvent::Seek(_) + ) { + playing = true; + continue; + } + } + if !playing { + return; + } + info!("Generating Animation From Keyframes"); + + let available_targets = valid_targets + .iter() + .map(|(e, a, name, element, _skeleton)| (*a, (e, name, element))) + .collect::>(); + + let keyframes = keyframes + .iter() + .into_group_map_by(|(keyframe, _, _, _, _)| keyframe.target); + + for (target, keyframes) in keyframes.into_iter() { + info!("Keyframes For Target {target:?}"); + let Some(target) = available_targets.get(&target) else { + info!("No valid target."); + continue; + }; + let (target, target_name, target_scene_element) = *target; + + let keyframes = keyframes.iter(); + + let keyframes = keyframes.sorted_by(|a, b| a.0.time.total_cmp(&b.0.time)); + + let (last, mut clip_timeline) = keyframes.fold( + ( + Option::<(f32, f32, Handle)>::None, + Vec::>::new(), + ), + |(last_clip, mut clip_timeline), (keyframe, asset, duration, reference, _entity)| { + let time = keyframe.time; + + let (last_start, last_duration, last_clip) = match last_clip { + Some((start, duration, clip)) => (start, duration, Some(clip)), + None => (0., 0., None), + }; + + let mut reached_now = false; + let mut clip_end = last_start + last_duration; + if clip_end > time { + reached_now = true; + clip_end = time; + } + + if let Some(last_clip) = last_clip { + let duration = clip_end - last_start; + + if duration > 0f32 { + clip_timeline.push( + Tween::new( + EaseMethod::Linear, + Duration::from_secs_f32(duration), + TimedAnimationClipLens { + clip: last_clip, + duration, + }, + ) + .into(), + ); + } + } + + if !reached_now { + let duration = time - clip_end; + if duration > 0f32 { + clip_timeline.push(Delay::new(Duration::from_secs_f32(duration)).into()); + } + } + + let duration = duration.0; + let clip = match reference { + RetargetedClipReference::UseOrigin => &asset.clip, + RetargetedClipReference::RenamedRoot { retargeted } => retargeted, + RetargetedClipReference::Mixamo { retargeted } => retargeted, + RetargetedClipReference::MocapNet { retargeted } => retargeted, + }; + + (Some((time, duration, clip.clone())), clip_timeline) + }, + ); + + if let Some((_, duration, clip)) = last { + if duration > 0f32 { + clip_timeline.push( + Tween::new( + EaseMethod::Linear, + Duration::from_secs_f32(duration), + TimedAnimationClipLens { clip, duration }, + ) + .into(), + ); + } + } + + if clip_timeline.is_empty() { + commands + .entity(target) + .remove::>() + .remove::(); + continue; + } + + let clip_timeline = Sequence::new(clip_timeline); + info!( + "Adding Animation Clip Track for {target_name:?} {target_scene_element:?} {target:?}" + ); + commands + .entity(target) + .insert((AnimationPlayer::default(), Animator::new(clip_timeline))); + } +} + +pub fn update_timed_animation_clip_duration( + mut commands: Commands, + query: Query<(Entity, &TimedAnimationClipAsset), Without>, + clips: Res>, +) { + for (entity, clip_asset) in &query { + if let Some(clip) = clips.get(&clip_asset.clip) { + let duration = clip.duration(); + commands + .entity(entity) + .insert(TimedAnimationClipDuration(duration)); + } + } +} + +fn retarget_timed_animation_clip( + mut commands: Commands, + clip_keys: Query< + ( + Entity, + &TimedAnimationClipAsset, + &ClipRetargeting, + &Keyframe, + ), + ( + With, + Without, + ), + >, + animation_targets: Query<(Entity, &AnimationTarget, &Name), With>, + mut clips: ResMut>, +) { + let targets = animation_targets + .iter() + .map(|(entity, target, name)| (*target, (entity, name))) + .collect::>(); + + for (entity, asset, retarget, keyframe) in &clip_keys { + info!("Added Animation: {asset:?} with retargeting {retarget:?}"); + let Some(clip) = clips.get(&asset.clip) else { + continue; + }; + info!("Animation exists"); + let Some(paths) = clip.field("paths") else { + continue; + }; + info!("Parsing Paths"); + let Some(paths): Option> = + HashMap::::from_reflect(paths) + else { + continue; + }; + + let Some((_target_entity, target_name)) = targets.get(&keyframe.target) else { + error!("Animation has no target: {keyframe:?}"); + continue; + }; + + match &retarget { + ClipRetargeting::UseOrigin => { + info!("Use Origin"); + if clip.compatible_with(target_name) { + info!("Animation Already Compatible"); + commands + .entity(entity) + .insert(RetargetedClipReference::UseOrigin); + } else { + let mut new_clip = AnimationClip::default(); + let first_path = paths.keys().next().cloned(); + let new_first_path = first_path.as_ref().map(|path| { + let mut new_path = path.clone(); + if let Some(root_name) = new_path.parts.first_mut() { + root_name.set(target_name.to_string()); + } + new_path + }); + for (path, bone_id) in paths.iter() { + let mut new_path = path.clone(); + if let Some(root_name) = new_path.parts.first_mut() { + root_name.set(target_name.to_string()); + } + + let Some(curves) = clip.get_curves(*bone_id) else { + continue; + }; + for curve in curves { + new_clip.add_curve_to_path(new_path.clone(), curve.clone()); + } + } + info!("Inserted Animation With Renamed Root - original {first_path:?} becomes {new_first_path:?}"); + let retargeted = clips.add(new_clip); + commands + .entity(entity) + .insert(RetargetedClipReference::RenamedRoot { retargeted }); + } + } + ClipRetargeting::Mixamo => { + commands + .entity(entity) + .insert(RetargetedClipReference::UseOrigin); + } + ClipRetargeting::MocapNet => { + commands + .entity(entity) + .insert(RetargetedClipReference::UseOrigin); + } + } + } +} + #[cfg(feature = "wasm")] mod wasm { use bevy::{prelude::*, utils::hashbrown::HashMap, window::PrimaryWindow}; @@ -613,8 +962,9 @@ mod wasm { }; use super::{ - AnimationTarget, EaseType, GlobalSeekTime, Keyframe, KeyframeInteractionEvent, KeyingMode, - TimedAnimationClipAsset, TimelineInteractionEvent, TransformKeyframe, + AnimationTarget, ClipRetargeting, EaseType, GlobalSeekTime, Keyframe, + KeyframeInteractionEvent, KeyingMode, TimedAnimationClipAsset, TimedAnimationClipDuration, + TimelineInteractionEvent, TransformKeyframe, }; static SEEK_TIME: WasmMessageBuffer = WasmMessageBuffer::new(); @@ -652,6 +1002,24 @@ mod wasm { ); } + #[wasm_bindgen(js_name = insertClip)] + pub fn insert_clip( + entity: String, + clip_path: String, + display_name: String, + retargeting_type: ClipRetargeting, + ) { + let Ok(target) = Entity::from_str(&entity) else { + return; + }; + KEYFRAME_EVENT.write_message(vec![KeyframeInteractionEvent::InsertAnimationClip { + target, + clip_path, + display_name, + retargeting_type, + }]); + } + #[wasm_bindgen(js_name = moveKeyframeTime)] pub fn move_keyframe_time(entity: String, time: f32) { let Ok(target) = Entity::from_str(&entity) else { @@ -746,10 +1114,10 @@ mod wasm { automatic: boolean } } - + declare global { export interface GlobalEventHandlersEventMap { - "seek-time-changed": CameraKeyframesEvent; + "seek-time-changed": CameraKeyframesEvent; "update-animation-targets": UpdateAnimationTargets; "update-keyframes": UpdateKeyframes; } @@ -780,8 +1148,8 @@ mod wasm { target: AnimationTarget, time: f32, clip: String, - duration: f32, speed: f32, + duration: f32, } #[derive(Serialize, Deserialize)] @@ -887,8 +1255,18 @@ mod wasm { Or<(Changed, Changed)>, >, q_clips: Query< - (Entity, &Keyframe, &TimedAnimationClipAsset), - Or<(Changed, Changed)>, + ( + Entity, + &Keyframe, + &TimedAnimationClipAsset, + &Name, + Option<&TimedAnimationClipDuration>, + ), + Or<( + Changed, + Changed, + Changed, + )>, >, removed: Query>, ) -> Result<(), String> { @@ -914,21 +1292,25 @@ mod wasm { Err(e) => Err(e), } }) - .chain(q_clips.iter().map(|(entity, keyframe, clip)| { - match serde_json::to_value(TimedAnimationClipInfo { - entity: format!("{entity:?}"), - target: keyframe.target, - time: keyframe.time, - clip: clip.clip.id().to_string(), - duration: clip.duration, - speed: clip.speed, - }) - .map_err(|e| format!("{e}")) - { - Ok(value) => Ok((format!("{entity:?}"), value)), - Err(e) => Err(e), - } - })) + .chain( + q_clips + .iter() + .map(|(entity, keyframe, clip, name, duration)| { + match serde_json::to_value(TimedAnimationClipInfo { + entity: format!("{entity:?}"), + target: keyframe.target, + time: keyframe.time, + clip: name.to_string(), + speed: clip.speed, + duration: duration.map(|v| v.0).unwrap_or_default(), + }) + .map_err(|e| format!("{e}")) + { + Ok(value) => Ok((format!("{entity:?}"), value)), + Err(e) => Err(e), + } + }), + ) .collect::, String>>()?; let removed = removed.iter().map(|v| format!("{v:?}")).collect::>(); diff --git a/studio/src/debug.rs b/studio/src/debug.rs index 117a1bd..b0e39d8 100644 --- a/studio/src/debug.rs +++ b/studio/src/debug.rs @@ -80,6 +80,14 @@ impl Plugin for DebugPlugin { Update, update_editor_ui_mode.run_if(resource_changed::()), ); + + #[cfg(feature = "wasm")] + { + app.add_systems( + PostUpdate, + wasm::notify_debug_mode_toggle.map(bevy::utils::error), + ); + } } } @@ -209,3 +217,45 @@ fn sync_editor_camera( cmd.entity(ent).insert(EditorCameraMarker); } } + +#[cfg(feature = "wasm")] +mod wasm { + use super::DebugMode; + use bevy::{prelude::*, window::PrimaryWindow}; + use serde_json::json; + use wasm_bindgen::prelude::*; + + #[wasm_bindgen(typescript_custom_section)] + const DEBUG_TYPES: &str = r#" + export interface DebugModeToggled extends CustomEvent { + type: "debug-mode-toggled", + detail: { + debugModeActive: boolean + } + } + + declare global { + export interface GlobalEventHandlersEventMap { + "debug-mode-toggled": DebugModeToggled + } + } + "#; + + pub fn notify_debug_mode_toggle( + q_window: Query<&Window, With>, + r_debug_mode: Res, + ) -> Result<(), String> { + if !r_debug_mode.is_changed() { + return Ok(()); + } + let Ok(window) = q_window.get_single() else { + return Ok(()); + }; + + crate::wasm::dispatch_to_js( + window, + "debug-mode-toggled", + &json!({ "debugModeActive": r_debug_mode.0 }), + ) + } +}