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
-
+
>().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(())
}