raster: info-panel size + "Layer to document size" (scale/expand-crop)
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.
This commit is contained in:
parent
69939c066d
commit
5869e3ced1
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
}
|
||||
|
||||
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<Vec<OldKeyframe>>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ pub struct InfopanelPane {
|
|||
selected_tool_gradient_stop: Option<usize>,
|
||||
/// FPS value captured when a drag/focus-in starts (for single-undo-action on commit)
|
||||
fps_drag_start: Option<f64>,
|
||||
/// 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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue