From 6fc3a131a6f7678b8cd3393024fd71e4a7ab5ec3 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 21 Jun 2026 00:29:34 -0400 Subject: [PATCH] Phase 3.5a: SetImageFillAction + Info-Panel image-fill picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SetImageFillAction (core): set/clear `image_fill` on the selected VectorGraph fills, with per-fill undo (mirrors SetFillPaintAction). Image takes render priority; clearing reveals the colour/gradient underneath. - Info Panel Shape section: an "Image:" combo listing the document's image assets (+ None) for the selected fill(s), showing the current assignment. Assign/clear pushes the action. This lets an existing shape be given (or cleared of) an image fill, complementing the import/drop placement. Next: 3.5b — persist image assets in the .beam container. --- .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/set_image_fill.rs | 67 +++++++++++++++++++ .../src/panes/infopanel.rs | 49 +++++++++++++- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/set_image_fill.rs diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index cc23c0c..0c96a79 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -40,6 +40,7 @@ pub mod raster_fill; pub mod add_raster_keyframe; pub mod move_layer; pub mod set_fill_paint; +pub mod set_image_fill; pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; @@ -75,5 +76,6 @@ pub use raster_fill::RasterFillAction; pub use add_raster_keyframe::AddRasterKeyframeAction; pub use move_layer::MoveLayerAction; pub use set_fill_paint::SetFillPaintAction; +pub use set_image_fill::SetImageFillAction; pub use change_bpm::ChangeBpmAction; pub use change_fps::ChangeFpsAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_image_fill.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_image_fill.rs new file mode 100644 index 0000000..9cf685c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_image_fill.rs @@ -0,0 +1,67 @@ +//! Action that sets or clears the image fill on one or more VectorGraph fills. +//! +//! `image_fill` is an asset id the renderer maps onto the fill's bounding box; it +//! takes priority over colour/gradient. Setting `None` clears it (the colour/gradient +//! underneath shows again). + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::vector_graph::FillId; +use uuid::Uuid; + +pub struct SetImageFillAction { + layer_id: Uuid, + time: f64, + fill_ids: Vec, + /// `Some(asset_id)` to set, `None` to clear. + new_image: Option, + /// Per-fill previous `image_fill`, for undo. + old: Vec<(FillId, Option)>, +} + +impl SetImageFillAction { + pub fn new(layer_id: Uuid, time: f64, fill_ids: Vec, image: Option) -> Self { + Self { layer_id, time, fill_ids, new_image: image, old: Vec::new() } + } + + fn get_graph_mut<'a>( + document: &'a mut Document, + layer_id: &Uuid, + time: f64, + ) -> Result<&'a mut crate::vector_graph::VectorGraph, String> { + let layer = document + .get_layer_mut(layer_id) + .ok_or_else(|| format!("Layer {} not found", layer_id))?; + match layer { + AnyLayer::Vector(vl) => vl + .graph_at_time_mut(time) + .ok_or_else(|| format!("No keyframe at time {}", time)), + _ => Err("Not a vector layer".to_string()), + } + } +} + +impl Action for SetImageFillAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?; + self.old.clear(); + for &fid in &self.fill_ids { + self.old.push((fid, graph.fill(fid).image_fill)); + graph.fill_mut(fid).image_fill = self.new_image; + } + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?; + for &(fid, old) in &self.old { + graph.fill_mut(fid).image_fill = old; + } + Ok(()) + } + + fn description(&self) -> String { + if self.new_image.is_some() { "Set image fill" } else { "Clear image fill" }.to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 85a57cd..b916206 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -12,7 +12,7 @@ use eframe::egui::{self, DragValue, Ui}; use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings}; -use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction}; +use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction, SetImageFillAction}; use lightningbeam_core::gradient::ShapeGradient; use lightningbeam_core::layer::{AnyLayer, LayerTrait}; use lightningbeam_core::selection::FocusSelection; @@ -785,6 +785,26 @@ impl InfopanelPane { let edge_ids: Vec = shared.selection.selected_edges() .iter().map(|eid| lightningbeam_core::vector_graph::EdgeId(eid.0)).collect(); + // Image-fill state for the selected fills + the document's image assets, for + // the picker below. Gathered now (immutable read) before `shared` is borrowed mut. + let image_assets: Vec<(Uuid, String)> = { + let doc = shared.action_executor.document(); + let mut v: Vec<(Uuid, String)> = doc.image_assets.iter() + .map(|(id, a)| (*id, a.name.clone())).collect(); + v.sort_by(|a, b| a.1.cmp(&b.1)); + v + }; + let current_image_fill: Option = { + let doc = shared.action_executor.document(); + match doc.get_layer(&layer_id) { + Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl + .graph_at_time(time) + .and_then(|g| face_ids.first().map(|&fid| g.fill(fid).image_fill)) + .flatten(), + _ => None, + } + }; + egui::CollapsingHeader::new("Shape") .id_salt(("shape", path)) .default_open(self.shape_section_open) @@ -859,6 +879,33 @@ impl InfopanelPane { } } + // Image fill picker (assign/clear an imported image asset on the fill). + if !face_ids.is_empty() { + ui.horizontal(|ui| { + ui.label("Image:"); + let selected_text = current_image_fill + .and_then(|id| image_assets.iter().find(|(aid, _)| *aid == id)) + .map(|(_, n)| n.clone()) + .unwrap_or_else(|| "None".to_string()); + egui::ComboBox::from_id_salt(("image_fill", path)) + .selected_text(selected_text) + .show_ui(ui, |ui| { + if ui.selectable_label(current_image_fill.is_none(), "None").clicked() { + shared.pending_actions.push(Box::new( + SetImageFillAction::new(layer_id, time, face_ids.clone(), None), + )); + } + for (aid, name) in &image_assets { + if ui.selectable_label(current_image_fill == Some(*aid), name).clicked() { + shared.pending_actions.push(Box::new( + SetImageFillAction::new(layer_id, time, face_ids.clone(), Some(*aid)), + )); + } + } + }); + }); + } + // Stroke color ui.horizontal(|ui| { ui.label("Stroke:");