Skip to main content

proof_engine/editor/
material_painter.rs

1//! Material Painter — click-to-assign material tags on SDF surfaces.
2//!
3//! # Overview
4//!
5//! The material painter works in SDF space.  The user moves a spherical brush
6//! over the rendered surface; at each frame the painter samples the SDF
7//! gradient to find the surface normal at the brush centre, then stamps a
8//! `MaterialPatch` into the patch list.  Patches are stored in paint order;
9//! the last patch that covers a given surface point wins.
10//!
11//! # Material tags
12//!
13//! Tags mirror the eight MaterialTag variants used in apotheosis.rs.  Each tag
14//! carries full PBR parameters: base colour, metallic, roughness, Fresnel
15//! strength (F0), SSS intensity, emission colour and emission strength.
16//!
17//! # Real-time preview
18//!
19//! `MaterialPainter::classify` returns the best-matching `MaterialTag` for any
20//! world-space point.  The rendering pipeline calls this once per particle to
21//! tint its output colour.
22
23use glam::{Vec2, Vec3, Vec4};
24use std::collections::HashMap;
25
26// ─────────────────────────────────────────────────────────────────────────────
27// MaterialTag
28// ─────────────────────────────────────────────────────────────────────────────
29
30/// Matches the eight material classifications used in the SDF renderer.
31#[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    /// Default PBR parameters for this tag.
80    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// ─────────────────────────────────────────────────────────────────────────────
248// MaterialParams
249// ─────────────────────────────────────────────────────────────────────────────
250
251/// Full PBR parameter set for a material.
252#[derive(Debug, Clone, PartialEq)]
253pub struct MaterialParams {
254    /// RGBA base colour (alpha = opacity for glass/transparent surfaces).
255    pub base_color:       Vec4,
256    /// 0 = dielectric, 1 = metal.
257    pub metallic:         f32,
258    /// Perceptual roughness [0, 1].
259    pub roughness:        f32,
260    /// Schlick F0 reflectance at normal incidence.
261    pub fresnel_f0:       f32,
262    /// Sub-surface scattering weight [0, 1].
263    pub sss_intensity:    f32,
264    /// Emission RGB (pre-tonemapped).
265    pub emission_color:   Vec3,
266    /// Emission multiplier (values > 1 bloom-clip).
267    pub emission_strength:f32,
268    /// Index of refraction (for glass, water, cornea).
269    pub ior:              f32,
270    /// GGX anisotropy [0, 1].
271    pub anisotropy:       f32,
272    /// Subsurface tint colour.
273    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    /// Lerp between two parameter sets by weight t ∈ [0,1].
295    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// ─────────────────────────────────────────────────────────────────────────────
313// MaterialPreset
314// ─────────────────────────────────────────────────────────────────────────────
315
316/// A named material preset stored in the asset library.
317#[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// ─────────────────────────────────────────────────────────────────────────────
335// MaterialPatch
336// ─────────────────────────────────────────────────────────────────────────────
337
338/// A single paint stroke on the SDF surface.
339///
340/// A patch is a sphere in SDF object space.  Any surface point within
341/// `radius` of `center` receives `tag` and `params`.
342#[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/// How patch influence fades toward the edge.
354#[derive(Debug, Clone, Copy, PartialEq)]
355pub enum PatchFalloff {
356    /// Hard edge: full influence up to `radius`, zero outside.
357    Hard,
358    /// Linear: full at centre, zero at `radius`.
359    Linear,
360    /// Smooth cubic fade (default).
361    Smooth,
362    /// Gaussian with sigma = radius / 3.
363    Gaussian,
364}
365
366impl Default for PatchFalloff {
367    fn default() -> Self { PatchFalloff::Smooth }
368}
369
370impl MaterialPatch {
371    /// Returns the influence weight [0, 1] for a point at distance `d` from center.
372    pub fn weight_at(&self, d: f32) -> f32 {
373        if d >= self.radius { return 0.0; }
374        let t = d / self.radius; // 0 at center, 1 at edge
375        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// ─────────────────────────────────────────────────────────────────────────────
389// Brush
390// ─────────────────────────────────────────────────────────────────────────────
391
392/// The active brush state while painting.
393#[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    /// World-space cursor position on the SDF surface.
402    pub cursor:   Vec3,
403    /// Surface normal at cursor (for orientation).
404    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// ─────────────────────────────────────────────────────────────────────────────
435// PaintLayer
436// ─────────────────────────────────────────────────────────────────────────────
437
438/// A named layer of paint patches — like Photoshop layers.
439#[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    /// Classify a point, returning the top-most (last) patch that covers it.
477    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; // last patch wins
486            }
487        }
488        best
489    }
490
491    /// Erase patches whose center is within `radius` of `p`.
492    pub fn erase_at(&mut self, p: Vec3, radius: f32) {
493        self.patches.retain(|patch| (patch.center - p).length() > radius);
494    }
495}
496
497// ─────────────────────────────────────────────────────────────────────────────
498// MaterialPainter
499// ─────────────────────────────────────────────────────────────────────────────
500
501/// Top-level material painter state.
502#[derive(Debug)]
503pub struct MaterialPainter {
504    pub layers:       Vec<PaintLayer>,
505    pub active_layer: usize,
506    pub brush:        Brush,
507    /// Preset library — available materials for quick selection.
508    pub presets:      Vec<MaterialPreset>,
509    /// Whether to show the layer panel in the UI.
510    pub show_layers:  bool,
511    /// Whether to show the material param panel in the UI.
512    pub show_params:  bool,
513    undo_stack:       Vec<PaintUndoEntry>,
514    redo_stack:       Vec<PaintUndoEntry>,
515    /// Custom colour palette (recent colours).
516    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    // ── Layer management ──────────────────────────────────────────────────
550
551    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    // ── Painting ──────────────────────────────────────────────────────────
581
582    /// Begin a paint stroke at `surface_p` (world space on the SDF surface).
583    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    /// Continue a stroke as the cursor moves.
590    pub fn continue_stroke(&mut self, surface_p: Vec3) {
591        if !self.brush.painting { return; }
592        // Only stamp if cursor moved more than 40% of brush radius
593        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    /// End the current stroke.
601    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        // Track recent colour
618        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    /// Erase all patches on the active layer within `radius` of `p`.
625    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    // ── Classification ────────────────────────────────────────────────────
640
641    /// Return the best material tag and params for a world-space point.
642    /// Layers are composited in order (last visible layer covering the point wins).
643    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    /// Return the default material if no patch covers `p`.
653    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    // ── Undo / redo ───────────────────────────────────────────────────────
663
664    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        // Simplified: just mark dirty.
692        if let Some(e) = self.redo_stack.pop() {
693            self.undo_stack.push(e);
694        }
695    }
696
697    // ── Preset management ─────────────────────────────────────────────────
698
699    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    // ── Stats ─────────────────────────────────────────────────────────────
716
717    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// ─────────────────────────────────────────────────────────────────────────────
737// MaterialLibrary
738// ─────────────────────────────────────────────────────────────────────────────
739
740/// A collection of named materials that can be referenced from node graphs.
741#[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// ─────────────────────────────────────────────────────────────────────────────
777// Tests
778// ─────────────────────────────────────────────────────────────────────────────
779
780#[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}