diff --git a/studio-web/src/lib/inspector/entity-inspector.element.ts b/studio-web/src/lib/inspector/entity-inspector.element.ts index c5cd81c..fd20171 100644 --- a/studio-web/src/lib/inspector/entity-inspector.element.ts +++ b/studio-web/src/lib/inspector/entity-inspector.element.ts @@ -3,9 +3,10 @@ import { LitElement, type PropertyValues, html, unsafeCSS } from "lit"; import { customElement } from "lit/decorators.js"; import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "../scene-data-provider.element"; -import { type InspectorComponent } from "./inspector.types"; +import { isLinear, type InspectorComponent } from "./inspector.types"; import "../scene-data-provider.element"; +import "./material-inspector.element"; import "./name-inspector.element"; import "./transform-inspector.element"; @@ -48,13 +49,14 @@ export class EntityInspectorElement extends LitElement { }) .map(({ name, value }) => { switch (name) { - case "bevy_core::name::Name": + case "bevy_core::name::Name": { return html` `; - case "bevy_transform::components::transform::Transform": + } + case "bevy_transform::components::transform::Transform": { return html` `; - default: + } + case "bevy_pbr::pbr_material::StandardMaterial": { + const { colorSpace, baseColor } = isLinear(value.base_color) + ? { colorSpace: "linear", baseColor: value.base_color.RgbaLinear } + : { colorSpace: "srgb", baseColor: value.base_color.Rgba }; + + return html` + + `; + } + default: { return html`

Unknown Component: ${name}

${JSON.stringify({ name, value }, null, "   ")}
`; + } } }) } diff --git a/studio-web/src/lib/inspector/inspector.types.ts b/studio-web/src/lib/inspector/inspector.types.ts index 06e585c..e117a06 100644 --- a/studio-web/src/lib/inspector/inspector.types.ts +++ b/studio-web/src/lib/inspector/inspector.types.ts @@ -1,6 +1,7 @@ export type InspectorComponent = BevyReflect<"bevy_transform::components::transform::Transform", BevyTransform> | BevyReflect<"bevy_core::name::Name", BevyName> + | BevyReflect<"bevy_pbr::pbr_material::StandardMaterial", StandardMaterial> ; export interface BevyReflect { @@ -31,3 +32,31 @@ export interface Vec3 { y: number; z: number; } + +export interface StandardMaterial { + base_color: BevyColor; +} + +export type BevyColor + = Color_RgbaLinear + | Color_Rgba + ; + +export interface Color_RgbaLinear { + RgbaLinear: Rgba; +} + +export interface Color_Rgba { + Rgba: Rgba; +} + +export interface Rgba { + red: number; + green: number; + blue: number; + alpha: number; +} + +export function isLinear(color: BevyColor): color is Color_RgbaLinear { + return "RgbaLinear" in color; +} diff --git a/studio-web/src/lib/inspector/material-inspector.element.scss b/studio-web/src/lib/inspector/material-inspector.element.scss new file mode 100644 index 0000000..f0c516b --- /dev/null +++ b/studio-web/src/lib/inspector/material-inspector.element.scss @@ -0,0 +1,21 @@ +@use "sass:math"; + +:host { + color-scheme: dark; + font-family: "Fira Sans", sans-serif; +} + +*, *::before, *::after { + box-sizing: border-box; +} + +h4 { + font-weight: 500; + margin: 0 0 0.25rem; +} + +label { + color: #FFF9; + font-size: math.div(14, 16) * 1rem; + margin-block-end: 0.333rem; +} diff --git a/studio-web/src/lib/inspector/material-inspector.element.ts b/studio-web/src/lib/inspector/material-inspector.element.ts new file mode 100644 index 0000000..77ab979 --- /dev/null +++ b/studio-web/src/lib/inspector/material-inspector.element.ts @@ -0,0 +1,113 @@ +import * as studio from "@storyteller/studio"; +import { match } from "@storyteller/utility"; +import { LitElement, html, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { v4 as uuid } from "uuid"; + +import { Rgba } from "./inspector.types"; + +import styles from "./material-inspector.element.scss?inline"; + +@customElement("sts-material-inspector") +export class MaterialInspectorElement extends LitElement { + static override styles = unsafeCSS(styles); + + @property({ attribute: false }) + baseColor: Rgba = { red: 1, green: 1, blue: 1, alpha: 1 }; + + @property() + inputColorSpace: "linear"|"srgb" = "linear"; + + #baseColorId = uuid(); + + onBaseColorInput(event: InputEvent): void { + const hex = (event.target as HTMLInputElement).value; + const value = match (this.inputColorSpace, { + "linear": () => RgbaLinear.fromHexSrgb(hex, this.baseColor.alpha), + "srgb": () => Srgba.fromHex(hex, this.baseColor.alpha), + }); + + studio.updateComponentField({ + component: "bevy_pbr::pbr_material::StandardMaterial", + reflectPath: "base_color", + value, + }); + } + + protected override render() { + const [rgb, _] = match (this.inputColorSpace, { + "linear": () => RgbaLinear.toHexSrgb(this.baseColor), + "srgb": () => Srgba.toHex(this.baseColor), + }); + + return html` +

Material

+ + + `; + } +} + +const HEX_PATTERN = /#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?/i; +const HEX_BASE = 16; +const U8_MAX = 255; + +function channelToHex(gamma: number) { + return (channel: number): string => { + return Math + .round(Math.pow(channel, gamma) * U8_MAX) + .toString(HEX_BASE) + .padStart(2, "0"); + } +} + +function channelFromHex(gamma: number) { + return (channel: string): number => { + return Math.pow(parseInt(channel, HEX_BASE) / U8_MAX, gamma); + } +} + +namespace RgbaLinear { + export function toHexSrgb({ red, green, blue, alpha }: Rgba): [string, number] { + const hexRgb = [red, green, blue] + .map(channelToHex(1 / 2.2)) + .join(""); + + return [`#${hexRgb}`, alpha]; + } + + export function fromHexSrgb(hex: string, alpha = 1): Rgba { + const [red, green, blue] = hex + .match(HEX_PATTERN)! + .slice(1) + .map(channelFromHex(2.2)); + + return { red, green, blue, alpha }; + } +} + +namespace Srgba { + export function toHex({ red, green, blue, alpha }: Rgba): [string, number] { + const hexRgb = [red, green, blue] + .map(channelToHex(2.2)) + .join(""); + + return [`#${hexRgb}`, alpha]; + } + + export function fromHex(hex: string, alpha = 1): Rgba { + const [red, green, blue] = hex + .match(HEX_PATTERN)! + .slice(1) + .map(channelFromHex(1 / 2.2)); + + return { red, green, blue, alpha }; + } +} diff --git a/studio-web/src/lib/inspector/transform-inspector.element.ts b/studio-web/src/lib/inspector/transform-inspector.element.ts index 5d66c53..bb729ce 100644 --- a/studio-web/src/lib/inspector/transform-inspector.element.ts +++ b/studio-web/src/lib/inspector/transform-inspector.element.ts @@ -34,7 +34,7 @@ export class TransformInspectorElement extends LitElement { protected override render = () => html`

Transform

- Translation + Position >().unwrap(); + let handle = entity.get::>().ok_or_else(|| { + format!( + "Failed to get StandardMaterial for entity {:?}", + entity.id() + ) + })?; + let mut assets = world .get_resource_mut::>() - .unwrap(); - let material = assets.get_mut(handle).unwrap(); + .ok_or_else(|| { + "Failed to get `Assets` resource".to_string() + })?; - let reflect_field = material.reflect_path_mut(path).unwrap(); - (f)(reflect_field); + let material = assets.get_mut(handle).ok_or_else(|| { + format!("Failed to retrieve material from handle {handle:?}") + })?; + + let reflect_field = material + .reflect_path_mut(path) + .map_err(|err| format!("{err}"))?; + + (f)(reflect_field) }), ); } @@ -107,7 +123,12 @@ struct InspectGetters { getters: HashMap< String, Arc< - dyn Fn(&mut EntityWorldMut, &str, &mut UnsafeWorldCell, &dyn Fn(&mut dyn Reflect)) + dyn Fn( + &mut EntityWorldMut, + &str, + &mut UnsafeWorldCell, + &dyn Fn(&mut dyn Reflect) -> Result<(), String>, + ) -> Result<(), String> + Send + Sync, >, @@ -148,7 +169,7 @@ impl AppInspectable for App { Arc::new(|entity, path, _, f| { let mut component = entity.get_mut::().unwrap(); let reflect_field = component.reflect_path_mut(path).unwrap(); - (f)(reflect_field); + (f)(reflect_field) }), ); } @@ -223,94 +244,159 @@ impl Default for UpdateInspect { } } -fn update_field_by_ui(world: &mut World) { +#[derive(Serialize, Deserialize)] +struct Rgba { + red: f32, + green: f32, + blue: f32, + alpha: f32, +} + +fn update_field_by_ui(world: &mut World) -> Result<(), String> { unsafe { let mut unsafe_world = world.as_unsafe_world_cell(); let Some(msg) = CHANGE_FROM_UI.take_message() else { - return; + return Ok(()); }; + info!("Processing update: {msg:?}"); let Some(selection) = unsafe_world.world_mut().get_resource::() else { - return; + return Ok(()); }; let entity = **selection; let getters = unsafe_world.get_resource_mut::().unwrap(); + let getter = getters + .getters + .get(&msg.component) + .ok_or_else(|| format!("Failed to find getter for component: {}", msg.component))?; - let mut entity_mut = unsafe_world.world_mut().entity_mut(entity); + // TODO: This is pretty ugly + let target = 'find_target: { + if &msg.component[..] == "bevy_pbr::pbr_material::StandardMaterial" { + let mut q_handles = unsafe_world + .world_mut() + .query::<&Handle>(); - let Some(getter) = getters.getters.get(&msg.component) else { - error!("Failed to find getter for component: {}", msg.component); - return; + if q_handles.get(unsafe_world.world(), entity).is_ok() { + break 'find_target Some(entity); + } + + let mut q_children = unsafe_world.world_mut().query::<&Children>(); + let Ok(ent_children) = q_children.get(unsafe_world.world(), entity) else { + break 'find_target None; + }; + + break 'find_target ent_children + .iter() + .copied() + .find(|&child| q_handles.get(unsafe_world.world(), child).is_ok()); + } else { + break 'find_target Some(entity); + } }; + let Some(target) = target else { + return Err(format!( + "Failed to find target for component {}", + &msg.component + )); + }; + + info!("Applying to entity: {target:?}"); + + let mut entity_mut = unsafe_world.world_mut().entity_mut(target); + getter.as_ref()( &mut entity_mut, &msg.reflect_path, &mut unsafe_world, &|reflect_field| { + let fmt_err = |err: serde_json::Error| { + format!( + "Failed to parse value {} for path {}: {err}", + msg.value, &msg.reflect_path, + ) + }; + if let Some(val) = reflect_field.downcast_mut::() { - let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else { - error!("Failed to parse value: {}", msg.value); - return; - }; + let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; *val = msg_value; + Ok(()) } else if let Some(val) = reflect_field.downcast_mut::() { - let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else { - error!("Failed to parse value: {}", msg.value); - return; - }; + let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; *val = msg_value; + Ok(()) } else if let Some(val) = reflect_field.downcast_mut::() { - let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else { - error!("Failed to parse value: {}", msg.value); - return; - }; + let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; *val = msg_value; + Ok(()) } else if let Some(val) = reflect_field.downcast_mut::() { - let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else { - error!("Failed to parse value: {}", msg.value); - return; - }; + let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; *val = msg_value; + Ok(()) } else if let Some(val) = reflect_field.downcast_mut::() { - let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else { - error!("Failed to parse value: {}", msg.value); - return; - }; + let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; *val = msg_value; + Ok(()) } else if let Some(val) = reflect_field.downcast_mut::() { - let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else { - error!("Failed to parse value: {}", msg.value); - return; - }; + let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; *val = msg_value; + Ok(()) } else if let Some(val) = reflect_field.downcast_mut::() { - let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else { - error!("Failed to parse value: {}", msg.value); - return; - }; + let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; *val = msg_value; + Ok(()) } else if let Some(val) = reflect_field.downcast_mut::() { - *val = msg.value.as_str().unwrap().to_string(); + let msg_value = msg + .value + .as_str() + .ok_or_else(|| format!("Failed to parse value as string: {}", msg.value))?; + *val = msg_value.to_string(); + Ok(()) } else if let Some(val) = reflect_field.downcast_mut::() { - let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else { - error!("Failed to parse value: {}", msg.value); - return; - }; + let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; *val = msg_value; + Ok(()) } else if let Some(val) = reflect_field.downcast_mut::() { - let Ok(msg_value) = serde_json::from_value(msg.value.clone()) else { - error!("Failed to parse value: {}", msg.value); - return; - }; + let msg_value = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; *val = msg_value; + Ok(()) + } else if let Some(val) = reflect_field.downcast_mut::() { + let Rgba { + red, + green, + blue, + alpha, + } = serde_json::from_value(msg.value.clone()).map_err(fmt_err)?; + + match *val { + Color::RgbaLinear { .. } => { + *val = Color::RgbaLinear { + red, + green, + blue, + alpha, + }; + } + Color::Rgba { .. } => { + *val = Color::Rgba { + red, + green, + blue, + alpha, + }; + } + other => { + return Err(format!("Unsupported color format: {other:?}")); + } + } + Ok(()) } else { - error!("Unsupported type {}!", &msg.reflect_path); - return; + Err(format!("Unsupported type {}!", &msg.reflect_path)) } }, - ); + ) } } @@ -385,11 +471,15 @@ fn notify_asset_change_system( mut ev_inspect: EventWriter>>, mut asset_changing: EventReader>, q_handles: Query<&Handle>, + q_children: Query<&Children>, ) { for ev in asset_changing.read() { if let Some(handle) = r_selection .as_ref() - .and_then(|selection| q_handles.get(***selection).ok()) + .and_then(|selection| { + find_handle_in_self_or_children(***selection, &q_children, &q_handles) + }) + .and_then(|target| q_handles.get(target).ok()) { if ev.is_modified(handle.id()) { ev_inspect.send(UpdateInspect::>::default()); @@ -401,6 +491,7 @@ fn notify_asset_change_system( fn auto_serialize_asset( r_selection: Option>, q_t: Query<&Handle>, + q_children: Query<&Children>, mut description: ResMut, registry: Res, mut ev_inspect: EventReader>>, @@ -413,7 +504,11 @@ fn auto_serialize_asset( ev_inspect.clear(); - if let Some(handle) = r_selection.and_then(|selection| q_t.get(**selection).ok()) { + if let Some(handle) = r_selection + .as_ref() + .and_then(|selection| find_handle_in_self_or_children(***selection, &q_children, &q_t)) + .and_then(|target| q_t.get(target).ok()) + { if let Some(asset) = assets.get(handle) { let registry = registry.read(); info!( @@ -458,7 +553,9 @@ impl ShortMaterial { } fn serialize_standart_materal( + r_selection: Option>, q_m: Query<&Handle>, + q_children: Query<&Children>, mut description: ResMut, registry: Res, mut ev_inspect: EventReader>>, @@ -471,7 +568,11 @@ fn serialize_standart_materal( ev_inspect.clear(); - if let Some(handle) = q_m.iter().next() { + if let Some(handle) = r_selection + .as_ref() + .and_then(|selection| find_handle_in_self_or_children(***selection, &q_children, &q_m)) + .and_then(|target| q_m.get(target).ok()) + { if let Some(material) = assets.get(handle) { let registry = registry.read(); let short_material = ShortMaterial::new(material); @@ -545,7 +646,27 @@ fn update_description( } } -#[derive(Deserialize, Serialize)] +/// Find a `Handle` component on this entity or one of its direct children +fn find_handle_in_self_or_children( + target: Entity, + q_children: &Query<&Children>, + q_handles: &Query<&Handle>, +) -> Option { + if q_handles.contains(target) { + return Some(target); + } + + let Ok(children) = q_children.get(target) else { + return None; + }; + + children + .iter() + .copied() + .find(|&child| q_handles.contains(child)) +} + +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct UpdateField { pub component: String, @@ -587,6 +708,7 @@ static CHANGE_FROM_UI: WasmMessageBuffer = WasmMessageBuffer::new() #[wasm_bindgen(js_name = updateComponentField, skip_typescript)] pub fn update_component_field(msg: JsValue) -> Result<(), JsValue> { let msg = serde_wasm_bindgen::from_value(msg)?; + info!("Received update: {msg:?}"); CHANGE_FROM_UI.write_message(msg); Ok(()) }