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::>(); 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::>(); 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::(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( 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, 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) { 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); } }