diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index 35a38a6..3d2a831 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -139,6 +139,156 @@ impl CanvasPair { pub fn swap(&mut self) { self.current = 1 - self.current; } } +// --------------------------------------------------------------------------- +// Raster affine-transform pipeline +// --------------------------------------------------------------------------- + +/// CPU-side parameters for the raster transform compute shader. +/// Must match the `Params` struct in `raster_transform.wgsl` (48 bytes, 16-byte aligned). +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub struct RasterTransformGpuParams { + pub a00: f32, pub a01: f32, // row 0 of 2×2 inverse affine matrix + pub a10: f32, pub a11: f32, // row 1 + pub b0: f32, pub b1: f32, // translation (source pixel offset at output (0,0)) + pub src_w: u32, pub src_h: u32, + pub dst_w: u32, pub dst_h: u32, + pub _pad0: u32, pub _pad1: u32, +} + +/// Compute pipeline for GPU-accelerated affine resampling of raster floats. +/// Created lazily on first transform use. +struct RasterTransformPipeline { + pipeline: wgpu::ComputePipeline, + bind_group_layout: wgpu::BindGroupLayout, +} + +impl RasterTransformPipeline { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("raster_transform_shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("panes/shaders/raster_transform.wgsl").into(), + ), + }); + + let bind_group_layout = device.create_bind_group_layout( + &wgpu::BindGroupLayoutDescriptor { + label: Some("raster_transform_bgl"), + entries: &[ + // 0: params uniform + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // 1: source texture (anchor canvas, sampled) + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // 2: destination texture (float canvas dst, write-only storage) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::Rgba8Unorm, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }, + ); + + let pipeline_layout = device.create_pipeline_layout( + &wgpu::PipelineLayoutDescriptor { + label: Some("raster_transform_pl"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }, + ); + + let pipeline = device.create_compute_pipeline( + &wgpu::ComputePipelineDescriptor { + label: Some("raster_transform_pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }, + ); + + Self { pipeline, bind_group_layout } + } + + /// Dispatch the transform shader: reads from `src_view`, writes to `dst_view`. + /// The caller must call `dst_canvas.swap()` after this returns. + fn render( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + src_view: &wgpu::TextureView, + dst_view: &wgpu::TextureView, + params: RasterTransformGpuParams, + ) { + use wgpu::util::DeviceExt; + + let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("raster_transform_params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("raster_transform_bg"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(src_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(dst_view), + }, + ], + }); + + let mut encoder = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("raster_transform_enc") }, + ); + { + let mut pass = encoder.begin_compute_pass( + &wgpu::ComputePassDescriptor { label: Some("raster_transform_pass"), timestamp_writes: None }, + ); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + let wg_x = params.dst_w.div_ceil(8); + let wg_y = params.dst_h.div_ceil(8); + pass.dispatch_workgroups(wg_x, wg_y, 1); + } + queue.submit(Some(encoder.finish())); + } +} + // --------------------------------------------------------------------------- // GpuBrushEngine // --------------------------------------------------------------------------- @@ -148,6 +298,9 @@ pub struct GpuBrushEngine { compute_pipeline: wgpu::ComputePipeline, compute_bg_layout: wgpu::BindGroupLayout, + /// Lazily created on first raster transform use. + transform_pipeline: Option, + /// Canvas texture pairs keyed by keyframe UUID. pub canvases: HashMap, } @@ -251,6 +404,7 @@ impl GpuBrushEngine { Self { compute_pipeline, compute_bg_layout, + transform_pipeline: None, canvases: HashMap::new(), } } @@ -610,6 +764,45 @@ impl GpuBrushEngine { pub fn remove_canvas(&mut self, keyframe_id: &Uuid) { self.canvases.remove(keyframe_id); } + + /// Dispatch the affine-resample transform shader from `anchor_id` → `float_id`. + /// + /// Reads from the anchor canvas's source view, writes into the float canvas's + /// destination view, then swaps the float canvas so the result becomes the new source. + /// + /// `float_id` must already have been resized to `params.dst_w × params.dst_h` via + /// `ensure_canvas` before calling this. + pub fn render_transform( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + anchor_id: &Uuid, + float_id: &Uuid, + params: RasterTransformGpuParams, + ) { + // Lazily create the transform pipeline. + let pipeline = self.transform_pipeline + .get_or_insert_with(|| RasterTransformPipeline::new(device)); + + // Borrow src_view and dst_view within a block so the borrows end before + // we call swap() on the float canvas. + let dispatched = { + let anchor = self.canvases.get(anchor_id); + let float = self.canvases.get(float_id); + if let (Some(anchor), Some(float)) = (anchor, float) { + pipeline.render(device, queue, anchor.src_view(), float.dst_view(), params); + true + } else { + false + } + }; + + if dispatched { + if let Some(float) = self.canvases.get_mut(float_id) { + float.swap(); + } + } + } } // --------------------------------------------------------------------------- diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 137cbdc..c1bbc3b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -178,7 +178,11 @@ impl InfopanelPane { Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Polygon ); - let has_options = is_vector_tool || is_raster_paint_tool || matches!( + let is_raster_transform = active_is_raster + && matches!(tool, Tool::Transform) + && shared.selection.raster_floating.is_some(); + + let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform || matches!( tool, Tool::PaintBucket | Tool::RegionSelect ); @@ -187,9 +191,11 @@ impl InfopanelPane { return; } - let header_label = raster_tool_def - .map(|d| d.header_label()) - .unwrap_or("Tool Options"); + let header_label = if is_raster_transform { + "Raster Transform" + } else { + raster_tool_def.map(|d| d.header_label()).unwrap_or("Tool Options") + }; egui::CollapsingHeader::new(header_label) .id_salt(("tool_options", path)) @@ -203,6 +209,15 @@ impl InfopanelPane { ui.add_space(2.0); } + // Raster transform tool hint. + if is_raster_transform { + ui.label("Drag handles to move, scale, or rotate."); + ui.add_space(4.0); + ui.label("Enter — apply Esc — cancel"); + ui.add_space(4.0); + return; + } + // Raster paint tool: delegate to per-tool impl. if let Some(def) = raster_tool_def { def.render_ui(ui, shared.raster_settings); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl new file mode 100644 index 0000000..154376e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl @@ -0,0 +1,78 @@ +// GPU affine-transform resample shader. +// +// For each output pixel, computes the corresponding source pixel via an inverse +// 2D affine transform (no perspective) and bilinear-samples from the source texture. +// +// Used by the raster selection transform tool: the source is the immutable "anchor" +// canvas (original float pixels), the destination is the current float canvas. +// +// CPU precomputes the inverse affine matrix components and the output bounding box. +// The shader just does the per-pixel mapping and bilinear interpolation. +// +// Dispatch: ceil(dst_w / 8) × ceil(dst_h / 8) × 1 + +struct Params { + // Inverse affine: src_pixel = A * out_pixel + b + // For output pixel center (ox, oy), source pixel is: + // sx = a00*ox + a01*oy + b0 + // sy = a10*ox + a11*oy + b1 + a00: f32, a01: f32, + a10: f32, a11: f32, + b0: f32, b1: f32, + src_w: u32, src_h: u32, + dst_w: u32, dst_h: u32, + _pad0: u32, _pad1: u32, // pad to 48 bytes (3 × 16) +} + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var src: texture_2d; +@group(0) @binding(2) var dst: texture_storage_2d; + +// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders). +fn bilinear_sample(px: f32, py: f32) -> vec4 { + let sw = i32(params.src_w); + let sh = i32(params.src_h); + + let ix = i32(floor(px - 0.5)); + let iy = i32(floor(py - 0.5)); + let fx = fract(px - 0.5); + let fy = fract(py - 0.5); + + let x0 = clamp(ix, 0, sw - 1); + let x1 = clamp(ix + 1, 0, sw - 1); + let y0 = clamp(iy, 0, sh - 1); + let y1 = clamp(iy + 1, 0, sh - 1); + + let s00 = textureLoad(src, vec2(x0, y0), 0); + let s10 = textureLoad(src, vec2(x1, y0), 0); + let s01 = textureLoad(src, vec2(x0, y1), 0); + let s11 = textureLoad(src, vec2(x1, y1), 0); + + return mix(mix(s00, s10, fx), mix(s01, s11, fx), fy); +} + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) gid: vec3) { + if gid.x >= params.dst_w || gid.y >= params.dst_h { return; } + + let ox = f32(gid.x); + let oy = f32(gid.y); + + // Map output pixel index → source pixel position via inverse affine. + // We use pixel centers (ox + 0.5, oy + 0.5) in the forward transform, but the + // b0/b1 precomputation on the CPU already accounts for the +0.5 offset, so ox/oy + // are used directly here (the CPU bakes +0.5 into b). + let sx = params.a00 * ox + params.a01 * oy + params.b0; + let sy = params.a10 * ox + params.a11 * oy + params.b1; + + var color: vec4; + if sx < 0.0 || sy < 0.0 || sx >= f32(params.src_w) || sy >= f32(params.src_h) { + // Outside source bounds → transparent + color = vec4(0.0); + } else { + // Bilinear sample at pixel center + color = bilinear_sample(sx + 0.5, sy + 0.5); + } + + textureStore(dst, vec2(i32(gid.x), i32(gid.y)), color); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index c057208..ba5ff9c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -402,6 +402,10 @@ struct VelloRenderContext { webcam_frame: Option, /// GPU brush dabs to dispatch in this frame's prepare() call. pending_raster_dabs: Option, + /// GPU affine-resample dispatch for the raster transform tool. + pending_transform_dispatch: Option, + /// When Some, override the float canvas blit with the display canvas during transform. + transform_display: Option, /// Instance ID (for storing readback results in the global map). instance_id_for_readback: u64, /// The (layer_id, keyframe_id) of the raster layer with a live GPU canvas. @@ -586,6 +590,50 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // --- Raster transform dispatch --- + // Runs after dab dispatch; uploads anchor pixels and runs the affine-resample + // shader from anchor → display canvas. + if let Some(ref dispatch) = self.ctx.pending_transform_dispatch { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + // Ensure anchor canvas at original dimensions. + gpu_brush.ensure_canvas(device, dispatch.anchor_canvas_id, dispatch.anchor_w, dispatch.anchor_h); + if let Some(canvas) = gpu_brush.canvases.get(&dispatch.anchor_canvas_id) { + canvas.upload(queue, &dispatch.anchor_pixels); + } + // Ensure display canvas at new (transformed) dimensions. + gpu_brush.ensure_canvas(device, dispatch.display_canvas_id, dispatch.new_w, dispatch.new_h); + // Dispatch the affine-resample shader. + let params = crate::gpu_brush::RasterTransformGpuParams { + a00: dispatch.a00, a01: dispatch.a01, + a10: dispatch.a10, a11: dispatch.a11, + b0: dispatch.b0, b1: dispatch.b1, + src_w: dispatch.anchor_w, src_h: dispatch.anchor_h, + dst_w: dispatch.new_w, dst_h: dispatch.new_h, + _pad0: 0, _pad1: 0, + }; + gpu_brush.render_transform(device, queue, &dispatch.anchor_canvas_id, &dispatch.display_canvas_id, params); + + // Final commit: readback the display canvas so render_content() can swap it in as the new float. + if dispatch.is_final_commit { + if let Some(pixels) = gpu_brush.readback_canvas(device, queue, dispatch.display_canvas_id) { + let results = TRANSFORM_READBACK_RESULTS.get_or_init(|| { + Arc::new(Mutex::new(std::collections::HashMap::new())) + }); + if let Ok(mut map) = results.lock() { + map.insert(self.ctx.instance_id_for_readback, TransformReadbackResult { + pixels, + width: dispatch.new_w, + height: dispatch.new_h, + x: dispatch.new_x, + y: dispatch.new_y, + display_canvas_id: dispatch.display_canvas_id, + }); + } + } + } + } + } + // Generate brush preview thumbnails on first use (one-time, blocking readback). if let Ok(mut previews) = self.ctx.brush_preview_pixels.try_lock() { if previews.is_empty() { @@ -1050,26 +1098,31 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } // Blit the float GPU canvas on top of all composited layers. + // During transform: blit the display canvas (compute shader output) instead of the float. // The float_mask_view clips to the selection shape (None = full float visible). - if let Some(ref float_sel) = self.ctx.selection.raster_floating { - let float_canvas_id = float_sel.canvas_id; - let float_x = float_sel.x; - let float_y = float_sel.y; - let float_w = float_sel.width; - let float_h = float_sel.height; + let blit_params = if let Some(ref td) = self.ctx.transform_display { + // During transform: show the display canvas (compute shader output) instead of float. + Some((td.display_canvas_id, td.x, td.y, td.w, td.h)) + } else if let Some(ref float_sel) = self.ctx.selection.raster_floating { + // Regular float blit. + Some((float_sel.canvas_id, float_sel.x, float_sel.y, float_sel.width, float_sel.height)) + } else { + None + }; + if let Some((blit_canvas_id, blit_x, blit_y, blit_w, blit_h)) = blit_params { if let Ok(gpu_brush) = shared.gpu_brush.lock() { - if let Some(canvas) = gpu_brush.canvases.get(&float_canvas_id) { + if let Some(canvas) = gpu_brush.canvases.get(&blit_canvas_id) { let float_hdr_handle = buffer_pool.acquire(device, hdr_spec); if let (Some(fhdr_view), Some(hdr_view)) = ( buffer_pool.get_view(float_hdr_handle), &instance_resources.hdr_texture_view, ) { let fcamera = crate::gpu_brush::CameraParams { - pan_x: self.ctx.pan_offset.x + float_x as f32 * self.ctx.zoom, - pan_y: self.ctx.pan_offset.y + float_y as f32 * self.ctx.zoom, + pan_x: self.ctx.pan_offset.x + blit_x as f32 * self.ctx.zoom, + pan_y: self.ctx.pan_offset.y + blit_y as f32 * self.ctx.zoom, zoom: self.ctx.zoom, - canvas_w: float_w as f32, - canvas_h: float_h as f32, + canvas_w: blit_w as f32, + canvas_h: blit_h as f32, viewport_w: width as f32, viewport_h: height as f32, _pad: 0.0, @@ -2486,6 +2539,10 @@ pub struct StagePane { /// Clone stamp: (source_world - drag_start_world) computed at stroke start. /// Constant for the entire stroke; cleared when the stroke ends. clone_stroke_offset: Option<(f32, f32)>, + /// Live state for the raster transform tool (scale/rotate/move float). + raster_transform_state: Option, + /// GPU transform work to dispatch in prepare(). + pending_transform_dispatch: Option, /// Synthetic drag/click override for test mode replay (debug builds only) #[cfg(debug_assertions)] replay_override: Option, @@ -2544,6 +2601,94 @@ struct PendingRasterDabs { wants_final_readback: bool, } +/// Which transform handle the user is interacting with. +#[derive(Clone, Copy, PartialEq)] +enum RasterTransformHandle { + Move, + Corner { right: bool, bottom: bool }, + EdgeH { bottom: bool }, + EdgeV { right: bool }, + Rotate, + Origin, // the pivot point, draggable +} + +/// Live state for an ongoing raster transform operation. +struct RasterTransformState { + /// canvas_id of the float when this state was created. If different → stale, reinit. + float_canvas_id: uuid::Uuid, + /// Anchor: original pixels, never written during transform. + anchor_canvas_id: uuid::Uuid, + /// sRGB-encoded pixel data for the anchor canvas (re-uploaded each dispatch). + anchor_pixels: Vec, + anchor_w: u32, + anchor_h: u32, + /// Display canvas: compute shader output, shown in place of float during transform. + display_canvas_id: uuid::Uuid, + /// Center of the transformed bounding box in canvas (world) coords. + cx: f32, cy: f32, + scale_x: f32, scale_y: f32, + /// Rotation in radians. + angle: f32, + /// Pivot point for rotate/scale (defaults to center). + origin_x: f32, origin_y: f32, + /// Which handle is being dragged, if any. + active_handle: Option, + /// Which handle the cursor is currently over (for visual feedback). + hovered_handle: Option, + /// World position where the current drag started. + drag_start_world: egui::Vec2, + /// Snapped values captured at drag start. + snap_cx: f32, snap_cy: f32, + snap_sx: f32, snap_sy: f32, + snap_angle: f32, + snap_origin_x: f32, snap_origin_y: f32, + /// True once at least one GPU transform dispatch has been queued. + transform_applied: bool, + /// True after Enter: waiting for the final readback before clearing state. + wants_apply: bool, +} + +/// GPU work queued by `handle_raster_transform_tool` for `prepare()`. +struct PendingTransformDispatch { + anchor_canvas_id: uuid::Uuid, + /// Anchor pixels — re-uploaded each dispatch to keep the anchor immutable. + anchor_pixels: Vec, + anchor_w: u32, + anchor_h: u32, + /// Display canvas: compute shader output (was float_canvas_id). + display_canvas_id: uuid::Uuid, + /// AABB of the transformed output (for readback result positioning). + new_x: i32, new_y: i32, + /// Output canvas dimensions (may differ from anchor if scaled/rotated). + new_w: u32, + new_h: u32, + /// Inverse affine coefficients: src_pixel = A * out_pixel + b. + a00: f32, a01: f32, + a10: f32, a11: f32, + b0: f32, b1: f32, + /// If true, readback the display canvas after dispatch and store in TRANSFORM_READBACK_RESULTS. + is_final_commit: bool, +} + +/// Pixels read back from the transformed display canvas, stored per-instance. +struct TransformReadbackResult { + pixels: Vec, + width: u32, + height: u32, + x: i32, + y: i32, + display_canvas_id: uuid::Uuid, +} + +/// Sent from StagePane to VelloCallback to override float blit with display canvas. +struct TransformDisplayInfo { + display_canvas_id: uuid::Uuid, + x: i32, y: i32, + w: u32, h: u32, +} + +static TRANSFORM_READBACK_RESULTS: OnceLock>>> = OnceLock::new(); + /// Result stored by `prepare()` after a stroke-end readback. struct RasterReadbackResult { layer_id: uuid::Uuid, @@ -2611,6 +2756,8 @@ impl StagePane { painting_float: false, raster_last_compute_time: 0.0, clone_stroke_offset: None, + raster_transform_state: None, + pending_transform_dispatch: None, #[cfg(debug_assertions)] replay_override: None, } @@ -6206,6 +6353,560 @@ impl StagePane { } } + // ------------------------------------------------------------------------- + // Raster transform tool + // ------------------------------------------------------------------------- + + /// CPU computation for raster transform: output AABB and inverse affine matrix. + /// + /// Returns `(new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1)` where + /// the inverse affine maps output pixel (ox, oy) → source pixel (sx, sy): + /// sx = a00*ox + a01*oy + b0 + /// sy = a10*ox + a11*oy + b1 + fn compute_transform_params( + orig_w: u32, orig_h: u32, + cx: f32, cy: f32, + scale_x: f32, scale_y: f32, + angle: f32, + ) -> (u32, u32, i32, i32, f32, f32, f32, f32, f32, f32) { + let hw = scale_x * orig_w as f32 / 2.0; + let hh = scale_y * orig_h as f32 / 2.0; + let cos_a = angle.cos(); + let sin_a = angle.sin(); + + // Rotate corners of scaled rect around (cx, cy) + let local = [(-hw, -hh), (hw, -hh), (-hw, hh), (hw, hh)]; + let rotated: [(f32, f32); 4] = local.map(|(lx, ly)| { + (cx + lx * cos_a - ly * sin_a, cy + lx * sin_a + ly * cos_a) + }); + + // AABB of rotated corners + let min_x = rotated.iter().map(|p| p.0).fold(f32::INFINITY, f32::min).floor(); + let min_y = rotated.iter().map(|p| p.1).fold(f32::INFINITY, f32::min).floor(); + let max_x = rotated.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max).ceil(); + let max_y = rotated.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max).ceil(); + let new_x = min_x as i32; + let new_y = min_y as i32; + let new_w = ((max_x - min_x).max(1.0)) as u32; + let new_h = ((max_y - min_y).max(1.0)) as u32; + + // Inverse affine: R^-1 * S^-1 + // Forward: dst = cx + R * S * (src_center_offset) + // Inverse: src_pixel = (src_w/2, src_h/2) + S^-1 * R^-1 * (out_pixel - cx, out_pixel_y - cy) + // with out_pixel center accounted for by baking +0.5 into b (CPU side). + let a00 = cos_a / scale_x; + let a01 = sin_a / scale_x; + let a10 = -sin_a / scale_y; + let a11 = cos_a / scale_y; + + // b accounts for the center offset and the new AABB origin. + // For output pixel (ox, oy) at its center (ox + 0.5, oy + 0.5) in output canvas coords, + // the source pixel is: + // (src_w/2, src_h/2) + A^-1 * ((new_x + ox + 0.5) - cx, (new_y + oy + 0.5) - cy) + // We bake (new_x + 0.5 - cx, new_y + 0.5 - cy) into b so the shader just uses ox/oy directly. + let off_x = new_x as f32 + 0.5 - cx; + let off_y = new_y as f32 + 0.5 - cy; + let b0 = orig_w as f32 / 2.0 + a00 * off_x + a01 * off_y; + let b1 = orig_h as f32 / 2.0 + a10 * off_x + a11 * off_y; + + (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) + } + + fn handle_raster_transform_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + // If float was cleared, clear transform state. + if shared.selection.raster_floating.is_none() { + self.raster_transform_state = None; + return; + } + let float_canvas_id = shared.selection.raster_floating.as_ref().unwrap().canvas_id; + + // If the float changed (new selection made), clear and reinit state. + if let Some(ref ts) = self.raster_transform_state { + if ts.float_canvas_id != float_canvas_id { + self.raster_transform_state = None; + } + } + + // --- Lazy init --- + if self.raster_transform_state.is_none() { + let float = shared.selection.raster_floating.as_ref().unwrap(); + let expected_len = (float.width * float.height * 4) as usize; + let anchor_pixels = if float.pixels.len() == expected_len { + float.pixels.clone() + } else { + vec![0u8; expected_len] + }; + let cx = float.x as f32 + float.width as f32 / 2.0; + let cy = float.y as f32 + float.height as f32 / 2.0; + self.raster_transform_state = Some(RasterTransformState { + float_canvas_id: float.canvas_id, + anchor_canvas_id: uuid::Uuid::new_v4(), + anchor_pixels, + anchor_w: float.width, + anchor_h: float.height, + display_canvas_id: uuid::Uuid::new_v4(), + cx, cy, + scale_x: 1.0, scale_y: 1.0, angle: 0.0, + origin_x: cx, origin_y: cy, + active_handle: None, hovered_handle: None, + drag_start_world: world_pos, + snap_cx: cx, snap_cy: cy, + snap_sx: 1.0, snap_sy: 1.0, snap_angle: 0.0, + snap_origin_x: cx, snap_origin_y: cy, + transform_applied: true, + wants_apply: false, + }); + // Queue an identity dispatch immediately so the display canvas is populated + // from frame 1. Without this, Move-only drags don't update the image because + // transform_applied would stay false (no scale/rotate → no needs_dispatch). + let init_dispatch = { + let ts = self.raster_transform_state.as_ref().unwrap(); + let (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) = + Self::compute_transform_params(ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, 1.0, 1.0, 0.0); + PendingTransformDispatch { + anchor_canvas_id: ts.anchor_canvas_id, + anchor_pixels: ts.anchor_pixels.clone(), + anchor_w: ts.anchor_w, + anchor_h: ts.anchor_h, + display_canvas_id: ts.display_canvas_id, + new_x, new_y, new_w, new_h, + a00, a01, a10, a11, b0, b1, + is_final_commit: false, + } + }; + self.pending_transform_dispatch = Some(init_dispatch); + } + + // Early return while waiting for a final readback (wants_apply set, readback pending). + if self.raster_transform_state.as_ref().map_or(false, |ts| ts.wants_apply) { + return; + } + + // --- Keyboard shortcuts --- + // Enter: queue final dispatch + readback, keep state alive until readback completes. + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let dispatch = self.raster_transform_state.as_ref().and_then(|ts| { + if ts.transform_applied { + let (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) = + Self::compute_transform_params(ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, ts.scale_x, ts.scale_y, ts.angle); + Some(PendingTransformDispatch { + anchor_canvas_id: ts.anchor_canvas_id, + anchor_pixels: ts.anchor_pixels.clone(), + anchor_w: ts.anchor_w, anchor_h: ts.anchor_h, + display_canvas_id: ts.display_canvas_id, + new_x, new_y, new_w, new_h, + a00, a01, a10, a11, b0, b1, + is_final_commit: true, + }) + } else { + None + } + }); + if let Some(d) = dispatch { + self.pending_transform_dispatch = Some(d); + // Keep state alive (wants_apply = true) until readback completes. + self.raster_transform_state.as_mut().unwrap().wants_apply = true; + } else { + // No transform was applied — just clear state. + self.raster_transform_state = None; + } + return; + } + + // Escape: float canvas is unchanged — just clear state. + // The anchor/display canvases are orphaned; they'll be freed when the GPU engine + // is next queried (the canvases are small and short-lived). + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + self.raster_transform_state = None; + return; + } + + // Read drag states before the mutable borrow of raster_transform_state. + let drag_started = self.rsp_drag_started(response); + let dragged = self.rsp_dragged(response); + let drag_stopped = self.rsp_drag_stopped(response); + let shift = ui.input(|i| i.modifiers.shift); + + // Collect pending dispatch from the inner block to assign after the borrow ends. + let pending_dispatch; + { + let ts = self.raster_transform_state.as_mut().unwrap(); + + // --- Compute handle positions in world space --- + let hw = ts.scale_x * ts.anchor_w as f32 / 2.0; + let hh = ts.scale_y * ts.anchor_h as f32 / 2.0; + let cos_a = ts.angle.cos(); + let sin_a = ts.angle.sin(); + let zoom = self.zoom; + + // Local offset → world position + let to_world = |lx: f32, ly: f32| -> egui::Vec2 { + egui::vec2(ts.cx + lx * cos_a - ly * sin_a, ts.cy + lx * sin_a + ly * cos_a) + }; + + // Rotate handle: above top-center, 24 screen-pixels outside bbox. + let rotate_offset = 24.0 / zoom; + let rotate_handle = to_world(0.0, -hh - rotate_offset); + + let handles: [(RasterTransformHandle, egui::Vec2); 10] = [ + (RasterTransformHandle::Corner { right: false, bottom: false }, to_world(-hw, -hh)), + (RasterTransformHandle::Corner { right: true, bottom: false }, to_world( hw, -hh)), + (RasterTransformHandle::Corner { right: false, bottom: true }, to_world(-hw, hh)), + (RasterTransformHandle::Corner { right: true, bottom: true }, to_world( hw, hh)), + (RasterTransformHandle::EdgeH { bottom: false }, to_world(0.0, -hh)), + (RasterTransformHandle::EdgeH { bottom: true }, to_world(0.0, hh)), + (RasterTransformHandle::EdgeV { right: false }, to_world(-hw, 0.0)), + (RasterTransformHandle::EdgeV { right: true }, to_world( hw, 0.0)), + (RasterTransformHandle::Rotate, rotate_handle), + (RasterTransformHandle::Origin, egui::vec2(ts.origin_x, ts.origin_y)), + ]; + + let hit_r_world = 8.0 / zoom; + let hovered = handles.iter() + .find(|(_, wp)| (world_pos - *wp).length() <= hit_r_world) + .map(|(h, _)| *h); + + // Inside bbox → Move handle (if no specific handle hit) + let in_bbox = { + let dx = world_pos.x - ts.cx; + let dy = world_pos.y - ts.cy; + let local_x = dx * cos_a + dy * sin_a; + let local_y = -dx * sin_a + dy * cos_a; + local_x.abs() <= hw && local_y.abs() <= hh + }; + let hovered = hovered.or_else(|| if in_bbox { Some(RasterTransformHandle::Move) } else { None }); + + // Store hovered handle for visual feedback in the draw function. + ts.hovered_handle = if ts.active_handle.is_none() { hovered } else { ts.active_handle }; + + // Set cursor icon based on hovered/active handle. + if let Some(h) = ts.active_handle.or(ts.hovered_handle) { + let cursor = match h { + RasterTransformHandle::Move => egui::CursorIcon::Grab, + RasterTransformHandle::Origin => egui::CursorIcon::Crosshair, + RasterTransformHandle::Corner { right, bottom } => { + if right == bottom { egui::CursorIcon::ResizeNwSe } + else { egui::CursorIcon::ResizeNeSw } + } + RasterTransformHandle::EdgeH { .. } => egui::CursorIcon::ResizeVertical, + RasterTransformHandle::EdgeV { .. } => egui::CursorIcon::ResizeHorizontal, + RasterTransformHandle::Rotate => egui::CursorIcon::AllScroll, + }; + ui.ctx().set_cursor_icon(cursor); + } + + // --- Drag start: use press_origin for hit testing (drag fires after threshold) --- + if drag_started { + let click_world = ui.input(|i| i.pointer.press_origin()) + .map(|sp| { + let canvas_pos = sp - response.rect.min.to_vec2(); + egui::vec2( + (canvas_pos.x - self.pan_offset.x) / self.zoom, + (canvas_pos.y - self.pan_offset.y) / self.zoom, + ) + }) + .unwrap_or(world_pos); + + // Recompute hovered handle at click_world position. + let click_hovered = handles.iter() + .find(|(_, wp)| (click_world - *wp).length() <= hit_r_world) + .map(|(h, _)| *h); + let click_in_bbox = { + let dx = click_world.x - ts.cx; + let dy = click_world.y - ts.cy; + let local_x = dx * cos_a + dy * sin_a; + let local_y = -dx * sin_a + dy * cos_a; + local_x.abs() <= hw && local_y.abs() <= hh + }; + let click_handle = click_hovered.or_else(|| if click_in_bbox { Some(RasterTransformHandle::Move) } else { None }); + + ts.active_handle = click_handle; + ts.drag_start_world = click_world; + ts.snap_cx = ts.cx; + ts.snap_cy = ts.cy; + ts.snap_sx = ts.scale_x; + ts.snap_sy = ts.scale_y; + ts.snap_angle = ts.angle; + ts.snap_origin_x = ts.origin_x; + ts.snap_origin_y = ts.origin_y; + } + + // --- Drag --- + let mut needs_dispatch = false; + if dragged { + if let Some(handle) = ts.active_handle { + let delta = world_pos - ts.drag_start_world; + let snap_hw = ts.snap_sx * ts.anchor_w as f32 / 2.0; + let snap_hh = ts.snap_sy * ts.anchor_h as f32 / 2.0; + let local_dx = delta.x * cos_a + delta.y * sin_a; + let local_dy = -delta.x * sin_a + delta.y * cos_a; + + match handle { + RasterTransformHandle::Move => { + ts.cx = ts.snap_cx + delta.x; + ts.cy = ts.snap_cy + delta.y; + ts.origin_x = ts.snap_origin_x + delta.x; + ts.origin_y = ts.snap_origin_y + delta.y; + // Pure move: display canvas keeps same pixels, position updated via compute_transform_params. + } + RasterTransformHandle::Origin => { + ts.origin_x = ts.snap_origin_x + delta.x; + ts.origin_y = ts.snap_origin_y + delta.y; + // No GPU dispatch needed for origin move alone. + } + RasterTransformHandle::Corner { right, bottom } => { + let sign_x = if right { 1.0_f32 } else { -1.0 }; + let sign_y = if bottom { 1.0_f32 } else { -1.0 }; + // Divide by 2: dragged corner = new_cx ± new_hw, and + // new_cx = wfx ± new_hw, so corner = wfx ± 2*new_hw. + // To make the corner move 1:1 with mouse, new_hw grows by delta/2. + // Signed clamp: allow negative scale (flip) but prevent exactly 0 + // which would make the inverse affine matrix singular. + let raw_hw = snap_hw + sign_x * local_dx / 2.0; + let new_hw = if raw_hw.abs() < 0.001 { if raw_hw <= 0.0 { -0.001 } else { 0.001 } } else { raw_hw }; + let new_hh = if shift { + // Preserve aspect ratio; sign follows new_hw. + new_hw * (ts.anchor_h as f32 / ts.anchor_w as f32).max(0.001) + } else { + let raw_hh = snap_hh + sign_y * local_dy / 2.0; + if raw_hh.abs() < 0.001 { if raw_hh <= 0.0 { -0.001 } else { 0.001 } } else { raw_hh } + }; + ts.scale_x = new_hw / (ts.anchor_w as f32 / 2.0).max(0.001); + ts.scale_y = new_hh / (ts.anchor_h as f32 / 2.0).max(0.001); + // Fixed corner world pos (opposite corner, from snap state). + let wfx = ts.snap_cx - sign_x * snap_hw * cos_a + sign_y * snap_hh * sin_a; + let wfy = ts.snap_cy - sign_x * snap_hw * sin_a - sign_y * snap_hh * cos_a; + // New center: fixed corner + rotated new half-extents. + ts.cx = wfx + sign_x * new_hw * cos_a - sign_y * new_hh * sin_a; + ts.cy = wfy + sign_x * new_hw * sin_a + sign_y * new_hh * cos_a; + // Maintain origin's relative position within the scaled bbox. + let o_dx = ts.snap_origin_x - ts.snap_cx; + let o_dy = ts.snap_origin_y - ts.snap_cy; + let o_local_x = o_dx * cos_a + o_dy * sin_a; + let o_local_y = -o_dx * sin_a + o_dy * cos_a; + let o_norm_x = if snap_hw > 0.0 { o_local_x / snap_hw } else { 0.0 }; + let o_norm_y = if snap_hh > 0.0 { o_local_y / snap_hh } else { 0.0 }; + let no_x = o_norm_x * new_hw; + let no_y = o_norm_y * new_hh; + ts.origin_x = ts.cx + no_x * cos_a - no_y * sin_a; + ts.origin_y = ts.cy + no_x * sin_a + no_y * cos_a; + needs_dispatch = true; + } + RasterTransformHandle::EdgeH { bottom } => { + let sign_y = if bottom { 1.0_f32 } else { -1.0 }; + let raw_hh = snap_hh + sign_y * local_dy / 2.0; + let new_hh = if raw_hh.abs() < 0.001 { if raw_hh <= 0.0 { -0.001 } else { 0.001 } } else { raw_hh }; + ts.scale_y = new_hh / (ts.anchor_h as f32 / 2.0).max(0.001); + // Fixed edge world position (opposite edge center). + let wfx = ts.snap_cx + sign_y * snap_hh * sin_a; + let wfy = ts.snap_cy - sign_y * snap_hh * cos_a; + ts.cx = wfx - sign_y * new_hh * sin_a; + ts.cy = wfy + sign_y * new_hh * cos_a; + // Maintain origin's relative Y position within the scaled bbox. + let o_dx = ts.snap_origin_x - ts.snap_cx; + let o_dy = ts.snap_origin_y - ts.snap_cy; + let o_local_x = o_dx * cos_a + o_dy * sin_a; + let o_local_y = -o_dx * sin_a + o_dy * cos_a; + let o_norm_y = if snap_hh > 0.0 { o_local_y / snap_hh } else { 0.0 }; + let no_x = o_local_x; // X local coord unchanged by EdgeH + let no_y = o_norm_y * new_hh; + ts.origin_x = ts.cx + no_x * cos_a - no_y * sin_a; + ts.origin_y = ts.cy + no_x * sin_a + no_y * cos_a; + needs_dispatch = true; + } + RasterTransformHandle::EdgeV { right } => { + let sign_x = if right { 1.0_f32 } else { -1.0 }; + let raw_hw = snap_hw + sign_x * local_dx / 2.0; + let new_hw = if raw_hw.abs() < 0.001 { if raw_hw <= 0.0 { -0.001 } else { 0.001 } } else { raw_hw }; + ts.scale_x = new_hw / (ts.anchor_w as f32 / 2.0).max(0.001); + // Fixed edge world position (opposite edge center). + let wfx = ts.snap_cx - sign_x * snap_hw * cos_a; + let wfy = ts.snap_cy - sign_x * snap_hw * sin_a; + ts.cx = wfx + sign_x * new_hw * cos_a; + ts.cy = wfy + sign_x * new_hw * sin_a; + // Maintain origin's relative X position within the scaled bbox. + let o_dx = ts.snap_origin_x - ts.snap_cx; + let o_dy = ts.snap_origin_y - ts.snap_cy; + let o_local_x = o_dx * cos_a + o_dy * sin_a; + let o_local_y = -o_dx * sin_a + o_dy * cos_a; + let o_norm_x = if snap_hw > 0.0 { o_local_x / snap_hw } else { 0.0 }; + let no_x = o_norm_x * new_hw; + let no_y = o_local_y; // Y local coord unchanged by EdgeV + ts.origin_x = ts.cx + no_x * cos_a - no_y * sin_a; + ts.origin_y = ts.cy + no_x * sin_a + no_y * cos_a; + needs_dispatch = true; + } + RasterTransformHandle::Rotate => { + // Rotate around origin (not center). + let v_start = ts.drag_start_world - egui::vec2(ts.origin_x, ts.origin_y); + let v_now = world_pos - egui::vec2(ts.origin_x, ts.origin_y); + let a_start = v_start.y.atan2(v_start.x); + let a_now = v_now.y.atan2(v_now.x); + let d_angle = a_now - a_start; + ts.angle = ts.snap_angle + d_angle; + // Also rotate cx/cy around the origin. + let ox = ts.snap_origin_x; + let oy = ts.snap_origin_y; + let dcx = ts.snap_cx - ox; + let dcy = ts.snap_cy - oy; + let (cos_d, sin_d) = (d_angle.cos(), d_angle.sin()); + ts.cx = ox + dcx * cos_d - dcy * sin_d; + ts.cy = oy + dcx * sin_d + dcy * cos_d; + needs_dispatch = true; + } + } + } + } + + // Build pending dispatch before the borrow ends (avoid partial move issues). + if needs_dispatch && dragged { + let (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) = + Self::compute_transform_params(ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, ts.scale_x, ts.scale_y, ts.angle); + ts.transform_applied = true; + let anchor_canvas_id = ts.anchor_canvas_id; + let anchor_pixels = ts.anchor_pixels.clone(); + let anchor_w = ts.anchor_w; + let anchor_h = ts.anchor_h; + let display_canvas_id = ts.display_canvas_id; + pending_dispatch = Some(PendingTransformDispatch { + anchor_canvas_id, anchor_pixels, anchor_w, anchor_h, + display_canvas_id, new_x, new_y, new_w, new_h, + a00, a01, a10, a11, b0, b1, + is_final_commit: false, + }); + } else { + pending_dispatch = None; + } + + // --- Drag stop --- + if drag_stopped { + ts.active_handle = None; + } + } + + if let Some(p) = pending_dispatch { + self.pending_transform_dispatch = Some(p); + } + + // Handle drawing is deferred to render_raster_transform_overlays(), called + // from render_content() AFTER the VelloCallback is registered, so the handles + // appear on top of the Vello scene rather than underneath it. + } + + fn draw_raster_transform_handles_static( + ui: &mut egui::Ui, + rect: egui::Rect, + ts: &RasterTransformState, + zoom: f32, + pan: egui::Vec2, + ) { + let painter = ui.painter_at(rect); + + // World → screen + let w2s = |wx: f32, wy: f32| -> egui::Pos2 { + egui::pos2( + wx * zoom + pan.x + rect.min.x, + wy * zoom + pan.y + rect.min.y, + ) + }; + + let hw = ts.scale_x * ts.anchor_w as f32 / 2.0; + let hh = ts.scale_y * ts.anchor_h as f32 / 2.0; + let cos_a = ts.angle.cos(); + let sin_a = ts.angle.sin(); + let to_world = |lx: f32, ly: f32| -> (f32, f32) { + (ts.cx + lx * cos_a - ly * sin_a, ts.cy + lx * sin_a + ly * cos_a) + }; + + // Draw bounding box outline (4 edges between corners) + let corners_local = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]; + let corners_screen: Vec = corners_local.iter() + .map(|&(lx, ly)| { let (wx, wy) = to_world(lx, ly); w2s(wx, wy) }) + .collect(); + let outline_color = egui::Color32::from_rgba_unmultiplied(255, 255, 255, 200); + let shadow_color = egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120); + for i in 0..4 { + let a = corners_screen[i]; + let b = corners_screen[(i + 1) % 4]; + painter.line_segment([a, b], egui::Stroke::new(2.0, shadow_color)); + painter.line_segment([a, b], egui::Stroke::new(1.0, outline_color)); + } + + // Colors + let handle_normal = egui::Color32::WHITE; + let handle_hovered = egui::Color32::from_rgb(100, 180, 255); // light blue + let handle_active = egui::Color32::from_rgb(30, 120, 255); // bright blue + + let handle_color = |h: RasterTransformHandle| -> egui::Color32 { + if ts.active_handle == Some(h) { handle_active } + else if ts.hovered_handle == Some(h) { handle_hovered } + else { handle_normal } + }; + + // Draw corner + edge handles (paired with their handle enum variant) + let handle_pairs: [(RasterTransformHandle, (f32, f32)); 8] = [ + (RasterTransformHandle::Corner { right: false, bottom: false }, to_world(-hw, -hh)), + (RasterTransformHandle::Corner { right: true, bottom: false }, to_world( hw, -hh)), + (RasterTransformHandle::Corner { right: false, bottom: true }, to_world(-hw, hh)), + (RasterTransformHandle::Corner { right: true, bottom: true }, to_world( hw, hh)), + (RasterTransformHandle::EdgeH { bottom: false }, to_world(0.0, -hh)), + (RasterTransformHandle::EdgeH { bottom: true }, to_world(0.0, hh)), + (RasterTransformHandle::EdgeV { right: false }, to_world(-hw, 0.0)), + (RasterTransformHandle::EdgeV { right: true }, to_world( hw, 0.0)), + ]; + for (handle, (wx, wy)) in handle_pairs { + let sp = w2s(wx, wy); + let is_hover = ts.hovered_handle == Some(handle) || ts.active_handle == Some(handle); + let size = if is_hover { 10.0 } else { 8.0 }; + let inner = if is_hover { 8.0 } else { 6.0 }; + painter.rect_filled( + egui::Rect::from_center_size(sp, egui::vec2(size, size)), + 0.0, + shadow_color, + ); + painter.rect_filled( + egui::Rect::from_center_size(sp, egui::vec2(inner, inner)), + 0.0, + handle_color(handle), + ); + } + + // Draw rotate handle (circle above top-center) + let rotate_offset = 24.0 / zoom; + let (rwx, rwy) = to_world(0.0, -hh - rotate_offset); + let rsp = w2s(rwx, rwy); + // Line from top-center to rotate handle + let (tcx, tcy) = to_world(0.0, -hh); + painter.line_segment([w2s(tcx, tcy), rsp], egui::Stroke::new(1.0, outline_color)); + let rot_hov = ts.hovered_handle == Some(RasterTransformHandle::Rotate) + || ts.active_handle == Some(RasterTransformHandle::Rotate); + let rot_color = if ts.active_handle == Some(RasterTransformHandle::Rotate) { handle_active } + else if ts.hovered_handle == Some(RasterTransformHandle::Rotate) { handle_hovered } + else { handle_normal }; + let rot_r = if rot_hov { 7.0 } else { 5.0 }; + painter.circle_filled(rsp, rot_r, rot_color); + painter.circle_stroke(rsp, rot_r, egui::Stroke::new(1.5, shadow_color)); + + // Draw origin handle (pivot point for rotate/scale) — a small crosshair circle. + // origin_x/origin_y are already in world coords, use w2s directly. + let origin_sp = w2s(ts.origin_x, ts.origin_y); + let orig_color = if ts.hovered_handle == Some(RasterTransformHandle::Origin) + || ts.active_handle == Some(RasterTransformHandle::Origin) { handle_hovered } else { handle_normal }; + painter.circle_filled(origin_sp, 5.0, orig_color); + painter.circle_stroke(origin_sp, 5.0, egui::Stroke::new(1.5, shadow_color)); + let arm = 6.0; + painter.line_segment([origin_sp - egui::vec2(arm, 0.0), origin_sp + egui::vec2(arm, 0.0)], + egui::Stroke::new(1.0, shadow_color)); + painter.line_segment([origin_sp - egui::vec2(0.0, arm), origin_sp + egui::vec2(0.0, arm)], + egui::Stroke::new(1.0, shadow_color)); + } + /// Apply an affine transform to selected DCEL vertices and their connected edge control points. /// Reads original positions from `original_dcel` and writes transformed positions to `dcel`. fn apply_dcel_transform( @@ -6243,6 +6944,15 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; + // Raster floating selection on a raster layer → raster transform path. + if let Some(active_id) = *shared.active_layer_id { + let is_raster = shared.action_executor.document().get_layer(&active_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + if is_raster && shared.selection.raster_floating.is_some() { + return self.handle_raster_transform_tool(ui, response, world_pos, shared); + } + } + // Check if we have an active layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, @@ -7896,6 +8606,9 @@ impl StagePane { ) { use lightningbeam_core::selection::RasterSelection; + // Don't show marching ants during raster transform — the handles show the bbox outline. + if self.raster_transform_state.is_some() { return; } + let has_sel = shared.selection.raster_selection.is_some(); if !has_sel { return; } @@ -8233,6 +8946,71 @@ impl PaneRenderer for StagePane { } } + // Consume transform readback results: swap display canvas in as the new float canvas. + if let Ok(mut results) = TRANSFORM_READBACK_RESULTS + .get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new()))) + .lock() + { + if let Some(rb) = results.remove(&self.instance_id) { + if let Some(ref mut float) = shared.selection.raster_floating { + self.pending_canvas_removal = Some(float.canvas_id); + float.canvas_id = rb.display_canvas_id; + float.pixels = rb.pixels; + float.width = rb.width; + float.height = rb.height; + float.x = rb.x; + float.y = rb.y; + } + // Update the selection border to match the new (transformed) float bounds, + // so marching ants appear around the result after switching tools / Enter. + // This also replaces the stale pre-transform rect so commit masking is correct. + shared.selection.raster_selection = Some( + lightningbeam_core::selection::RasterSelection::Rect( + rb.x, rb.y, + rb.x + rb.width as i32, + rb.y + rb.height as i32, + ) + ); + // Readback complete — clear transform state. + self.raster_transform_state = None; + } + } + + // Clear transform state if the float was committed externally (by another tool), + // or if the user switched away from the Transform tool without finishing. + { + use lightningbeam_core::tool::Tool; + let float_gone = shared.selection.raster_floating.is_none(); + let not_transform = !matches!(*shared.selected_tool, Tool::Transform); + if (float_gone || not_transform) && self.raster_transform_state.is_some() { + // If a transform was applied but not yet committed, queue the final dispatch now. + let needs_dispatch = self.raster_transform_state.as_ref() + .map_or(false, |ts| ts.transform_applied && !ts.wants_apply); + if needs_dispatch { + let dispatch = { + let ts = self.raster_transform_state.as_ref().unwrap(); + let (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) = + Self::compute_transform_params(ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, ts.scale_x, ts.scale_y, ts.angle); + PendingTransformDispatch { + anchor_canvas_id: ts.anchor_canvas_id, + anchor_pixels: ts.anchor_pixels.clone(), + anchor_w: ts.anchor_w, anchor_h: ts.anchor_h, + display_canvas_id: ts.display_canvas_id, + new_x, new_y, new_w, new_h, + a00, a01, a10, a11, b0, b1, + is_final_commit: true, + } + }; + self.pending_transform_dispatch = Some(dispatch); + self.raster_transform_state.as_mut().unwrap().wants_apply = true; + // Don't clear state yet — wait for readback (handles stay visible 1 frame). + } else if !self.raster_transform_state.as_ref().map_or(false, |ts| ts.wants_apply) { + // No pending dispatch — just clear. + self.raster_transform_state = None; + } + } + } + // Handle input for pan/zoom and tool controls self.handle_input(ui, rect, shared); @@ -8533,6 +9311,21 @@ impl PaneRenderer for StagePane { vello::kurbo::Point::new(local.x as f64, local.y as f64) }); + // Compute transform_display for the VelloCallback. + // Only override the float blit once the display canvas has actual content + // (transform_applied = true). Before the first drag, show the regular float canvas. + let transform_display = self.raster_transform_state.as_ref() + .filter(|ts| ts.transform_applied) + .map(|ts| { + let (new_w, new_h, new_x, new_y, ..) = Self::compute_transform_params( + ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, ts.scale_x, ts.scale_y, ts.angle, + ); + TransformDisplayInfo { + display_canvas_id: ts.display_canvas_id, + x: new_x, y: new_y, w: new_w, h: new_h, + } + }); + // Use egui's custom painting callback for Vello // document_arc() returns Arc - cheap pointer copy, not deep clone let callback = VelloCallback { ctx: VelloRenderContext { @@ -8561,6 +9354,8 @@ impl PaneRenderer for StagePane { mouse_world_pos, webcam_frame: shared.webcam_frame.clone(), pending_raster_dabs: self.pending_raster_dabs.take(), + pending_transform_dispatch: self.pending_transform_dispatch.take(), + transform_display, instance_id_for_readback: self.instance_id, painting_canvas: self.painting_canvas, pending_canvas_removal: self.pending_canvas_removal.take(), @@ -8649,6 +9444,13 @@ impl PaneRenderer for StagePane { // Raster selection overlays: marching ants + floating selection texture self.render_raster_selection_overlays(ui, rect, shared); + // Raster transform handles (drawn after Vello scene so they appear on top) + if let Some(ref ts) = self.raster_transform_state { + let zoom = self.zoom; + let pan = self.pan_offset; + Self::draw_raster_transform_handles_static(ui, rect, ts, zoom, pan); + } + // Render snap indicator (works for all tools, not just Select/BezierEdit) self.render_snap_indicator(ui, rect, shared);