#![allow(deprecated)] use eframe::{egui, App, Frame, NativeOptions}; use egui::{ColorImage, TextureOptions, TextureHandle, TextureFilter, Color32, Stroke, Rect, Vec2, Pos2, StrokeKind}; use hcie_blend::BlendMode; const THUMB_W: usize = 40; const THUMB_H: usize = 30; const CHECKER_SIZE: f32 = 12.0; const ZOOM_MIN: f32 = 0.01; const ZOOM_MAX: f32 = 64.0; struct LayerEntry { layer_id: u64, parent_id: Option, clipping_mask: bool, name: String, visible: bool, opacity: f32, blend_mode: BlendMode, layer_type: String, width: u32, height: u32, pixels: Vec, dirty: bool, thumbnail: Option, adjustment: Option, mask_pixels: Option>, mask_bounds: Option<[i32; 4]>, mask_default_color: u8, curve_knots: [Vec<(f32, f32)>; 3], master_knots: Vec<(f32, f32)>, effects: Vec, fill_opacity: f32, } struct HcieIoApp { layers: Vec, canvas_w: u32, canvas_h: u32, status: String, temp_path: String, temp_format: String, zoom: f32, pan_offset: Vec2, composite_texture: Option, composite_dirty: bool, checked_path: String, layer_id_counter: usize, selected_layer: Option, load_on_start: Option, curve_channel: usize, curve_drag_knot: Option, ref_texture: Option, ref_path: Option, ref_pixels: Option<(Vec, u32, u32)>, diff_texture: Option, view_mode: usize, compare_stats: String, } impl Default for HcieIoApp { fn default() -> Self { Self { layers: vec![], canvas_w: 800, canvas_h: 600, status: String::new(), temp_path: "output.png".to_string(), temp_format: "png".to_string(), zoom: 1.0, pan_offset: Vec2::ZERO, composite_texture: None, composite_dirty: false, checked_path: String::new(), layer_id_counter: 0, selected_layer: None, load_on_start: Some(std::path::PathBuf::from("_images/_psd_stil_test/sultan_test/sultan.psd")), curve_channel: 0, curve_drag_knot: None, ref_texture: None, ref_path: None, ref_pixels: None, diff_texture: None, view_mode: 0, compare_stats: String::new(), } } } impl HcieIoApp { fn load_file(&mut self, path: &std::path::Path) { let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_lowercase(); let result = match ext.as_str() { "psd" => { // Get canvas size from PSD header, not from first layer if let Ok(bytes) = std::fs::read(path) { if let Ok(psd) = hcie_psd::Psd::from_bytes(&bytes) { self.canvas_w = psd.width(); self.canvas_h = psd.height(); } } hcie_psd::psd_import::import_psd(path) }, "kra" => hcie_kra::import_kra(path), _ => hcie_io::load_image(path).map(|l| vec![hcie_protocol::Layer::from_rgba(l.name, l.width, l.height, l.pixels)]), }; match result { Ok(layers) => { if layers.is_empty() { self.status = "No layers found".to_string(); return; } // canvas_w/h already set from PSD header above (for PSD files) if ext != "psd" { self.canvas_w = layers[0].width; self.canvas_h = layers[0].height; } self.layers.clear(); for l in layers { self.layer_id_counter += 1; let curve_knots = if let Some(hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b }) = &l.adjustment { if !l.curve_points.is_empty() { let k = l.curve_points.iter().map(|&(o, i)| (i as f32, o as f32)).collect::>(); [k.clone(), k.clone(), k] } else { [extract_knots(&lut_r[..]), extract_knots(&lut_g[..]), extract_knots(&lut_b[..])] } } else { [vec![], vec![], vec![]] }; let master_knots = curve_knots[0].clone(); self.layers.push(LayerEntry { layer_id: l.id, parent_id: l.parent_id, name: l.name.clone(), visible: l.visible, opacity: l.opacity, blend_mode: to_blend_mode(l.blend_mode), clipping_mask: l.clipping_mask, layer_type: format!("{:?}", l.layer_type), width: l.width, height: l.height, pixels: l.pixels.clone(), dirty: true, thumbnail: None, adjustment: l.adjustment.clone(), mask_pixels: l.mask_pixels.clone(), mask_bounds: l.mask_bounds, mask_default_color: l.mask_default_color, curve_knots, master_knots, effects: l.effects.iter().map(|e| hcie_fx::protocol_to_hcie_fx_effect(e)).collect(), fill_opacity: l.fill_opacity, }); } self.selected_layer = if self.layers.is_empty() { None } else { Some(0) }; self.composite_dirty = true; self.status = format!("Loaded {} layers: {}x{}", self.layers.len(), self.canvas_w, self.canvas_h); // Auto-load reference PNG if exists next to PSD let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let parent = path.parent().unwrap_or(std::path::Path::new(".")); let ref_png = parent.join(format!("{}.png", stem)); if ref_png.exists() { self.load_reference(&ref_png); } } Err(e) => self.status = format!("Error: {}", e), } } fn load_reference(&mut self, path: &std::path::Path) { match image::open(path) { Ok(img) => { let rgba = img.to_rgba8(); let w = rgba.width(); let h = rgba.height(); self.ref_pixels = Some((rgba.into_raw(), w, h)); self.ref_path = Some(path.display().to_string()); self.ref_texture = None; self.diff_texture = None; self.compare_stats = String::new(); self.status = format!("{} + Ref: {}x{}", self.status, w, h); } Err(e) => { self.status = format!("{} (ref load error: {})", self.status, e); } } } fn save_image(&mut self) -> bool { let layer = hcie_io::Layer::from_rgba("Export", self.canvas_w, self.canvas_h, self.composite()); match hcie_io::save_image(&layer, std::path::Path::new(&self.temp_path), &self.temp_format) { Ok(()) => { self.status = "Saved successfully!".to_string(); true } Err(e) => { self.status = format!("Save error: {}", e); false } } } fn composite(&self) -> Vec { let comp_layers: Vec = self.layers.iter().map(|l| hcie_composite::Layer { name: l.name.clone(), layer_type: hcie_protocol::LayerType::default(), data: hcie_protocol::LayerData::default(), pixels: l.pixels.clone(), width: l.width, height: l.height, visible: l.visible, opacity: l.opacity, blend_mode: from_blend_mode(l.blend_mode), locked: false, dirty: false, id: l.layer_id, parent_id: l.parent_id, styles: vec![], clipping_mask: l.clipping_mask, collapsed: false, adjustment: l.adjustment.clone(), mask_pixels: l.mask_pixels.clone(), mask_bounds: l.mask_bounds, mask_default_color: l.mask_default_color, curve_points: vec![], fill_opacity: l.fill_opacity, effects: l.effects.iter().map(|e| hcie_fx::hcie_fx_effect_to_protocol(e)).collect(), effects_dirty: std::sync::atomic::AtomicBool::new(false), effects_cache: std::sync::Mutex::new(None), adjustment_raw: None, is_section_divider: false, image_resources_raw: None, }).collect(); hcie_composite::composite_layers(&comp_layers, self.canvas_w, self.canvas_h) } fn render_composition(&mut self, ctx: &egui::Context) { let needs_render = self.composite_dirty || self.composite_texture.is_none() || self.composite_texture.as_ref().map_or(true, |t| t.size() != [self.canvas_w as usize, self.canvas_h as usize]); if !needs_render && self.ref_texture.is_some() { return; } let buf = self.composite(); self.composite_dirty = false; let options = TextureOptions { magnification: TextureFilter::Nearest, minification: TextureFilter::Linear, ..Default::default() }; let image = ColorImage::from_rgba_unmultiplied( [self.canvas_w as usize, self.canvas_h as usize], &buf, ); if let Some(tex) = &mut self.composite_texture { if tex.size() == [self.canvas_w as usize, self.canvas_h as usize] { tex.set(image, options); } else { self.composite_texture = Some(ctx.load_texture("composite-view", image, options)); } } else { self.composite_texture = Some(ctx.load_texture("composite-view", image, options)); } // Create reference texture from stored pixels if let Some((ref px, rw, rh)) = &self.ref_pixels { if self.ref_texture.is_none() { let ref_img = ColorImage::from_rgba_unmultiplied([*rw as usize, *rh as usize], px); self.ref_texture = Some(ctx.load_texture("ref-view", ref_img, options)); } // Generate diff image and stats if self.diff_texture.is_none() { let comp = &buf; let comp_w = self.canvas_w; let comp_h = self.canvas_h; let total = (*rw * *rh) as usize; let mut diff_pixels = Vec::with_capacity(total * 4); let mut diff_sum = 0.0f64; let mut max_diff = 0u32; let mut match_count = 0usize; let mut diff_counts = [0u32; 256]; for i in 0..total { let cx = (i as u32 % rw).min(comp_w.saturating_sub(1)) as usize; let cy = (i as u32 / rw).min(comp_h.saturating_sub(1)) as usize; let ci = (cy * comp_w as usize + cx) * 4; let ri = i * 4; let dr = if ci + 3 < comp.len() { (comp[ci] as i32 - px[ri] as i32).unsigned_abs() } else { 255 }; let dg = if ci + 3 < comp.len() { (comp[ci+1] as i32 - px[ri+1] as i32).unsigned_abs() } else { 255 }; let db = if ci + 3 < comp.len() { (comp[ci+2] as i32 - px[ri+2] as i32).unsigned_abs() } else { 255 }; let da = if ci + 3 < comp.len() { (comp[ci+3] as i32 - px[ri+3] as i32).unsigned_abs() } else { 255 }; let pix_diff = dr.max(dg).max(db).max(da); if pix_diff > max_diff { max_diff = pix_diff; } diff_sum += (dr + dg + db + da) as f64 / 4.0; if pix_diff <= 5 { match_count += 1; } diff_counts[pix_diff.min(255) as usize] += 1; let d = (pix_diff.min(255) * 4).min(255) as u8; diff_pixels.extend_from_slice(&[d, d, d, 255]); } let mean_diff = diff_sum / total as f64; let match_pct = match_count as f64 / total as f64 * 100.0; let diff_img = ColorImage::from_rgba_unmultiplied([*rw as usize, *rh as usize], &diff_pixels); self.diff_texture = Some(ctx.load_texture("diff-view", diff_img, options)); let mut stats = format!("MAE: {:.2} MaxDiff: {} Match(<=5): {:.1}% ({}/{})", mean_diff, max_diff, match_pct, match_count, total); stats.push_str("\nDiff histogram (0-20):"); for d in 0..=20 { if diff_counts[d] > 0 { stats.push_str(&format!("\n diff={}: {} px", d, diff_counts[d])); } } self.compare_stats = stats; } } } fn update_layer_thumb(&mut self, idx: usize, ctx: &egui::Context) { let layer = &self.layers[idx]; if !layer.dirty && self.layers[idx].thumbnail.is_some() { return; } let thumb = thumbnail_nearest(&layer.pixels, layer.width, layer.height, THUMB_W as u32, THUMB_H as u32); let pixels: Vec = thumb.chunks_exact(4) .map(|c| Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])).collect(); let img = ColorImage::new([THUMB_W, THUMB_H], pixels); let opts = TextureOptions { magnification: TextureFilter::Nearest, minification: TextureFilter::Nearest, ..Default::default() }; let name = format!("thumb-{}", idx); if let Some(tex) = &mut self.layers[idx].thumbnail { tex.set(img, opts); } else { self.layers[idx].thumbnail = Some(ctx.load_texture(name, img, opts)); } self.layers[idx].dirty = false; } } impl App for HcieIoApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut Frame) { let ctx = ui.ctx(); // Load startup file once if let Some(path) = self.load_on_start.take() { self.checked_path = path.display().to_string(); self.load_file(&path); } egui::CentralPanel::default().show(ctx, |ui| { ui.horizontal(|ui| { ui.heading("hcie-io Test GUI"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("๐Ÿ“ Load").clicked() { if let Some(path) = rfd::FileDialog::new() .add_filter("PSD", &["psd"]) .add_filter("KRA", &["kra"]) .add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp", "gif"]).pick_file() { self.checked_path = path.display().to_string(); self.load_file(&path); } } }); }); ui.horizontal(|ui| { ui.label("Path:"); ui.add(egui::TextEdit::singleline(&mut self.checked_path).desired_width(200.0)); ui.label("Fmt:"); ui.add(egui::TextEdit::singleline(&mut self.temp_format).desired_width(60.0)); if ui.button("๐Ÿ’พ Save").clicked() { self.save_image(); } }); ui.horizontal(|ui| { if ui.button("โˆ’").clicked() { self.zoom = (self.zoom / 1.2).clamp(ZOOM_MIN, ZOOM_MAX); } ui.label(format!("{:.0}%", self.zoom * 100.0)); if ui.button("๏ผ‹").clicked() { self.zoom = (self.zoom * 1.2).clamp(ZOOM_MIN, ZOOM_MAX); } if ui.button("1:1").clicked() { self.zoom = 1.0; } if ui.button("โŠ™ Reset").clicked() { self.pan_offset = Vec2::ZERO; self.zoom = 1.0; } if self.ref_pixels.is_some() { ui.separator(); ui.label("View:"); ui.selectable_value(&mut self.view_mode, 0, "Composite"); ui.selectable_value(&mut self.view_mode, 1, "Reference"); ui.selectable_value(&mut self.view_mode, 2, "Diff"); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(format!("{}", self.status)); }); }); let available = ui.available_size(); let panel_w = 200.0_f32; let canvas_w = available.x - panel_w * 2.0 - 12.0; // โ”€โ”€ Render composition pipeline (before drawing) โ”€โ”€ self.render_composition(ui.ctx()); ui.horizontal(|ui| { // Layer panel ui.allocate_ui(Vec2::new(panel_w, available.y), |ui| { ui.vertical(|ui| { ui.strong("Layers"); ui.separator(); let mut toggled: Vec<(usize, bool)> = Vec::new(); let num = self.layers.len(); egui::ScrollArea::vertical().show(ui, |ui| { for i in (0..num).rev() { self.update_layer_thumb(i, ui.ctx()); let is_sel = self.selected_layer == Some(i); let row_h = 36.0; let (row_id, row_rect) = ui.allocate_space(Vec2::new(ui.available_width(), row_h)); let row_resp = ui.interact(row_rect, row_id, egui::Sense::click()); let layer = &self.layers[i]; let indent = if layer.parent_id.is_some() || layer.layer_type.contains("Group") { 16.0 } else { 0.0 }; let inner = Rect::from_min_max( Pos2::new(row_rect.min.x + 2.0 + indent, row_rect.min.y + 2.0), Pos2::new(row_rect.max.x - 2.0, row_rect.max.y - 2.0), ); let eye_rect = Rect::from_min_size(inner.left_top(), Vec2::new(20.0, row_h)); if row_resp.clicked() { if let Some(press_pos) = ui.input(|i| i.pointer.press_origin()) { if eye_rect.contains(press_pos) { toggled.push((i, !layer.visible)); } else { self.selected_layer = Some(i); } } else { self.selected_layer = Some(i); } } if is_sel { ui.painter().rect_filled(row_rect, 2.0, ui.visuals().selection.bg_fill); } else if row_resp.hovered() { ui.painter().rect_filled(row_rect, 2.0, ui.visuals().extreme_bg_color.gamma_multiply(0.5)); } let (icon, color) = if layer.visible { ("๐Ÿ‘", ui.visuals().strong_text_color()) } else { ("๐Ÿ‘", ui.visuals().weak_text_color().gamma_multiply(0.25)) }; ui.painter().text(eye_rect.center(), egui::Align2::CENTER_CENTER, icon, egui::FontId::proportional(16.0), color); let thumb_rect = Rect::from_min_size(Pos2::new(inner.left() + 22.0, inner.top() + 3.0), Vec2::new(THUMB_W as f32, THUMB_H as f32)); checkerboard_painter(ui.painter(), thumb_rect, 4.0, Color32::from_gray(160), Color32::from_gray(200)); if let Some(tex) = &layer.thumbnail { ui.painter().image(tex.id(), thumb_rect, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), Color32::WHITE); } ui.painter().text(Pos2::new(thumb_rect.right() + 6.0, inner.center().y), egui::Align2::LEFT_CENTER, &layer.name, egui::FontId::proportional(12.0), ui.visuals().text_color()); } }); for (i, vis) in toggled { self.layers[i].visible = vis; self.composite_dirty = true; } }); }); // Properties panel ui.allocate_ui(Vec2::new(panel_w, available.y), |ui| { ui.vertical(|ui| { ui.strong("Properties"); ui.separator(); if let Some(i) = self.selected_layer.filter(|&i| i < self.layers.len()) { let layer = &mut self.layers[i]; ui.label(format!("Name: {}", layer.name)); ui.label(format!("Type: {}", layer.layer_type)); ui.label(format!("Size: {}x{} px", layer.width, layer.height)); ui.label(format!("Blend: {:?}", layer.blend_mode)); egui::ComboBox::from_id_source("blend_mode") .selected_text(format!("{:?}", layer.blend_mode)) .show_ui(ui, |ui| { let modes = [ BlendMode::Normal, BlendMode::Dissolve, BlendMode::Darken, BlendMode::Multiply, BlendMode::ColorBurn, BlendMode::LinearBurn, BlendMode::DarkerColor, BlendMode::Lighten, BlendMode::Screen, BlendMode::ColorDodge, BlendMode::LinearDodge, BlendMode::LighterColor, BlendMode::Overlay, BlendMode::SoftLight, BlendMode::HardLight, BlendMode::VividLight, BlendMode::LinearLight, BlendMode::PinLight, BlendMode::HardMix, BlendMode::Difference, BlendMode::Exclusion, BlendMode::Subtract, BlendMode::Divide, BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity, ]; for m in modes { if ui.selectable_value(&mut layer.blend_mode, m, format!("{:?}", m)).changed() { self.composite_dirty = true; } } }); if ui.add(egui::Slider::new(&mut layer.opacity, 0.0..=1.0).text("Opacity")).changed() { self.composite_dirty = true; } if ui.checkbox(&mut layer.visible, "Visible").changed() { self.composite_dirty = true; } ui.label(format!("Pixels: {} bytes", layer.pixels.len())); if let Some(hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b }) = &mut layer.adjustment { ui.separator(); ui.strong("Curves Adjustment"); let mut curve_ch = self.curve_channel; let mut ch_changed = None; ui.horizontal(|ui| { let btn = egui::SelectableLabel::new(curve_ch == 3, egui::RichText::new("Master").strong().color(Color32::from_gray(200))); if ui.add(btn).clicked() { ch_changed = Some(3); } ui.separator(); for (idx, name, col) in [ (0usize, "R", Color32::from_rgb(255, 80, 80)), (1, "G", Color32::from_rgb(80, 220, 80)), (2, "B", Color32::from_rgb(80, 120, 255)), ] { let btn = egui::SelectableLabel::new(curve_ch == idx, egui::RichText::new(name).color(col)); if ui.add(btn).clicked() { ch_changed = Some(idx); } } }); if let Some(ch) = ch_changed { self.curve_channel = ch; curve_ch = ch; self.curve_drag_knot = None; } let sz = ui.available_width().min(220.0).max(120.0); let (resp, painter) = ui.allocate_painter(Vec2::new(sz, sz), egui::Sense::click_and_drag()); let rect = resp.rect; let to_screen = |ix: f32, iy: f32| -> Pos2 { Pos2::new(rect.min.x + (ix / 255.0) * sz, rect.max.y - (iy / 255.0) * sz) }; let from_screen = |p: Pos2| -> (f32, f32) { (((p.x - rect.min.x) / sz * 255.0).clamp(0.0, 255.0), ((rect.max.y - p.y) / sz * 255.0).clamp(0.0, 255.0)) }; painter.rect_filled(rect, 0.0, Color32::from_gray(25)); for i in 0..=4 { let t = i as f32 / 4.0; let x = rect.min.x + t * sz; let y = rect.min.y + (1.0 - t) * sz; painter.line_segment([Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], Stroke::new(0.5, Color32::from_gray(50))); painter.line_segment([Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], Stroke::new(0.5, Color32::from_gray(50))); } painter.line_segment([ Pos2::new(rect.min.x, rect.max.y), Pos2::new(rect.max.x, rect.min.y), ], Stroke::new(0.5, Color32::from_gray(70))); let knot_radius = 5.0f32; let colors = [ Color32::from_rgb(255, 80, 80), Color32::from_rgb(80, 220, 80), Color32::from_rgb(80, 120, 255), ]; // Draw master curve (gray, always visible) if !layer.master_knots.is_empty() { let ms = spline_lut(&layer.master_knots); let mut mpts = Vec::with_capacity(256); for i in 0..256u16 { mpts.push(to_screen(i as f32, ms[i as usize] as f32)); } for w in mpts.windows(2) { painter.line_segment([w[0], w[1]], Stroke::new(1.5, Color32::from_gray(120))); } } // Draw per-channel curves when a specific channel is selected if curve_ch < 3 && !layer.curve_knots[curve_ch].is_empty() { let cs = spline_lut(&layer.curve_knots[curve_ch]); let mut cpts = Vec::with_capacity(256); for i in 0..256u16 { cpts.push(to_screen(i as f32, cs[i as usize] as f32)); } for w in cpts.windows(2) { painter.line_segment([w[0], w[1]], Stroke::new(2.0, colors[curve_ch])); } } // Hover info let hover_pos = ui.input(|i| i.pointer.hover_pos()); if resp.hovered() { if let Some(pos) = hover_pos { if rect.contains(pos) { let (hx, _) = from_screen(pos); let hi = hx.round().clamp(0.0, 255.0) as usize; painter.line_segment([Pos2::new(pos.x, rect.min.y), Pos2::new(pos.x, rect.max.y)], Stroke::new(0.3, Color32::from_gray(100))); painter.line_segment([Pos2::new(rect.min.x, pos.y), Pos2::new(rect.max.x, pos.y)], Stroke::new(0.3, Color32::from_gray(100))); // Show master value if !layer.master_knots.is_empty() { let ms = spline_lut(&layer.master_knots); let mv = ms[hi]; let dp = to_screen(hi as f32, mv as f32); painter.circle_filled(dp, 3.0, Color32::from_gray(160)); } // Show channel value if curve_ch < 3 && !layer.curve_knots[curve_ch].is_empty() { let cs = spline_lut(&layer.curve_knots[curve_ch]); let cv = cs[hi]; let dp = to_screen(hi as f32, cv as f32); painter.circle_filled(dp, 3.0, colors[curve_ch]); ui.label(format!("Ch {} | {} โ†’ {}", ["R","G","B"][curve_ch], hi, cv)); } else if curve_ch == 3 { let ms = spline_lut(&layer.master_knots); ui.label(format!("Master | {} โ†’ {}", hi, ms[hi])); } } } } // Find nearest knot on the ACTIVE curve // Copy active knots for immutable reference during interaction let active_knots = if curve_ch == 3 { layer.master_knots.clone() } else { layer.curve_knots[curve_ch].clone() }; let find_nearest = |pos: Pos2, knots: &[(f32, f32)]| -> Option { let mut best = None; let mut best_d = knot_radius * 2.5; for (ki, &(kx, ky)) in knots.iter().enumerate() { let kp = to_screen(kx, ky); let d = (kp.x - pos.x).hypot(kp.y - pos.y); if d < best_d { best_d = d; best = Some(ki); } } best }; // Draw knots on active curve for (ki, &(kx, ky)) in active_knots.iter().enumerate() { let kp = to_screen(kx, ky); let is_drag = self.curve_drag_knot == Some(ki); let is_hover = hover_pos.map_or(false, |hp| (kp.x - hp.x).hypot(kp.y - hp.y) < knot_radius * 2.0); let fill = if is_drag { Color32::WHITE } else if is_hover { Color32::from_gray(220) } else { Color32::from_gray(160) }; let sw = if is_drag || is_hover { 2.0 } else { 1.0 }; let sc = if curve_ch == 3 { Color32::from_gray(255) } else { colors[curve_ch] }; painter.circle_filled(kp, knot_radius, fill); painter.circle_stroke(kp, knot_radius, Stroke::new(sw, sc)); } let mut changed = false; let pointer_pos = ui.input(|i| i.pointer.latest_pos()); let pointer_down = ui.input(|i| i.pointer.primary_down()); let pointer_released = ui.input(|i| i.pointer.primary_released()); let pointer_pressed = ui.input(|i| i.pointer.primary_pressed()); if let Some(pos) = pointer_pos { if rect.contains(pos) { if pointer_pressed { if let Some(ki) = find_nearest(pos, &active_knots) { self.curve_drag_knot = Some(ki); } else { let (nx, ny) = from_screen(pos); let knots = if curve_ch == 3 { &mut layer.master_knots } else { &mut layer.curve_knots[curve_ch] }; knots.push((nx, ny)); knots.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); self.curve_drag_knot = knots.iter().position(|&(kx, ky)| (kx - nx).abs() < 0.01 && (ky - ny).abs() < 0.01); changed = true; } } if pointer_down { if let (Some(ki), true) = (self.curve_drag_knot, true) { let (_nx, ny) = from_screen(pos); if curve_ch == 3 { if ki < layer.master_knots.len() { let kx = layer.master_knots[ki].0; layer.master_knots[ki] = (kx, ny); changed = true; } } else { if ki < layer.curve_knots[curve_ch].len() { let kx = layer.curve_knots[curve_ch][ki].0; layer.curve_knots[curve_ch][ki] = (kx, ny); changed = true; } } } } if pointer_released { self.curve_drag_knot = None; } } else if pointer_released { self.curve_drag_knot = None; } } // Right-click to remove let right_clicked = ui.input(|i| i.pointer.secondary_released()); if right_clicked { if let Some(pos) = pointer_pos { if rect.contains(pos) { if let Some(ki) = find_nearest(pos, &active_knots) { let knots = if curve_ch == 3 { &mut layer.master_knots } else { &mut layer.curve_knots[curve_ch] }; if ki != 0 && ki != knots.len() - 1 { knots.remove(ki); changed = true; self.curve_drag_knot = None; } } } } } if changed { // Rebuild master LUT let new_master = spline_lut(&layer.master_knots); // Apply master to all channels, then apply per-channel overrides for ch in 0..3usize { let new_lut = if layer.curve_knots[ch].len() <= 2 { // No per-channel override, use master directly new_master.clone() } else { // Per-channel override: use per-channel knots spline_lut(&layer.curve_knots[ch]) }; match ch { 0 => *lut_r = new_lut, 1 => *lut_g = new_lut, _ => *lut_b = new_lut, } } layer.dirty = true; self.composite_dirty = true; } } // Layer Effects panel if !layer.effects.is_empty() { ui.separator(); ui.horizontal(|ui| { ui.strong("Layer Effects"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(format!("{} effects", layer.effects.len())) .color(Color32::from_gray(120)).small()); }); }); ui.add_space(2.0); let fx_icons = [ ("โ†•", Color32::from_rgb(180, 140, 100)), // Drop Shadow ("โ†•", Color32::from_rgb(100, 140, 180)), // Inner Shadow ("โ˜€", Color32::from_rgb(200, 180, 100)), // Outer Glow ("โ˜€", Color32::from_rgb(100, 180, 200)), // Inner Glow ("โ—†", Color32::from_rgb(180, 180, 180)), // Bevel ("โ—‡", Color32::from_rgb(160, 140, 180)), // Satin ("โ– ", Color32::from_rgb(200, 100, 100)), // Color Overlay ("โ– ", Color32::from_rgb(100, 200, 100)), // Gradient Overlay ("โ– ", Color32::from_rgb(100, 100, 200)), // Pattern Overlay ("โ–ก", Color32::from_rgb(200, 200, 200)), // Stroke ]; for (ei, fx) in layer.effects.iter().enumerate() { let enabled = fx.is_enabled(); let name = fx.effect_name(); let icon_idx = match fx { hcie_fx::LayerEffect::DropShadow { .. } => 0, hcie_fx::LayerEffect::InnerShadow { .. } => 1, hcie_fx::LayerEffect::OuterGlow { .. } => 2, hcie_fx::LayerEffect::InnerGlow { .. } => 3, hcie_fx::LayerEffect::BevelEmboss { .. } => 4, hcie_fx::LayerEffect::Satin { .. } => 5, hcie_fx::LayerEffect::ColorOverlay { .. } => 6, hcie_fx::LayerEffect::GradientOverlay { .. } => 7, hcie_fx::LayerEffect::PatternOverlay { .. } => 8, hcie_fx::LayerEffect::Stroke { .. } => 9, }; let (icon, icon_color) = fx_icons[icon_idx]; let text_color = if enabled { ui.visuals().text_color() } else { ui.visuals().weak_text_color().gamma_multiply(0.5) }; // Effect header row let _bg_color = if enabled { Color32::from_rgba_premultiplied(60, 60, 80, 180) } else { Color32::from_rgba_premultiplied(40, 40, 45, 120) }; let header_rect = ui.available_rect_before_wrap(); let _header_rect = Rect::from_min_size(header_rect.min, Vec2::new(header_rect.width(), 20.0)); ui.horizontal(|ui| { // Visibility icon (eye) let eye = if enabled { "โ—" } else { "โ—‹" }; let eye_color = if enabled { Color32::from_rgb(100, 200, 100) } else { Color32::from_gray(80) }; ui.label(egui::RichText::new(eye).color(eye_color).size(10.0)); // Effect icon ui.label(egui::RichText::new(icon).color(icon_color).size(12.0)); // Effect name ui.label(egui::RichText::new(name).color(text_color).strong()); // Status badge ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if enabled { ui.label(egui::RichText::new("ON") .color(Color32::from_rgb(80, 180, 80)).small().strong()); } else { ui.label(egui::RichText::new("OFF") .color(Color32::from_gray(80)).small()); } }); }); // Effect details (indented) if enabled { match fx { hcie_fx::LayerEffect::DropShadow { blend_mode, color, opacity, angle, distance, size, spread, .. } | hcie_fx::LayerEffect::InnerShadow { blend_mode, color, opacity, angle, distance, size, choke: spread, .. } => { ui.indent(ei, |ui| { ui.horizontal(|ui| { // Color swatch let c = Color32::from_rgb(color[0], color[1], color[2]); let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), egui::Sense::hover()); ui.painter().rect_filled(rect, 2.0, c); ui.label(egui::RichText::new(format!("{:?}", blend_mode)).small()); ui.label(egui::RichText::new(format!("{:.0}%", opacity * 100.0)).small().color(Color32::from_gray(160))); }); ui.label(egui::RichText::new(format!( "Angle {:.0}ยฐ Distance {:.0}px Size {:.0}px Spread {:.0}%", angle, distance, size, spread )).small().color(Color32::from_gray(140))); }); } hcie_fx::LayerEffect::OuterGlow { blend_mode, color, opacity, size, spread, .. } | hcie_fx::LayerEffect::InnerGlow { blend_mode, color, opacity, size, choke: spread, .. } => { ui.indent(ei, |ui| { ui.horizontal(|ui| { let c = Color32::from_rgb(color[0], color[1], color[2]); let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), egui::Sense::hover()); ui.painter().rect_filled(rect, 2.0, c); ui.label(egui::RichText::new(format!("{:?}", blend_mode)).small()); ui.label(egui::RichText::new(format!("{:.0}%", opacity * 100.0)).small().color(Color32::from_gray(160))); }); ui.label(egui::RichText::new(format!("Size {:.0}px Spread {:.0}%", size, spread)).small().color(Color32::from_gray(140))); }); } hcie_fx::LayerEffect::BevelEmboss { style, technique, depth, size, angle, altitude, highlight_blend, shadow_blend, .. } => { ui.indent(ei, |ui| { ui.label(egui::RichText::new(format!("{:?} ยท {:?}", style, technique)).small()); ui.label(egui::RichText::new(format!( "Depth {:.1} Size {:.0}px Angle {:.0}ยฐ Altitude {:.0}ยฐ", depth, size, angle, altitude )).small().color(Color32::from_gray(140))); ui.label(egui::RichText::new(format!( "Highlight: {:?} Shadow: {:?}", highlight_blend, shadow_blend )).small().color(Color32::from_gray(140))); }); } hcie_fx::LayerEffect::Satin { blend_mode, color, opacity, angle, distance, size, invert, .. } => { ui.indent(ei, |ui| { ui.horizontal(|ui| { let c = Color32::from_rgb(color[0], color[1], color[2]); let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), egui::Sense::hover()); ui.painter().rect_filled(rect, 2.0, c); ui.label(egui::RichText::new(format!("{:?}", blend_mode)).small()); ui.label(egui::RichText::new(format!("{:.0}%", opacity * 100.0)).small().color(Color32::from_gray(160))); }); ui.label(egui::RichText::new(format!( "Angle {:.0}ยฐ Distance {:.0}px Size {:.0}px Invert: {}", angle, distance, size, if *invert { "Yes" } else { "No" } )).small().color(Color32::from_gray(140))); }); } hcie_fx::LayerEffect::ColorOverlay { blend_mode, color, opacity, .. } => { ui.indent(ei, |ui| { ui.horizontal(|ui| { let c = Color32::from_rgb(color[0], color[1], color[2]); let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), egui::Sense::hover()); ui.painter().rect_filled(rect, 2.0, c); ui.label(egui::RichText::new(format!("{:?}", blend_mode)).small()); ui.label(egui::RichText::new(format!("{:.0}%", opacity * 100.0)).small().color(Color32::from_gray(160))); }); }); } hcie_fx::LayerEffect::GradientOverlay { blend_mode, opacity, angle, scale, .. } => { ui.indent(ei, |ui| { ui.label(egui::RichText::new(format!("{:?}", blend_mode)).small()); ui.label(egui::RichText::new(format!( "Opacity {:.0}% Angle {:.0}ยฐ Scale {:.0}%", opacity * 100.0, angle, scale )).small().color(Color32::from_gray(140))); }); } hcie_fx::LayerEffect::PatternOverlay { blend_mode, opacity, scale, .. } => { ui.indent(ei, |ui| { ui.label(egui::RichText::new(format!("{:?}", blend_mode)).small()); ui.label(egui::RichText::new(format!( "Opacity {:.0}% Scale {:.0}%", opacity * 100.0, scale )).small().color(Color32::from_gray(140))); }); } hcie_fx::LayerEffect::Stroke { position, fill_type, blend_mode, opacity, size, color, .. } => { ui.indent(ei, |ui| { ui.horizontal(|ui| { let c = Color32::from_rgb(color[0], color[1], color[2]); let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), egui::Sense::hover()); ui.painter().rect_filled(rect, 2.0, c); ui.label(egui::RichText::new(format!("{:?} {:?}", position, fill_type)).small()); }); ui.label(egui::RichText::new(format!( "{:?} {:.0}% {:.0}px", blend_mode, opacity * 100.0, size )).small().color(Color32::from_gray(140))); }); } } } ui.add_space(2.0); } } } else { ui.label("No layer selected"); } }); }); // Canvas ui.allocate_ui(Vec2::new(canvas_w, available.y), |ui| { ui.vertical(|ui| { let (resp, painter) = ui.allocate_painter(ui.available_size() - Vec2::new(0.0, if !self.compare_stats.is_empty() { 120.0 } else { 0.0 }), egui::Sense::click_and_drag()); let rect = resp.rect; let scroll = ui.input(|i| i.smooth_scroll_delta.y); if scroll != 0.0 && resp.hovered() { let factor = if ui.input(|i| i.modifiers.command) { if scroll > 0.0 { 1.1 } else { 1.0 / 1.1 } } else { (scroll / 200.0).exp() }; let old = self.zoom; self.zoom = (old * factor).clamp(ZOOM_MIN, ZOOM_MAX); if let Some(mpos) = ui.input(|i| i.pointer.hover_pos()) { let actual = self.zoom / old; self.pan_offset += (mpos - rect.center() - self.pan_offset) * (1.0 - actual); } } if resp.dragged_by(egui::PointerButton::Middle) { self.pan_offset += resp.drag_delta(); } checkerboard_painter(&painter, rect, CHECKER_SIZE, Color32::from_gray(160), Color32::from_gray(200)); let active_tex = match self.view_mode { 1 => self.ref_texture.as_ref(), 2 => self.diff_texture.as_ref(), _ => self.composite_texture.as_ref(), }; if let Some(tex) = active_tex { let tex_w = tex.size()[0] as f32; let tex_h = tex.size()[1] as f32; let cw = tex_w * self.zoom; let ch = tex_h * self.zoom; let center = rect.center() + self.pan_offset; let img_rect = Rect::from_center_size(center, Vec2::new(cw, ch)); painter.image(tex.id(), img_rect, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), Color32::WHITE); painter.rect_stroke(img_rect, 0.0, Stroke::new(1.0, Color32::from_gray(100)), StrokeKind::Outside); } else { let center = rect.center() + self.pan_offset; let r = Rect::from_center_size(center, Vec2::new(self.canvas_w as f32 * self.zoom, self.canvas_h as f32 * self.zoom)); painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(100)), StrokeKind::Outside); } // Show comparison stats below canvas if !self.compare_stats.is_empty() { ui.separator(); ui.strong("Comparison Stats"); ui.label(egui::RichText::new(&self.compare_stats).monospace().small()); } }); }); }); }); if self.composite_dirty { ctx.request_repaint(); } } } fn to_blend_mode(mode: hcie_protocol::BlendMode) -> BlendMode { match mode { hcie_protocol::BlendMode::Normal => BlendMode::Normal, hcie_protocol::BlendMode::Dissolve => BlendMode::Dissolve, hcie_protocol::BlendMode::Darken => BlendMode::Darken, hcie_protocol::BlendMode::Multiply => BlendMode::Multiply, hcie_protocol::BlendMode::ColorBurn => BlendMode::ColorBurn, hcie_protocol::BlendMode::LinearBurn => BlendMode::LinearBurn, hcie_protocol::BlendMode::DarkerColor => BlendMode::DarkerColor, hcie_protocol::BlendMode::Lighten => BlendMode::Lighten, hcie_protocol::BlendMode::Screen => BlendMode::Screen, hcie_protocol::BlendMode::ColorDodge => BlendMode::ColorDodge, hcie_protocol::BlendMode::LinearDodge => BlendMode::LinearDodge, hcie_protocol::BlendMode::LighterColor=> BlendMode::LighterColor, hcie_protocol::BlendMode::Overlay => BlendMode::Overlay, hcie_protocol::BlendMode::SoftLight => BlendMode::SoftLight, hcie_protocol::BlendMode::HardLight => BlendMode::HardLight, hcie_protocol::BlendMode::VividLight => BlendMode::VividLight, hcie_protocol::BlendMode::LinearLight => BlendMode::LinearLight, hcie_protocol::BlendMode::PinLight => BlendMode::PinLight, hcie_protocol::BlendMode::HardMix => BlendMode::HardMix, hcie_protocol::BlendMode::Difference => BlendMode::Difference, hcie_protocol::BlendMode::Exclusion => BlendMode::Exclusion, hcie_protocol::BlendMode::Subtract => BlendMode::Subtract, hcie_protocol::BlendMode::Divide => BlendMode::Divide, hcie_protocol::BlendMode::Hue => BlendMode::Hue, hcie_protocol::BlendMode::Saturation => BlendMode::Saturation, hcie_protocol::BlendMode::Color => BlendMode::Color, hcie_protocol::BlendMode::Luminosity => BlendMode::Luminosity, hcie_protocol::BlendMode::PassThrough => BlendMode::Normal, } } fn from_blend_mode(mode: BlendMode) -> hcie_protocol::BlendMode { match mode { BlendMode::Normal => hcie_protocol::BlendMode::Normal, BlendMode::Dissolve => hcie_protocol::BlendMode::Dissolve, BlendMode::Darken => hcie_protocol::BlendMode::Darken, BlendMode::Multiply => hcie_protocol::BlendMode::Multiply, BlendMode::ColorBurn => hcie_protocol::BlendMode::ColorBurn, BlendMode::LinearBurn => hcie_protocol::BlendMode::LinearBurn, BlendMode::DarkerColor => hcie_protocol::BlendMode::DarkerColor, BlendMode::Lighten => hcie_protocol::BlendMode::Lighten, BlendMode::Screen => hcie_protocol::BlendMode::Screen, BlendMode::ColorDodge => hcie_protocol::BlendMode::ColorDodge, BlendMode::LinearDodge => hcie_protocol::BlendMode::LinearDodge, BlendMode::LighterColor=> hcie_protocol::BlendMode::LighterColor, BlendMode::Overlay => hcie_protocol::BlendMode::Overlay, BlendMode::SoftLight => hcie_protocol::BlendMode::SoftLight, BlendMode::HardLight => hcie_protocol::BlendMode::HardLight, BlendMode::VividLight => hcie_protocol::BlendMode::VividLight, BlendMode::LinearLight => hcie_protocol::BlendMode::LinearLight, BlendMode::PinLight => hcie_protocol::BlendMode::PinLight, BlendMode::HardMix => hcie_protocol::BlendMode::HardMix, BlendMode::Difference => hcie_protocol::BlendMode::Difference, BlendMode::Exclusion => hcie_protocol::BlendMode::Exclusion, BlendMode::Subtract => hcie_protocol::BlendMode::Subtract, BlendMode::Divide => hcie_protocol::BlendMode::Divide, BlendMode::Hue => hcie_protocol::BlendMode::Hue, BlendMode::Saturation => hcie_protocol::BlendMode::Saturation, BlendMode::Color => hcie_protocol::BlendMode::Color, BlendMode::Luminosity => hcie_protocol::BlendMode::Luminosity, BlendMode::PassThrough => hcie_protocol::BlendMode::Normal, } } fn thumbnail_nearest(pixels: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec { let mut out = vec![0u8; (dw * dh * 4) as usize]; for dy in 0..dh { for dx in 0..dw { let sx = (dx * sw / dw).min(sw.saturating_sub(1)); let sy = (dy * sh / dh).min(sh.saturating_sub(1)); let si = ((sy * sw + sx) * 4) as usize; let di = ((dy * dw + dx) * 4) as usize; out[di..di + 4].copy_from_slice(&pixels[si..si + 4]); } } out } fn checkerboard_painter(painter: &egui::Painter, rect: Rect, ts: f32, ca: Color32, cb: Color32) { let mut y = rect.min.y; let mut row = 0; while y < rect.max.y { let mut x = rect.min.x; let mut col = 0; while x < rect.max.x { let fill = if (row + col) % 2 == 0 { ca } else { cb }; painter.rect_filled(Rect::from_min_max( Pos2::new(x, y), Pos2::new((x + ts).min(rect.max.x), (y + ts).min(rect.max.y)), ), 0.0, fill); x += ts; col += 1; } y += ts; row += 1; } } fn extract_knots(lut: &[u8]) -> Vec<(f32, f32)> { let mut knots = vec![(0.0, lut[0] as f32), (255.0, lut[255] as f32)]; fn sub(lut: &[u8], out: &mut Vec<(f32, f32)>, x0: f32, y0: f32, x1: f32, y1: f32, d: usize) { if d > 8 { return; } let xm = (x0 + x1) / 2.0; let ym = lut[xm.round() as usize].min(255) as f32; let yi = (y0 + y1) / 2.0; if (ym - yi).abs() > 5.0 { out.push((xm, ym)); sub(lut, out, x0, y0, xm, ym, d + 1); sub(lut, out, xm, ym, x1, y1, d + 1); } } sub(lut, &mut knots, 0.0, lut[0] as f32, 255.0, lut[255] as f32, 0); knots.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); knots.dedup_by(|a, b| (a.0 - b.0).abs() < 0.5); knots } fn spline_lut(knots: &[(f32, f32)]) -> Vec { let n = knots.len(); let mut lut = vec![0u8; 256]; if n == 0 { for i in 0..256 { lut[i] = i as u8; } return lut; } if n == 1 { let v = knots[0].1.clamp(0.0, 255.0).round() as u8; return vec![v; 256]; } let mut pts: Vec<(f32, f32)> = knots.to_vec(); pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); pts.dedup_by(|a, b| (a.0 - b.0).abs() < 0.01); let n = pts.len(); if n < 2 { let v = pts[0].1.clamp(0.0, 255.0).round() as u8; return vec![v; 256]; } let mut h = vec![0.0f32; n - 1]; for i in 0..n - 1 { h[i] = pts[i + 1].0 - pts[i].0; } let mut alpha = vec![0.0f32; n]; for i in 1..n - 1 { alpha[i] = 3.0 * ((pts[i + 1].1 - pts[i].1) / h[i] - (pts[i].1 - pts[i - 1].1) / h[i - 1]); } let mut l = vec![1.0f32; n]; let mut mu = vec![0.0f32; n]; let mut z = vec![0.0f32; n]; for i in 1..n - 1 { l[i] = 2.0 * (pts[i + 1].0 - pts[i - 1].0) - h[i - 1] * mu[i - 1]; if l[i].abs() < 1e-12 { continue; } mu[i] = h[i] / l[i]; z[i] = (alpha[i] - h[i - 1] * z[i - 1]) / l[i]; } let mut c = vec![0.0f32; n]; let mut b = vec![0.0f32; n.saturating_sub(1)]; let mut d = vec![0.0f32; n.saturating_sub(1)]; for j in (0..n - 1).rev() { c[j] = z[j] - mu[j] * c[j + 1]; b[j] = (pts[j + 1].1 - pts[j].1) / h[j] - h[j] * (c[j + 1] + 2.0 * c[j]) / 3.0; d[j] = (c[j + 1] - c[j]) / (3.0 * h[j]); } for i in 0..256 { let x = i as f32; if x <= pts[0].0 { lut[i] = pts[0].1.clamp(0.0, 255.0).round() as u8; } else if x >= pts[n - 1].0 { lut[i] = pts[n - 1].1.clamp(0.0, 255.0).round() as u8; } else { for j in 0..n - 1 { if x >= pts[j].0 && x <= pts[j + 1].0 { let dx = x - pts[j].0; let y = pts[j].1 + b[j] * dx + c[j] * dx * dx + d[j] * dx * dx * dx; lut[i] = y.clamp(0.0, 255.0).round() as u8; break; } } } } lut } fn main() -> eframe::Result { let mut opts = NativeOptions::default(); opts.viewport.inner_size = Some(Vec2::new(1400.0, 800.0)); eframe::run_native("hcie-io Test", opts, Box::new(|_cc| Ok(Box::new(HcieIoApp::default())))) }