Phase 3.5a: SetImageFillAction + Info-Panel image-fill picker

- 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.
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 00:29:34 -04:00
parent 659bc5fb02
commit 6fc3a131a6
3 changed files with 117 additions and 1 deletions

View File

@ -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;

View File

@ -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<FillId>,
/// `Some(asset_id)` to set, `None` to clear.
new_image: Option<Uuid>,
/// Per-fill previous `image_fill`, for undo.
old: Vec<(FillId, Option<Uuid>)>,
}
impl SetImageFillAction {
pub fn new(layer_id: Uuid, time: f64, fill_ids: Vec<FillId>, image: Option<Uuid>) -> 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()
}
}

View File

@ -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<lightningbeam_core::vector_graph::EdgeId> = 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<Uuid> = {
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:");