From 5869e3ced17ca461c78f5198d15105c6d785ce36 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 26 Jun 2026 15:40:16 -0400 Subject: [PATCH] raster: info-panel size + "Layer to document size" (scale/expand-crop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resizing the document leaves raster layers as-is (canvas keeps its old pixel size, anchored top-left). To reconcile, the info panel now shows the active raster layer's canvas size — driven by the *active* layer, not selection focus, since painting doesn't focus the layer — and, when it differs from the document, a Scale / Expand-Crop toggle + a "Layer to document size" button. - RasterKeyframe::resize_to(w, h, mode): always applies the new declared size; resamples (Lanczos3) when Scale, top-left pad/trim when Canvas, and only touches the buffer when pixels are resident (a blank canvas just takes the new size). Sets texture_dirty so the stage's dirty-scan refreshes the GPU texture. - ResizeRasterLayerAction resizes every keyframe with undo, holding a (read-only) RasterStore so paged-out keyframes are loaded one at a time rather than bulk. Resized keyframes stay resident + dirty and persist on the next full save (no incremental store write to page them back out). Scale is lossy/compounds; Expand-Crop is lossless. --- .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/resize_raster_layer.rs | 90 +++++++++++++++++++ .../lightningbeam-core/src/raster_layer.rs | 51 +++++++++++ .../src/panes/infopanel.rs | 56 ++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/resize_raster_layer.rs diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index ed209cf..29db4f5 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_diff; pub mod raster_stroke; pub mod raster_fill; pub mod add_raster_keyframe; +pub mod resize_raster_layer; pub mod move_layer; pub mod set_fill_paint; pub mod set_image_fill; @@ -77,6 +78,7 @@ pub use group_layers::GroupLayersAction; pub use raster_stroke::RasterStrokeAction; pub use raster_fill::RasterFillAction; pub use add_raster_keyframe::AddRasterKeyframeAction; +pub use resize_raster_layer::ResizeRasterLayerAction; pub use move_layer::MoveLayerAction; pub use set_fill_paint::SetFillPaintAction; pub use set_image_fill::SetImageFillAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/resize_raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/actions/resize_raster_layer.rs new file mode 100644 index 0000000..7fa5420 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/resize_raster_layer.rs @@ -0,0 +1,90 @@ +//! Resize every keyframe of a raster layer to a new canvas size (Scale or Canvas mode), with undo. +//! +//! Used by the info panel's "Layer to document size" button. Pixels must be resident (the editor +//! faults them in first) so the resample/copy is exact and a later page-in won't mismatch the size. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::raster_layer::RasterResizeMode; +use crate::raster_store::RasterStore; +use uuid::Uuid; + +/// Per-keyframe state captured for undo. +struct OldKeyframe { + id: Uuid, + width: u32, + height: u32, + pixels: Vec, +} + +pub struct ResizeRasterLayerAction { + layer_id: Uuid, + new_w: u32, + new_h: u32, + mode: RasterResizeMode, + /// Read-only page-in for keyframes whose pixels aren't resident. (No incremental write exists, so + /// resized keyframes stay resident + dirty and persist on the next full save.) + store: RasterStore, + /// Captured on first execute for rollback. + old: Option>, +} + +impl ResizeRasterLayerAction { + pub fn new(layer_id: Uuid, new_w: u32, new_h: u32, mode: RasterResizeMode, store: RasterStore) -> Self { + Self { layer_id, new_w, new_h, mode, store, old: None } + } +} + +impl Action for ResizeRasterLayerAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&self.layer_id) else { + return Err("ResizeRasterLayerAction: layer is not a raster layer".into()); + }; + let capture = self.old.is_none(); + let mut old = Vec::new(); + for kf in rl.keyframes.iter_mut() { + // Page the keyframe in one at a time so we never hold the whole layer in memory at once. + if kf.raw_pixels.is_empty() { + if let Some(px) = self.store.load_pixels(kf.id) { + kf.raw_pixels = px; + kf.needs_fault_in = false; + } + } + if capture { + old.push(OldKeyframe { id: kf.id, width: kf.width, height: kf.height, pixels: kf.raw_pixels.clone() }); + } + kf.resize_to(self.new_w, self.new_h, self.mode); + } + if capture { + self.old = Some(old); + } + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&self.layer_id) else { + return Err("ResizeRasterLayerAction: layer is not a raster layer".into()); + }; + if let Some(old) = &self.old { + for o in old { + if let Some(kf) = rl.keyframes.iter_mut().find(|kf| kf.id == o.id) { + kf.width = o.width; + kf.height = o.height; + kf.raw_pixels = o.pixels.clone(); + kf.proxy = None; + kf.texture_dirty = true; + kf.dirty = true; + } + } + } + Ok(()) + } + + fn description(&self) -> String { + match self.mode { + RasterResizeMode::Scale => "Scale raster layer".into(), + RasterResizeMode::Canvas => "Resize raster canvas".into(), + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index 6a14d11..d158fd9 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -169,6 +169,57 @@ impl RasterKeyframe { proxy: None, } } + + /// Change the canvas to `(new_w, new_h)`. `Scale` resamples the content (Lanczos3) to fill the + /// new size; `Canvas` keeps the content at native resolution anchored top-left, padding with + /// transparent (expand) or trimming (crop). No-op if the size is unchanged. If pixels aren't + /// resident the buffer is left empty and only the declared size changes — the caller must fault + /// pixels in first (a later load would otherwise mismatch the new size). + pub fn resize_to(&mut self, new_w: u32, new_h: u32, mode: RasterResizeMode) { + if new_w == 0 || new_h == 0 || (self.width == new_w && self.height == new_h) { + return; + } + // Resample/recanvas the buffer only when pixels are resident. A blank keyframe (no content + // and no store row) just takes the new declared size — there's nothing to corrupt. Paged-out + // keyframes are loaded by the caller (ResizeRasterLayerAction) before this runs. + if !self.raw_pixels.is_empty() { + let old = std::mem::take(&mut self.raw_pixels); + self.raw_pixels = match mode { + RasterResizeMode::Scale => match image::RgbaImage::from_raw(self.width, self.height, old) { + Some(img) => image::imageops::resize(&img, new_w, new_h, image::imageops::FilterType::Lanczos3).into_raw(), + None => vec![0u8; (new_w as usize) * (new_h as usize) * 4], + }, + RasterResizeMode::Canvas => { + // Copy the old pixels into a transparent new buffer, anchored top-left (matching + // the raster's (0,0) document anchor); right/bottom is padded or trimmed. + let mut buf = vec![0u8; (new_w as usize) * (new_h as usize) * 4]; + let copy_w = self.width.min(new_w) as usize; + let copy_h = self.height.min(new_h) as usize; + let (ow, nw) = (self.width as usize, new_w as usize); + for y in 0..copy_h { + let src = y * ow * 4; + let dst = y * nw * 4; + buf[dst..dst + copy_w * 4].copy_from_slice(&old[src..src + copy_w * 4]); + } + buf + } + }; + self.proxy = None; // invalidate any downsampled proxy + } + self.width = new_w; + self.height = new_h; + self.texture_dirty = true; + self.dirty = true; + } +} + +/// How a raster canvas resize treats existing pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RasterResizeMode { + /// Resample the content to fill the new size (changes pixel resolution). + Scale, + /// Keep content at native resolution, anchored top-left; pad/trim the canvas. + Canvas, } /// A pixel-buffer painting layer diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index f730d72..0cb524a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -44,6 +44,8 @@ pub struct InfopanelPane { selected_tool_gradient_stop: Option, /// FPS value captured when a drag/focus-in starts (for single-undo-action on commit) fps_drag_start: Option, + /// Resize mode for the active raster layer's "to document size" action (scale vs canvas). + raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode, } impl InfopanelPane { @@ -61,6 +63,7 @@ impl InfopanelPane { selected_shape_gradient_stop: None, selected_tool_gradient_stop: None, fps_drag_start: None, + raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale, } } } @@ -1197,6 +1200,53 @@ impl InfopanelPane { }); } + /// Render a raster-layer section: shows the active keyframe's canvas dimensions and, when they + /// differ from the document, a Scale/Expand-Crop mode toggle + a "Layer to document size" button. + /// Driven by the *active* layer (not selection focus), since painting doesn't focus the layer. + fn render_raster_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_id: Uuid) { + use lightningbeam_core::raster_layer::RasterResizeMode; + + // Pull the values, then drop the document borrow before mutating `shared`. + let time = *shared.playback_time; + let dims = { + let document = shared.action_executor.document(); + match document.get_layer(&layer_id) { + Some(AnyLayer::Raster(rl)) => rl + .keyframe_at(time) + .map(|kf| (kf.width, kf.height, document.width as u32, document.height as u32)), + _ => None, + } + }; + let Some((kf_w, kf_h, doc_w, doc_h)) = dims else { return }; + + egui::CollapsingHeader::new("Raster Layer") + .id_salt(("raster_layer", path)) + .default_open(true) + .show(ui, |ui| { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Size:"); + ui.label(format!("{} × {}", kf_w, kf_h)); + }); + + if kf_w != doc_w || kf_h != doc_h { + ui.horizontal(|ui| { + ui.label("Mode:"); + ui.selectable_value(&mut self.raster_resize_mode, RasterResizeMode::Scale, "Scale"); + ui.selectable_value(&mut self.raster_resize_mode, RasterResizeMode::Canvas, "Expand/Crop"); + }); + if ui.button(format!("Layer to document size ({} × {})", doc_w, doc_h)).clicked() { + let store = lightningbeam_core::raster_store::RasterStore::new(shared.container_path.clone()); + let action = lightningbeam_core::actions::ResizeRasterLayerAction::new( + layer_id, doc_w, doc_h, self.raster_resize_mode, store, + ); + shared.pending_actions.push(Box::new(action)); + } + } + ui.add_space(4.0); + }); + } + /// Render clip instance info section fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) { let document = shared.action_executor.document(); @@ -1547,6 +1597,12 @@ impl PaneRenderer for InfopanelPane { } } + // Active raster layer's size + "to document size" — shown whenever a raster layer is + // active (independent of selection focus, since painting doesn't focus the layer). + if let Some(active_id) = *shared.active_layer_id { + self.render_raster_layer_section(ui, path, shared, active_id); + } + // Onion-skinning view settings — always available, regardless of selection. ui.add_space(8.0); ui.separator();