Blacksite/crates/editor_ui/src/materials/inputs.rs
Rbanh 53dc1e44d8
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
feat: add production UI gallery and inspector system
2026-07-18 12:05:26 -04:00

725 lines
26 KiB
Rust

use super::texture_inputs::{
ensure_material_texture_binding, material_property_texture_binding,
material_texture_channel_ui, texture_binding_ui, texture_channel_label,
upsert_material_texture_binding,
};
use super::uv_transform::{material_vec2_value, set_material_vec2_value, uv_transform_section};
use super::*;
use crate::design_system;
use crate::design_system::color_picker::{color_control, color_control_with_intensity};
use crate::design_system::controls::{
scalar_control, section_header, section_header_with_summary, switch,
};
use crate::design_system::property_grid::PropertyGridGeometry;
use crate::design_system::typography::TypeRole;
use material_schema::MaterialShaderKind;
pub fn material_input_schema_editor(
ui: &mut egui::Ui,
schema: &MaterialInputSchema,
inputs: &mut MaterialInputSet,
texture_assets: &[MaterialTextureCandidate],
drop_candidate: Option<&MaterialTextureCandidate>,
) -> MaterialInputEditorResponse {
material_input_schema_editor_with_inheritance(
ui,
schema,
inputs,
None,
texture_assets,
drop_candidate,
)
}
pub fn material_input_schema_editor_with_inheritance(
ui: &mut egui::Ui,
schema: &MaterialInputSchema,
inputs: &mut MaterialInputSet,
inherited: Option<&MaterialInputSet>,
texture_assets: &[MaterialTextureCandidate],
drop_candidate: Option<&MaterialTextureCandidate>,
) -> MaterialInputEditorResponse {
let mut response = MaterialInputEditorResponse::default();
let has_standard_packed_inputs = ["occlusion", "roughness", "metallic"]
.iter()
.all(|name| schema.inputs.iter().any(|input| input.name == *name));
let mut groups = schema.groups.clone();
if groups.is_empty() {
groups.push(material_schema::MaterialInputGroupDesc {
id: String::new(),
display_name: "Properties".into(),
order: 0,
advanced: false,
});
}
groups.sort_by_key(|group| group.order);
for group in groups {
let mut properties = schema
.inputs
.iter()
.filter(|property| {
property.group == group.id
&& matches!(
property.presentation,
material_schema::MaterialInputPresentation::Row
)
})
.collect::<Vec<_>>();
let companions = schema
.inputs
.iter()
.filter(|property| property.group == group.id)
.filter_map(|property| match &property.presentation {
material_schema::MaterialInputPresentation::Companion { owner } => {
Some((owner.as_str(), property))
}
material_schema::MaterialInputPresentation::Row => None,
})
.collect::<Vec<_>>();
properties.sort_by_key(|property| property.order);
if properties.is_empty() {
continue;
}
if group.id == "uv_transform" {
material_uv_transform_ui(ui, inputs, inherited);
// The design-system scope contributes four pixels between vertical items; add the
// remaining four to reach the authored eight-pixel section gap.
ui.add_space(4.0);
continue;
}
let open_id = ui.make_persistent_id(("material_input_group_open", &group.id));
let mut open = ui
.ctx()
.data_mut(|data| data.get_persisted::<bool>(open_id))
.unwrap_or(!group.advanced);
let context = MaterialInputGroupContext {
properties: &properties,
companions: &companions,
texture_assets,
drop_candidate,
};
let rows = material_input_group_ui(ui, context, &mut open, inputs, |ui, open| {
if group.id == "surface_inputs" {
let summary = format!("{} bindings", properties.len());
section_header_with_summary(ui, "Inputs", Some(&summary), open);
} else {
section_header(ui, &group.display_name, open);
}
});
response.accepted_drop |= rows.accepted_drop;
response.locate = response.locate.take().or(rows.locate);
response.browse_library |= rows.browse_library;
ui.ctx()
.data_mut(|data| data.insert_persisted(open_id, open));
ui.add_space(4.0);
}
if has_standard_packed_inputs {
super::advanced_preview::unsupported_advanced_preview(ui);
}
response
}
#[derive(Clone, Copy)]
struct MaterialInputGroupContext<'schema, 'view> {
properties: &'view [&'schema MaterialInputDesc],
companions: &'view [(&'schema str, &'schema MaterialInputDesc)],
texture_assets: &'view [MaterialTextureCandidate],
drop_candidate: Option<&'view MaterialTextureCandidate>,
}
fn material_input_group_ui(
ui: &mut egui::Ui,
context: MaterialInputGroupContext<'_, '_>,
open: &mut bool,
inputs: &mut MaterialInputSet,
header_ui: impl FnOnce(&mut egui::Ui, &mut bool),
) -> MaterialInputEditorResponse {
let width = ui.available_width().max(1.0);
let geometry = PropertyGridGeometry::for_width(width);
let body_open = *open;
let height = material_input_group_height(width, body_open, context.properties.len());
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
let palette = design_system::palette(ui);
ui.painter().rect(
rect,
5.0,
palette.section,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
// The parent owns the complete section rect. Child controls may manage local layout state,
// but they cannot alter where the next material section begins.
let mut section = ui.new_child(egui::UiBuilder::new().max_rect(rect));
section.set_clip_rect(rect.intersect(ui.clip_rect()));
let header_rect = egui::Rect::from_min_size(
rect.min,
egui::vec2(width, design_system::INPUT_SECTION_HEADER_HEIGHT),
);
let mut header = section.new_child(egui::UiBuilder::new().max_rect(header_rect));
header.set_clip_rect(header_rect.intersect(section.clip_rect()));
header_ui(&mut header, open);
if body_open {
let rows_rect =
egui::Rect::from_min_max(egui::pos2(rect.left(), header_rect.bottom()), rect.max);
material_input_rows(&mut section, rows_rect, geometry, context, inputs)
} else {
MaterialInputEditorResponse::default()
}
}
fn material_input_group_height(width: f32, open: bool, rows: usize) -> f32 {
let body = if open {
PropertyGridGeometry::for_width(width).row_height * rows as f32
} else {
0.0
};
design_system::INPUT_SECTION_HEADER_HEIGHT + body
}
fn material_uv_transform_ui(
ui: &mut egui::Ui,
inputs: &mut MaterialInputSet,
inherited: Option<&MaterialInputSet>,
) {
let mut offset = material_vec2_value(inputs, "uv_offset", Vec2::ZERO);
let mut tiling = material_vec2_value(inputs, "uv_tiling", Vec2::ONE);
let reset_offset = inherited.map_or(Vec2::ZERO, |inputs| {
material_vec2_value(inputs, "uv_offset", Vec2::ZERO)
});
let reset_tiling = inherited.map_or(Vec2::ONE, |inputs| {
material_vec2_value(inputs, "uv_tiling", Vec2::ONE)
});
let original = (offset, tiling);
uv_transform_section(ui, &mut offset, &mut tiling, reset_offset, reset_tiling);
if (offset, tiling) != original {
set_material_vec2_value(inputs, "uv_offset", offset);
set_material_vec2_value(inputs, "uv_tiling", tiling);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackedMapMode {
Arm,
Separate,
}
pub fn packed_map_mode(inputs: &MaterialInputSet) -> PackedMapMode {
let channels = [
("occlusion", TextureChannel::R),
("roughness", TextureChannel::G),
("metallic", TextureChannel::B),
];
if channels.iter().all(|(name, channel)| {
inputs
.texture(name)
.is_some_and(|binding| binding.channel == *channel)
}) {
PackedMapMode::Arm
} else {
PackedMapMode::Separate
}
}
pub fn apply_arm_texture_preset(inputs: &mut MaterialInputSet) {
let shared_texture = ["occlusion", "roughness", "metallic"]
.iter()
.find_map(|name| {
inputs
.texture(name)
.and_then(|binding| binding.texture.clone())
});
for (name, channel) in [
("occlusion", TextureChannel::R),
("roughness", TextureChannel::G),
("metallic", TextureChannel::B),
] {
let binding = ensure_material_texture_binding(&mut inputs.textures, name);
binding.channel = channel;
if let Some(texture) = shared_texture.clone() {
binding.texture = Some(texture);
}
}
}
pub fn apply_separate_texture_preset(inputs: &mut MaterialInputSet) {
for name in ["occlusion", "roughness", "metallic"] {
ensure_material_texture_binding(&mut inputs.textures, name).channel = TextureChannel::R;
}
}
fn material_input_rows(
ui: &mut egui::Ui,
rows_rect: egui::Rect,
geometry: PropertyGridGeometry,
context: MaterialInputGroupContext<'_, '_>,
inputs: &mut MaterialInputSet,
) -> MaterialInputEditorResponse {
let palette = design_system::palette(ui);
let mut response = MaterialInputEditorResponse::default();
for (row, property) in context.properties.iter().enumerate() {
let companion = context
.companions
.iter()
.find_map(|(owner, companion)| (*owner == property.name).then_some(*companion));
ui.push_id(("material_input_row", &property.name), |ui| {
let row_rect = egui::Rect::from_min_size(
rows_rect.min + egui::vec2(0.0, row as f32 * geometry.row_height),
egui::vec2(rows_rect.width(), geometry.row_height),
);
ui.painter().rect_filled(
row_rect,
0.0,
if row.is_multiple_of(2) {
palette.row_even
} else {
palette.row_odd
},
);
let rects = geometry.row_rects(row_rect);
rect_ui(ui, rects.label, |ui| material_input_label(ui, property));
let is_color = matches!(property.property_type, ShaderPropertyType::Color);
rect_ui(
ui,
if is_color {
rects.color_value
} else {
rects.value
},
|ui| material_input_value_ui(ui, property, companion, inputs),
);
if !is_color {
rect_ui(ui, rects.channel, |ui| {
material_input_channel_ui(ui, property, inputs)
});
}
if material_input_uses_texture(property) {
let texture = rect_ui(ui, rects.texture_group, |ui| {
material_input_texture_ui(
ui,
property,
inputs,
context.texture_assets,
context.drop_candidate,
rects.inline_texture_actions,
)
});
response.accepted_drop |= texture.accepted_drop;
response.locate = response.locate.take().or(texture.locate);
response.browse_library |= texture.browse_library;
}
});
}
response
}
fn rect_ui<R>(
ui: &mut egui::Ui,
rect: egui::Rect,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
ui.set_clip_rect(rect.intersect(ui.clip_rect()));
add_contents(ui)
})
.inner
}
pub(super) fn material_input_label(ui: &mut egui::Ui, property: &MaterialInputDesc) {
let palette = design_system::palette(ui);
ui.add(
egui::Label::new(
TypeRole::Body
.text(&property.display_name)
.color(palette.text_secondary),
)
.truncate(),
)
.on_hover_text(&property.tooltip);
}
pub(super) fn material_input_uses_texture(property: &MaterialInputDesc) -> bool {
property.texture.is_some() || matches!(property.property_type, ShaderPropertyType::Texture)
}
pub(super) fn material_input_value_ui(
ui: &mut egui::Ui,
property: &MaterialInputDesc,
companion: Option<&MaterialInputDesc>,
inputs: &mut MaterialInputSet,
) {
let MaterialInputSet { values, .. } = inputs;
ui.horizontal(|ui| {
if matches!(property.property_type, ShaderPropertyType::Texture) {
ui.label(egui::RichText::new("Texture").small().color(TEXT_DIM));
} else {
let default = property
.default_value
.clone()
.unwrap_or_else(|| default_value_for_shader_property(&property.property_type));
let mut value = values
.iter()
.find(|value| value.name == property.name)
.map(|parameter| parameter.value.clone())
.unwrap_or(default);
if !parameter_value_matches_property(&value, &property.property_type) {
value = default_value_for_shader_property(&property.property_type);
}
let original = value.clone();
if matches!(property.property_type, ShaderPropertyType::Color)
&& companion.is_some_and(|input| input.name == "emissive_intensity")
{
let companion = companion.expect("checked companion");
let companion_default = companion
.default_value
.clone()
.unwrap_or(MaterialParameterValue::Float(0.0));
let mut companion_value = values
.iter()
.find(|value| value.name == companion.name)
.map(|parameter| parameter.value.clone())
.unwrap_or(companion_default);
if !parameter_value_matches_property(&companion_value, &companion.property_type) {
companion_value = default_value_for_shader_property(&companion.property_type);
}
let original_companion = companion_value.clone();
if let (
MaterialParameterValue::Color(color),
MaterialParameterValue::Float(intensity),
) = (&mut value, &mut companion_value)
{
let mut rgba = [color.r, color.g, color.b, color.a];
let picker = color_control_with_intensity(
ui,
"material_emissive_color",
&property.display_name,
&mut rgba,
intensity,
);
if picker.changed {
*color = ColorDesc {
r: rgba[0],
g: rgba[1],
b: rgba[2],
a: rgba[3],
};
}
}
if value != original {
upsert_material_parameter(values, &property.name, value);
}
if companion_value != original_companion {
upsert_material_parameter(values, &companion.name, companion_value);
}
} else {
material_parameter_control_ui(
ui,
&property.display_name,
&property.property_type,
&mut value,
);
if value != original {
upsert_material_parameter(values, &property.name, value);
}
}
}
});
}
fn upsert_material_parameter(
values: &mut Vec<MaterialParameter>,
name: &str,
value: MaterialParameterValue,
) {
if let Some(parameter) = values.iter_mut().find(|parameter| parameter.name == name) {
parameter.value = value;
} else {
values.push(MaterialParameter {
name: name.to_string(),
value,
});
}
}
fn material_input_channel_ui(
ui: &mut egui::Ui,
property: &MaterialInputDesc,
inputs: &mut MaterialInputSet,
) {
let palette = design_system::palette(ui);
if property
.texture
.as_ref()
.is_some_and(|texture| texture.allow_channel_override)
{
let original = material_property_texture_binding(&inputs.textures, property);
let mut binding = original.clone();
material_texture_channel_ui(ui, property, &mut binding);
if binding != original {
upsert_material_texture_binding(&mut inputs.textures, binding);
}
} else {
let channel = property.texture.as_ref().map(|texture| {
if matches!(texture.semantic, material_schema::TextureSemantic::Color) {
"RGB"
} else {
texture_channel_label(texture.default_channel)
}
});
if let Some(channel) = channel {
readonly_channel(ui, channel);
} else {
ui.label(TypeRole::Body.text("").color(palette.text_muted));
}
}
}
pub(super) fn material_input_texture_ui(
ui: &mut egui::Ui,
property: &MaterialInputDesc,
inputs: &mut MaterialInputSet,
texture_assets: &[MaterialTextureCandidate],
drop_candidate: Option<&MaterialTextureCandidate>,
inline_actions: bool,
) -> TextureSlotResponse {
if !material_input_uses_texture(property) {
return TextureSlotResponse::default();
}
let original = material_property_texture_binding(&inputs.textures, property);
let mut binding = original.clone();
let response = texture_binding_ui(
ui,
&mut binding,
texture_assets,
drop_candidate,
inline_actions,
);
if binding != original {
upsert_material_texture_binding(&mut inputs.textures, binding);
}
response
}
pub(super) fn material_parameter_control_ui(
ui: &mut egui::Ui,
label: &str,
property_type: &ShaderPropertyType,
value: &mut MaterialParameterValue,
) {
if !parameter_value_matches_property(value, property_type) {
*value = default_value_for_shader_property(property_type);
}
match value {
MaterialParameterValue::Bool(value) => {
switch(ui, value);
}
MaterialParameterValue::Float(value) => {
let (min, max) = match property_type {
ShaderPropertyType::Float { min, max } => (*min, *max),
_ => (None, None),
};
match (min, max) {
(Some(min), Some(max)) => {
scalar_control(ui, label, value, min..=max, usize::from(max <= 1_000.0) * 2);
}
_ => {
ui.add(egui::DragValue::new(value).speed(0.01));
}
}
}
MaterialParameterValue::Vec2(value) => {
ui.add(egui::DragValue::new(&mut value.x).speed(0.01).prefix("X "));
ui.add(egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y "));
}
MaterialParameterValue::Vec3(value) => {
ui.add(egui::DragValue::new(&mut value.x).speed(0.01).prefix("X "));
ui.add(egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y "));
ui.add(egui::DragValue::new(&mut value.z).speed(0.01).prefix("Z "));
}
MaterialParameterValue::Color(value) => {
let mut rgba = [value.r, value.g, value.b, value.a];
let response = color_control(ui, "material_color", label, &mut rgba);
if response.changed {
*value = ColorDesc {
r: rgba[0],
g: rgba[1],
b: rgba[2],
a: rgba[3],
};
}
}
MaterialParameterValue::Enum(value) => {
let options = match property_type {
ShaderPropertyType::Enum { options } => options.as_slice(),
_ => &[],
};
egui::ComboBox::from_id_salt(("material_enum", value.as_str()))
.selected_text(value.as_str())
.show_ui(ui, |ui| {
for option in options {
ui.selectable_value(value, option.clone(), option);
}
});
}
}
}
fn readonly_channel(ui: &mut egui::Ui, channel: &str) {
let palette = design_system::palette(ui);
let (rect, response) = ui.allocate_exact_size(egui::vec2(48.0, 22.0), egui::Sense::hover());
ui.painter().rect(
rect,
4.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.left_center() + egui::vec2(9.0, 0.0),
egui::Align2::LEFT_CENTER,
channel,
TypeRole::Body.font(),
palette.text_secondary,
);
ui.painter().text(
rect.right_center() - egui::vec2(8.0, 0.0),
egui::Align2::CENTER_CENTER,
egui_phosphor_icons::icons::CARET_DOWN.as_str(),
egui::FontId::new(9.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_muted,
);
response.on_hover_text("Channel is fixed by this material input");
}
pub fn default_value_for_shader_property(
property_type: &ShaderPropertyType,
) -> MaterialParameterValue {
match property_type {
ShaderPropertyType::Bool => MaterialParameterValue::Bool(false),
ShaderPropertyType::Float { .. } => MaterialParameterValue::Float(0.0),
ShaderPropertyType::Vec2 => MaterialParameterValue::Vec2(Vec2::ZERO),
ShaderPropertyType::Vec3 => MaterialParameterValue::Vec3(Vec3::ZERO),
ShaderPropertyType::Color => MaterialParameterValue::Color(ColorDesc::default()),
ShaderPropertyType::Enum { options } => {
MaterialParameterValue::Enum(options.first().cloned().unwrap_or_default())
}
ShaderPropertyType::Texture => MaterialParameterValue::Float(0.0),
}
}
pub fn parameter_value_matches_property(
value: &MaterialParameterValue,
property_type: &ShaderPropertyType,
) -> bool {
matches!(
(value, property_type),
(MaterialParameterValue::Bool(_), ShaderPropertyType::Bool)
| (
MaterialParameterValue::Float(_),
ShaderPropertyType::Float { .. }
)
| (MaterialParameterValue::Vec2(_), ShaderPropertyType::Vec2)
| (MaterialParameterValue::Vec3(_), ShaderPropertyType::Vec3)
| (MaterialParameterValue::Color(_), ShaderPropertyType::Color)
| (
MaterialParameterValue::Enum(_),
ShaderPropertyType::Enum { .. }
)
)
}
pub fn compact_text_edit(ui: &mut egui::Ui, label: &str, value: &mut String) {
ui.horizontal(|ui| {
ui.add_sized([92.0, 20.0], egui::Label::new(label));
ui.add_sized(
[fit_width(ui, 100.0, f32::INFINITY), 22.0],
egui::TextEdit::singleline(value),
);
});
}
pub fn shader_kind_picker(ui: &mut egui::Ui, kind: &mut MaterialShaderKind) {
ui.horizontal(|ui| {
ui.add_sized([92.0, 20.0], egui::Label::new("Shader"));
shader_kind_combo(ui, kind, "asset_material_shader_kind");
});
}
pub fn shader_kind_combo(
ui: &mut egui::Ui,
kind: &mut MaterialShaderKind,
salt: impl std::hash::Hash,
) {
egui::ComboBox::from_id_salt(salt)
.selected_text(match kind {
MaterialShaderKind::StandardLit => "Standard Lit",
MaterialShaderKind::Unlit => "Unlit",
MaterialShaderKind::Custom => "Custom Surface",
})
.width(150.0_f32.min(ui.available_width().max(1.0)))
.show_ui(ui, |ui| {
ui.selectable_value(kind, MaterialShaderKind::StandardLit, "Standard Lit");
ui.selectable_value(kind, MaterialShaderKind::Unlit, "Unlit");
ui.selectable_value(kind, MaterialShaderKind::Custom, "Custom Surface");
});
}
pub fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) {
ui.horizontal(|ui| {
ui.add_sized([92.0, 20.0], egui::Label::new(label));
let mut text = value.clone().unwrap_or_default();
if ui
.add_sized(
[fit_width(ui, 80.0, f32::INFINITY), 22.0],
egui::TextEdit::singleline(&mut text),
)
.changed()
{
*value = if text.trim().is_empty() {
None
} else {
Some(text)
};
}
if ui.button("Clear").clicked() {
*value = None;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_group_parent_owns_exact_reference_and_compact_heights() {
assert_eq!(material_input_group_height(569.0, true, 6), 216.0);
assert_eq!(material_input_group_height(369.0, true, 6), 372.0);
assert_eq!(material_input_group_height(569.0, false, 6), 24.0);
}
#[test]
fn rendering_missing_standard_defaults_does_not_author_or_dirty_inputs() {
let context = egui::Context::default();
context.set_fonts(crate::fonts::font_definitions());
let mut inputs = MaterialInputSet::default();
let original = inputs.clone();
let schema = material_schema::standard_lit_input_schema();
let _ = context.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(700.0, 900.0),
)),
..Default::default()
},
|ui| {
material_input_schema_editor(ui, &schema, &mut inputs, &[], None);
},
);
assert_eq!(inputs, original);
}
}