mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-17 00:13:17 +00:00
cba70a62ae
This commit implements a Sketchfab-style single-object viewer as a minimal proof-of-concept. ### `@storyteller/studio-web` This is a new package to house web components for interfacing with the `@storyteller/studio` WASM library. It currently includes a single component, `<sts-viewer />`, which is exported from `@storyteller/studio-web/viewer`. ### `react-frontend` This is a new application intended as a proof-of-concept and testbed for React interop with `@storyteller/studio-web`. Web components are currently a little bit fiddly to use in a React application. `react-frontend/src/app/studio-viewer.tsx` demonstrates how a React-idiomatic component can be written as a thin wrapper around a web component, and `react-frontend/src/app/app.tsx` demonstrates that wrapper component in use.
180 lines
5.3 KiB
Rust
180 lines
5.3 KiB
Rust
use bevy::prelude::*;
|
|
use bevy_mod_outline::{
|
|
AutoGenerateOutlineNormalsPlugin, OutlineBundle, OutlinePlugin, OutlineVolume,
|
|
};
|
|
use bevy_mod_picking::{prelude::Pointer, selection::Select, PickableBundle};
|
|
|
|
use crate::{
|
|
input::{self, Action, InputAction, Lifecycle},
|
|
scene::{SceneElement, SceneSpawnerSystem, SceneState},
|
|
};
|
|
|
|
pub struct SelectionPlugin;
|
|
impl Plugin for SelectionPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.add_plugins((OutlinePlugin, AutoGenerateOutlineNormalsPlugin))
|
|
.add_systems(Update, update_selection)
|
|
.add_systems(
|
|
PostUpdate,
|
|
draw_selection_outline.run_if(resource_changed_or_removed::<Selection>()),
|
|
)
|
|
.add_systems(
|
|
OnEnter(SceneState::Active),
|
|
(apply_deferred, tag_scene_elements)
|
|
.chain()
|
|
.after(SceneSpawnerSystem),
|
|
);
|
|
|
|
#[cfg(feature = "wasm")]
|
|
app.add_systems(
|
|
PostUpdate,
|
|
wasm::notify_entity_selection_change
|
|
.map(bevy::utils::error)
|
|
.run_if(resource_changed_or_removed::<Selection>()),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[derive(Component, Clone, Copy)]
|
|
pub struct Selectable;
|
|
|
|
#[derive(Component, Clone, Copy)]
|
|
pub struct Selected;
|
|
|
|
#[derive(Resource, Deref, DerefMut, Debug)]
|
|
pub struct Selection(Entity);
|
|
|
|
fn tag_scene_elements(
|
|
mut cmd: Commands,
|
|
q_scene_elements: Query<(Entity, &Children), (With<SceneElement>, Without<Selectable>)>,
|
|
q_primitives: Query<&Handle<Mesh>>,
|
|
) {
|
|
for (ent, children) in q_scene_elements.iter() {
|
|
cmd.entity(ent).insert(Selectable);
|
|
|
|
for &ent_child in children
|
|
.iter()
|
|
.filter(|child| q_primitives.contains(**child))
|
|
{
|
|
cmd.entity(ent_child).insert(PickableBundle::default());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_selection(
|
|
mut cmd: Commands,
|
|
// FIXME: These events aren't aware of our input actions, so we receive a
|
|
// selection event when LMB is released after orbiting the camera.
|
|
// Should drop bevy_mod_picking's Selection plugin/feature and just
|
|
// use our own conditional click listener.
|
|
mut ev_select: EventReader<Pointer<Select>>,
|
|
mut ev_input: EventReader<InputAction>,
|
|
q_parents: Query<&Parent>,
|
|
q_selectable: Query<&Selectable>,
|
|
q_selected: Query<Entity, With<Selected>>,
|
|
) {
|
|
if let Some(event) = ev_select.read().last() {
|
|
// Clear any existing `Selected` components
|
|
for ent in q_selected.iter() {
|
|
cmd.entity(ent).remove::<Selected>();
|
|
}
|
|
|
|
// Find the nearest Selectable parent of the target entity
|
|
let mut ent = event.target;
|
|
let ent_selected = loop {
|
|
if q_selectable.get(ent).is_ok() {
|
|
break Some(ent);
|
|
}
|
|
|
|
if let Ok(parent) = q_parents.get(ent) {
|
|
ent = parent.get();
|
|
} else {
|
|
break None;
|
|
}
|
|
};
|
|
|
|
if let Some(ent) = ent_selected {
|
|
cmd.insert_resource(Selection(ent));
|
|
cmd.entity(ent).insert(Selected);
|
|
} else {
|
|
cmd.remove_resource::<Selection>();
|
|
}
|
|
} else if ev_input.read().any(|action| {
|
|
matches!(
|
|
action,
|
|
InputAction(Lifecycle::Stop, Action::Selection(input::Selection::Clear))
|
|
)
|
|
}) {
|
|
cmd.remove_resource::<Selection>();
|
|
|
|
for ent in q_selected.iter() {
|
|
cmd.entity(ent).remove::<Selected>();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_selection_outline(
|
|
mut cmd: Commands,
|
|
q_selected: Query<&Children, With<Selected>>,
|
|
q_meshes: Query<Entity, (With<Handle<Mesh>>, Without<OutlineVolume>)>,
|
|
q_outlines: Query<Entity, With<OutlineVolume>>,
|
|
) {
|
|
// Remove existing outlines
|
|
for ent in q_outlines.iter() {
|
|
cmd.entity(ent).remove::<OutlineBundle>();
|
|
}
|
|
|
|
for children in q_selected.iter() {
|
|
for &child in children.iter() {
|
|
if q_meshes.contains(child) {
|
|
cmd.entity(child).insert(OutlineBundle {
|
|
outline: OutlineVolume {
|
|
visible: true,
|
|
width: 4.,
|
|
colour: Color::YELLOW,
|
|
},
|
|
..default()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "wasm")]
|
|
mod wasm {
|
|
use bevy::{
|
|
prelude::{Query, Res, With},
|
|
window::{PrimaryWindow, Window},
|
|
};
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
use super::Selection;
|
|
use crate::wasm;
|
|
|
|
#[wasm_bindgen(typescript_custom_section)]
|
|
const EVENT_DECLARATION: &str = r#"
|
|
/**
|
|
* An event indicating that an entity has been selected or that the selection
|
|
* has been cleared.
|
|
*/
|
|
export interface EntitySelectEvent extends CustomEvent {
|
|
/** The selected entity ID. If `null`, the selection has been cleared. */
|
|
detail: string | null;
|
|
}
|
|
|
|
declare global {
|
|
export interface HTMLElementEventMap {
|
|
"entity-select": EntitySelectEvent;
|
|
}
|
|
}
|
|
"#;
|
|
|
|
pub(super) fn notify_entity_selection_change(
|
|
r_selection: Option<Res<Selection>>,
|
|
q_window: Query<&Window, With<PrimaryWindow>>,
|
|
) -> Result<(), String> {
|
|
let entity = r_selection.map(|selection| **selection);
|
|
wasm::dispatch_to_js(q_window.single(), "entity-select", &entity)
|
|
}
|
|
}
|