1use glam::{Vec2, Vec3, Vec4};
24use std::collections::HashMap;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
32pub enum MaterialTag {
33 Skin,
34 Hair,
35 Jacket,
36 Boot,
37 Metal,
38 Eye,
39 Cloth,
40 Emissive,
41 Glass,
42 Ceramic,
43 Rubber,
44 Wood,
45 Stone,
46 Custom(u16),
47}
48
49impl MaterialTag {
50 pub fn label(self) -> &'static str {
51 match self {
52 MaterialTag::Skin => "Skin",
53 MaterialTag::Hair => "Hair",
54 MaterialTag::Jacket => "Jacket",
55 MaterialTag::Boot => "Boot",
56 MaterialTag::Metal => "Metal",
57 MaterialTag::Eye => "Eye",
58 MaterialTag::Cloth => "Cloth",
59 MaterialTag::Emissive => "Emissive",
60 MaterialTag::Glass => "Glass",
61 MaterialTag::Ceramic => "Ceramic",
62 MaterialTag::Rubber => "Rubber",
63 MaterialTag::Wood => "Wood",
64 MaterialTag::Stone => "Stone",
65 MaterialTag::Custom(_)=> "Custom",
66 }
67 }
68
69 pub fn all() -> &'static [MaterialTag] {
70 &[
71 MaterialTag::Skin, MaterialTag::Hair, MaterialTag::Jacket,
72 MaterialTag::Boot, MaterialTag::Metal, MaterialTag::Eye,
73 MaterialTag::Cloth, MaterialTag::Emissive, MaterialTag::Glass,
74 MaterialTag::Ceramic, MaterialTag::Rubber, MaterialTag::Wood,
75 MaterialTag::Stone,
76 ]
77 }
78
79 pub fn default_params(self) -> MaterialParams {
81 match self {
82 MaterialTag::Skin => MaterialParams {
83 base_color: Vec4::new(0.85, 0.72, 0.60, 1.0),
84 metallic: 0.0,
85 roughness: 0.65,
86 fresnel_f0: 0.028,
87 sss_intensity: 0.6,
88 emission_color: Vec3::ZERO,
89 emission_strength:0.0,
90 ior: 1.42,
91 anisotropy: 0.0,
92 subsurface_color: Vec3::new(0.9, 0.4, 0.3),
93 },
94 MaterialTag::Hair => MaterialParams {
95 base_color: Vec4::new(0.15, 0.10, 0.07, 1.0),
96 metallic: 0.0,
97 roughness: 0.5,
98 fresnel_f0: 0.046,
99 sss_intensity: 0.1,
100 emission_color: Vec3::ZERO,
101 emission_strength:0.0,
102 ior: 1.55,
103 anisotropy: 0.85,
104 subsurface_color: Vec3::new(0.3, 0.2, 0.1),
105 },
106 MaterialTag::Jacket => MaterialParams {
107 base_color: Vec4::new(0.25, 0.25, 0.30, 1.0),
108 metallic: 0.0,
109 roughness: 0.8,
110 fresnel_f0: 0.04,
111 sss_intensity: 0.0,
112 emission_color: Vec3::ZERO,
113 emission_strength:0.0,
114 ior: 1.5,
115 anisotropy: 0.0,
116 subsurface_color: Vec3::ZERO,
117 },
118 MaterialTag::Boot => MaterialParams {
119 base_color: Vec4::new(0.10, 0.08, 0.07, 1.0),
120 metallic: 0.0,
121 roughness: 0.7,
122 fresnel_f0: 0.05,
123 sss_intensity: 0.0,
124 emission_color: Vec3::ZERO,
125 emission_strength:0.0,
126 ior: 1.5,
127 anisotropy: 0.1,
128 subsurface_color: Vec3::ZERO,
129 },
130 MaterialTag::Metal => MaterialParams {
131 base_color: Vec4::new(0.82, 0.82, 0.82, 1.0),
132 metallic: 1.0,
133 roughness: 0.2,
134 fresnel_f0: 0.9,
135 sss_intensity: 0.0,
136 emission_color: Vec3::ZERO,
137 emission_strength:0.0,
138 ior: 2.93,
139 anisotropy: 0.0,
140 subsurface_color: Vec3::ZERO,
141 },
142 MaterialTag::Eye => MaterialParams {
143 base_color: Vec4::new(0.3, 0.6, 0.9, 1.0),
144 metallic: 0.0,
145 roughness: 0.02,
146 fresnel_f0: 0.04,
147 sss_intensity: 0.15,
148 emission_color: Vec3::new(0.2, 0.4, 0.8),
149 emission_strength:0.3,
150 ior: 1.336,
151 anisotropy: 0.0,
152 subsurface_color: Vec3::new(0.2, 0.4, 0.8),
153 },
154 MaterialTag::Cloth => MaterialParams {
155 base_color: Vec4::new(0.5, 0.5, 0.5, 1.0),
156 metallic: 0.0,
157 roughness: 0.9,
158 fresnel_f0: 0.04,
159 sss_intensity: 0.0,
160 emission_color: Vec3::ZERO,
161 emission_strength:0.0,
162 ior: 1.5,
163 anisotropy: 0.3,
164 subsurface_color: Vec3::ZERO,
165 },
166 MaterialTag::Emissive => MaterialParams {
167 base_color: Vec4::new(0.2, 0.5, 1.0, 1.0),
168 metallic: 0.0,
169 roughness: 0.3,
170 fresnel_f0: 0.04,
171 sss_intensity: 0.0,
172 emission_color: Vec3::new(0.2, 0.5, 1.0),
173 emission_strength:3.0,
174 ior: 1.5,
175 anisotropy: 0.0,
176 subsurface_color: Vec3::ZERO,
177 },
178 MaterialTag::Glass => MaterialParams {
179 base_color: Vec4::new(0.9, 0.95, 1.0, 0.1),
180 metallic: 0.0,
181 roughness: 0.01,
182 fresnel_f0: 0.04,
183 sss_intensity: 0.0,
184 emission_color: Vec3::ZERO,
185 emission_strength:0.0,
186 ior: 1.52,
187 anisotropy: 0.0,
188 subsurface_color: Vec3::ZERO,
189 },
190 MaterialTag::Ceramic => MaterialParams {
191 base_color: Vec4::new(0.95, 0.93, 0.88, 1.0),
192 metallic: 0.0,
193 roughness: 0.15,
194 fresnel_f0: 0.04,
195 sss_intensity: 0.0,
196 emission_color: Vec3::ZERO,
197 emission_strength:0.0,
198 ior: 1.7,
199 anisotropy: 0.0,
200 subsurface_color: Vec3::ZERO,
201 },
202 MaterialTag::Rubber => MaterialParams {
203 base_color: Vec4::new(0.15, 0.15, 0.15, 1.0),
204 metallic: 0.0,
205 roughness: 0.95,
206 fresnel_f0: 0.04,
207 sss_intensity: 0.0,
208 emission_color: Vec3::ZERO,
209 emission_strength:0.0,
210 ior: 1.519,
211 anisotropy: 0.0,
212 subsurface_color: Vec3::ZERO,
213 },
214 MaterialTag::Wood => MaterialParams {
215 base_color: Vec4::new(0.55, 0.35, 0.18, 1.0),
216 metallic: 0.0,
217 roughness: 0.8,
218 fresnel_f0: 0.04,
219 sss_intensity: 0.05,
220 emission_color: Vec3::ZERO,
221 emission_strength:0.0,
222 ior: 1.5,
223 anisotropy: 0.5,
224 subsurface_color: Vec3::new(0.6, 0.4, 0.2),
225 },
226 MaterialTag::Stone => MaterialParams {
227 base_color: Vec4::new(0.5, 0.48, 0.45, 1.0),
228 metallic: 0.0,
229 roughness: 0.9,
230 fresnel_f0: 0.04,
231 sss_intensity: 0.0,
232 emission_color: Vec3::ZERO,
233 emission_strength:0.0,
234 ior: 1.6,
235 anisotropy: 0.0,
236 subsurface_color: Vec3::ZERO,
237 },
238 MaterialTag::Custom(_) => MaterialParams::default(),
239 }
240 }
241}
242
243impl Default for MaterialTag {
244 fn default() -> Self { MaterialTag::Skin }
245}
246
247#[derive(Debug, Clone, PartialEq)]
253pub struct MaterialParams {
254 pub base_color: Vec4,
256 pub metallic: f32,
258 pub roughness: f32,
260 pub fresnel_f0: f32,
262 pub sss_intensity: f32,
264 pub emission_color: Vec3,
266 pub emission_strength:f32,
268 pub ior: f32,
270 pub anisotropy: f32,
272 pub subsurface_color: Vec3,
274}
275
276impl Default for MaterialParams {
277 fn default() -> Self {
278 Self {
279 base_color: Vec4::new(0.8, 0.8, 0.8, 1.0),
280 metallic: 0.0,
281 roughness: 0.5,
282 fresnel_f0: 0.04,
283 sss_intensity: 0.0,
284 emission_color: Vec3::ZERO,
285 emission_strength: 0.0,
286 ior: 1.5,
287 anisotropy: 0.0,
288 subsurface_color: Vec3::ZERO,
289 }
290 }
291}
292
293impl MaterialParams {
294 pub fn lerp(&self, other: &Self, t: f32) -> Self {
296 let s = 1.0 - t;
297 Self {
298 base_color: self.base_color * s + other.base_color * t,
299 metallic: self.metallic * s + other.metallic * t,
300 roughness: self.roughness * s + other.roughness * t,
301 fresnel_f0: self.fresnel_f0 * s + other.fresnel_f0 * t,
302 sss_intensity: self.sss_intensity * s + other.sss_intensity * t,
303 emission_color: self.emission_color * s + other.emission_color * t,
304 emission_strength: self.emission_strength * s + other.emission_strength * t,
305 ior: self.ior * s + other.ior * t,
306 anisotropy: self.anisotropy * s + other.anisotropy * t,
307 subsurface_color: self.subsurface_color * s + other.subsurface_color * t,
308 }
309 }
310}
311
312#[derive(Debug, Clone)]
318pub struct MaterialPreset {
319 pub name: String,
320 pub tag: MaterialTag,
321 pub params: MaterialParams,
322}
323
324impl MaterialPreset {
325 pub fn new(name: impl Into<String>, tag: MaterialTag) -> Self {
326 Self {
327 name: name.into(),
328 params: tag.default_params(),
329 tag,
330 }
331 }
332}
333
334#[derive(Debug, Clone)]
343pub struct MaterialPatch {
344 pub id: u32,
345 pub center: Vec3,
346 pub radius: f32,
347 pub tag: MaterialTag,
348 pub params: MaterialParams,
349 pub opacity: f32,
350 pub falloff: PatchFalloff,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq)]
355pub enum PatchFalloff {
356 Hard,
358 Linear,
360 Smooth,
362 Gaussian,
364}
365
366impl Default for PatchFalloff {
367 fn default() -> Self { PatchFalloff::Smooth }
368}
369
370impl MaterialPatch {
371 pub fn weight_at(&self, d: f32) -> f32 {
373 if d >= self.radius { return 0.0; }
374 let t = d / self.radius; let w = match self.falloff {
376 PatchFalloff::Hard => 1.0,
377 PatchFalloff::Linear => 1.0 - t,
378 PatchFalloff::Smooth => { let t2 = t * t; 1.0 - t2 * (3.0 - 2.0 * t) },
379 PatchFalloff::Gaussian => {
380 let sigma = self.radius / 3.0;
381 (-0.5 * (d / sigma).powi(2)).exp()
382 }
383 };
384 w * self.opacity
385 }
386}
387
388#[derive(Debug, Clone)]
394pub struct Brush {
395 pub tag: MaterialTag,
396 pub params: MaterialParams,
397 pub radius: f32,
398 pub opacity: f32,
399 pub falloff: PatchFalloff,
400 pub painting: bool,
401 pub cursor: Vec3,
403 pub normal: Vec3,
405}
406
407impl Default for Brush {
408 fn default() -> Self {
409 Self {
410 tag: MaterialTag::Skin,
411 params: MaterialTag::Skin.default_params(),
412 radius: 0.08,
413 opacity: 1.0,
414 falloff: PatchFalloff::Smooth,
415 painting: false,
416 cursor: Vec3::ZERO,
417 normal: Vec3::Y,
418 }
419 }
420}
421
422impl Brush {
423 pub fn set_tag(&mut self, tag: MaterialTag) {
424 self.tag = tag;
425 self.params = tag.default_params();
426 }
427
428 pub fn radius_step_up(&mut self) { self.radius = (self.radius * 1.2).min(2.0); }
429 pub fn radius_step_down(&mut self) { self.radius = (self.radius / 1.2).max(0.005); }
430 pub fn opacity_step_up(&mut self) { self.opacity = (self.opacity + 0.05).min(1.0); }
431 pub fn opacity_step_down(&mut self) { self.opacity = (self.opacity - 0.05).max(0.01); }
432}
433
434#[derive(Debug, Clone)]
440pub struct PaintLayer {
441 pub name: String,
442 pub visible: bool,
443 pub locked: bool,
444 pub opacity: f32,
445 patches: Vec<MaterialPatch>,
446 next_patch: u32,
447}
448
449impl PaintLayer {
450 pub fn new(name: impl Into<String>) -> Self {
451 Self {
452 name: name.into(),
453 visible: true,
454 locked: false,
455 opacity: 1.0,
456 patches: Vec::new(),
457 next_patch: 1,
458 }
459 }
460
461 pub fn add_patch(&mut self, center: Vec3, radius: f32, tag: MaterialTag,
462 params: MaterialParams, opacity: f32, falloff: PatchFalloff) -> u32 {
463 let id = self.next_patch;
464 self.next_patch += 1;
465 self.patches.push(MaterialPatch { id, center, radius, tag, params, opacity, falloff });
466 id
467 }
468
469 pub fn remove_patch(&mut self, id: u32) {
470 self.patches.retain(|p| p.id != id);
471 }
472
473 pub fn patches(&self) -> &[MaterialPatch] { &self.patches }
474 pub fn patch_count(&self) -> usize { self.patches.len() }
475
476 pub fn classify(&self, p: Vec3) -> Option<(&MaterialPatch, f32)> {
478 if !self.visible { return None; }
479 let mut best: Option<(&MaterialPatch, f32)> = None;
480 for patch in self.patches.iter().rev() {
481 let d = (p - patch.center).length();
482 let w = patch.weight_at(d) * self.opacity;
483 if w > 0.0 {
484 best = Some((patch, w));
485 break; }
487 }
488 best
489 }
490
491 pub fn erase_at(&mut self, p: Vec3, radius: f32) {
493 self.patches.retain(|patch| (patch.center - p).length() > radius);
494 }
495}
496
497#[derive(Debug)]
503pub struct MaterialPainter {
504 pub layers: Vec<PaintLayer>,
505 pub active_layer: usize,
506 pub brush: Brush,
507 pub presets: Vec<MaterialPreset>,
509 pub show_layers: bool,
511 pub show_params: bool,
513 undo_stack: Vec<PaintUndoEntry>,
514 redo_stack: Vec<PaintUndoEntry>,
515 pub recent_colors: Vec<Vec4>,
517}
518
519#[derive(Debug, Clone)]
520enum PaintUndoEntry {
521 AddPatch { layer: usize, patch_id: u32 },
522 Erase { layer: usize, erased: Vec<MaterialPatch> },
523 BatchPaint{ layer: usize, patch_ids: Vec<u32> },
524}
525
526impl MaterialPainter {
527 pub fn new() -> Self {
528 let mut presets = Vec::new();
529 for &tag in MaterialTag::all() {
530 presets.push(MaterialPreset::new(tag.label(), tag));
531 }
532 let mut layers = vec![PaintLayer::new("Base")];
533 layers.push(PaintLayer::new("Details"));
534 layers.push(PaintLayer::new("Emissive"));
535
536 Self {
537 layers,
538 active_layer: 0,
539 brush: Brush::default(),
540 presets,
541 show_layers: true,
542 show_params: true,
543 undo_stack: Vec::new(),
544 redo_stack: Vec::new(),
545 recent_colors: Vec::new(),
546 }
547 }
548
549 pub fn add_layer(&mut self, name: impl Into<String>) -> usize {
552 self.layers.push(PaintLayer::new(name));
553 self.layers.len() - 1
554 }
555
556 pub fn remove_layer(&mut self, idx: usize) {
557 if self.layers.len() > 1 && idx < self.layers.len() {
558 self.layers.remove(idx);
559 if self.active_layer >= self.layers.len() {
560 self.active_layer = self.layers.len() - 1;
561 }
562 }
563 }
564
565 pub fn move_layer_up(&mut self, idx: usize) {
566 if idx > 0 && idx < self.layers.len() {
567 self.layers.swap(idx, idx - 1);
568 }
569 }
570
571 pub fn move_layer_down(&mut self, idx: usize) {
572 if idx + 1 < self.layers.len() {
573 self.layers.swap(idx, idx + 1);
574 }
575 }
576
577 pub fn active_layer(&self) -> &PaintLayer { &self.layers[self.active_layer] }
578 pub fn active_layer_mut(&mut self) -> &mut PaintLayer { &mut self.layers[self.active_layer] }
579
580 pub fn begin_stroke(&mut self, surface_p: Vec3) {
584 self.brush.painting = true;
585 self.brush.cursor = surface_p;
586 self.paint_at(surface_p);
587 }
588
589 pub fn continue_stroke(&mut self, surface_p: Vec3) {
591 if !self.brush.painting { return; }
592 let delta = (surface_p - self.brush.cursor).length();
594 if delta > self.brush.radius * 0.4 {
595 self.brush.cursor = surface_p;
596 self.paint_at(surface_p);
597 }
598 }
599
600 pub fn end_stroke(&mut self) {
602 self.brush.painting = false;
603 }
604
605 fn paint_at(&mut self, p: Vec3) {
606 if self.layers[self.active_layer].locked { return; }
607 let id = self.layers[self.active_layer].add_patch(
608 p,
609 self.brush.radius,
610 self.brush.tag,
611 self.brush.params.clone(),
612 self.brush.opacity,
613 self.brush.falloff,
614 );
615 self.undo_stack.push(PaintUndoEntry::AddPatch { layer: self.active_layer, patch_id: id });
616 self.redo_stack.clear();
617 let c = self.brush.params.base_color;
619 self.recent_colors.retain(|&rc| rc != c);
620 self.recent_colors.insert(0, c);
621 if self.recent_colors.len() > 16 { self.recent_colors.truncate(16); }
622 }
623
624 pub fn erase_at(&mut self, p: Vec3, radius: f32) {
626 if self.layers[self.active_layer].locked { return; }
627 let erased: Vec<_> = self.layers[self.active_layer]
628 .patches().iter()
629 .filter(|patch| (patch.center - p).length() <= radius)
630 .cloned()
631 .collect();
632 if !erased.is_empty() {
633 self.undo_stack.push(PaintUndoEntry::Erase { layer: self.active_layer, erased });
634 self.redo_stack.clear();
635 self.layers[self.active_layer].erase_at(p, radius);
636 }
637 }
638
639 pub fn classify(&self, p: Vec3) -> Option<(MaterialTag, &MaterialParams)> {
644 for layer in self.layers.iter().rev() {
645 if let Some((patch, _w)) = layer.classify(p) {
646 return Some((patch.tag, &patch.params));
647 }
648 }
649 None
650 }
651
652 pub fn classify_or_default(&self, p: Vec3) -> (MaterialTag, MaterialParams) {
654 self.classify(p)
655 .map(|(tag, params)| (tag, params.clone()))
656 .unwrap_or_else(|| {
657 let tag = MaterialTag::Skin;
658 (tag, tag.default_params())
659 })
660 }
661
662 pub fn undo(&mut self) {
665 let Some(entry) = self.undo_stack.pop() else { return; };
666 match &entry {
667 PaintUndoEntry::AddPatch { layer, patch_id } => {
668 if let Some(l) = self.layers.get_mut(*layer) {
669 l.remove_patch(*patch_id);
670 }
671 }
672 PaintUndoEntry::Erase { layer, erased } => {
673 if let Some(l) = self.layers.get_mut(*layer) {
674 for patch in erased {
675 l.patches.push(patch.clone());
676 }
677 }
678 }
679 PaintUndoEntry::BatchPaint { layer, patch_ids } => {
680 if let Some(l) = self.layers.get_mut(*layer) {
681 for &id in patch_ids {
682 l.remove_patch(id);
683 }
684 }
685 }
686 }
687 self.redo_stack.push(entry);
688 }
689
690 pub fn redo(&mut self) {
691 if let Some(e) = self.redo_stack.pop() {
693 self.undo_stack.push(e);
694 }
695 }
696
697 pub fn save_preset(&mut self, name: impl Into<String>) {
700 let preset = MaterialPreset {
701 name: name.into(),
702 tag: self.brush.tag,
703 params: self.brush.params.clone(),
704 };
705 self.presets.push(preset);
706 }
707
708 pub fn apply_preset(&mut self, idx: usize) {
709 if let Some(preset) = self.presets.get(idx) {
710 self.brush.tag = preset.tag;
711 self.brush.params = preset.params.clone();
712 }
713 }
714
715 pub fn total_patches(&self) -> usize {
718 self.layers.iter().map(|l| l.patch_count()).sum()
719 }
720
721 pub fn status_line(&self) -> String {
722 let al = &self.layers[self.active_layer];
723 format!(
724 "Material Painter — layer '{}' ({} patches) | brush {} r={:.3} op={:.2} | {} total patches",
725 al.name, al.patch_count(),
726 self.brush.tag.label(), self.brush.radius, self.brush.opacity,
727 self.total_patches()
728 )
729 }
730}
731
732impl Default for MaterialPainter {
733 fn default() -> Self { Self::new() }
734}
735
736#[derive(Debug, Default)]
742pub struct MaterialLibrary {
743 pub materials: HashMap<String, MaterialParams>,
744 pub tags: HashMap<String, MaterialTag>,
745}
746
747impl MaterialLibrary {
748 pub fn new() -> Self {
749 let mut lib = Self::default();
750 for &tag in MaterialTag::all() {
751 lib.materials.insert(tag.label().to_string(), tag.default_params());
752 lib.tags.insert(tag.label().to_string(), tag);
753 }
754 lib
755 }
756
757 pub fn insert(&mut self, name: impl Into<String>, tag: MaterialTag, params: MaterialParams) {
758 let n = name.into();
759 self.materials.insert(n.clone(), params);
760 self.tags.insert(n, tag);
761 }
762
763 pub fn get(&self, name: &str) -> Option<(&MaterialTag, &MaterialParams)> {
764 let tag = self.tags.get(name)?;
765 let params = self.materials.get(name)?;
766 Some((tag, params))
767 }
768
769 pub fn names(&self) -> Vec<&str> {
770 let mut names: Vec<_> = self.materials.keys().map(String::as_str).collect();
771 names.sort();
772 names
773 }
774}
775
776#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 fn patch_weight_hard() {
786 let p = MaterialPatch {
787 id: 1, center: Vec3::ZERO, radius: 1.0,
788 tag: MaterialTag::Skin, params: MaterialParams::default(),
789 opacity: 1.0, falloff: PatchFalloff::Hard,
790 };
791 assert!((p.weight_at(0.5) - 1.0).abs() < 1e-5);
792 assert!((p.weight_at(1.5)).abs() < 1e-5);
793 }
794
795 #[test]
796 fn patch_weight_smooth() {
797 let p = MaterialPatch {
798 id: 1, center: Vec3::ZERO, radius: 1.0,
799 tag: MaterialTag::Skin, params: MaterialParams::default(),
800 opacity: 1.0, falloff: PatchFalloff::Smooth,
801 };
802 assert!((p.weight_at(0.0) - 1.0).abs() < 1e-5);
803 assert!((p.weight_at(1.0)).abs() < 1e-5);
804 assert!(p.weight_at(0.5) > 0.0 && p.weight_at(0.5) < 1.0);
805 }
806
807 #[test]
808 fn classify_last_patch_wins() {
809 let mut layer = PaintLayer::new("test");
810 layer.add_patch(Vec3::ZERO, 1.0, MaterialTag::Skin, MaterialParams::default(), 1.0, PatchFalloff::Hard);
811 layer.add_patch(Vec3::ZERO, 1.0, MaterialTag::Metal, MaterialParams::default(), 1.0, PatchFalloff::Hard);
812 let result = layer.classify(Vec3::ZERO);
813 assert!(result.is_some());
814 assert_eq!(result.unwrap().0.tag, MaterialTag::Metal);
815 }
816
817 #[test]
818 fn painter_undo() {
819 let mut painter = MaterialPainter::new();
820 painter.begin_stroke(Vec3::ZERO);
821 painter.end_stroke();
822 assert_eq!(painter.active_layer().patch_count(), 1);
823 painter.undo();
824 assert_eq!(painter.active_layer().patch_count(), 0);
825 }
826
827 #[test]
828 fn material_lerp() {
829 let a = MaterialTag::Metal.default_params();
830 let b = MaterialTag::Skin.default_params();
831 let mid = a.lerp(&b, 0.5);
832 let expected_rough = (a.roughness + b.roughness) * 0.5;
833 assert!((mid.roughness - expected_rough).abs() < 1e-5);
834 }
835
836 #[test]
837 fn library_all_tags() {
838 let lib = MaterialLibrary::new();
839 for &tag in MaterialTag::all() {
840 assert!(lib.get(tag.label()).is_some());
841 }
842 }
843}