Implement transform tool for raster
This commit is contained in:
parent
e7641edd0d
commit
4386917fc2
|
|
@ -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<RasterTransformPipeline>,
|
||||
|
||||
/// Canvas texture pairs keyed by keyframe UUID.
|
||||
pub canvases: HashMap<Uuid, CanvasPair>,
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<uniform> params: Params;
|
||||
@group(0) @binding(1) var src: texture_2d<f32>;
|
||||
@group(0) @binding(2) var dst: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders).
|
||||
fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {
|
||||
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<i32>(x0, y0), 0);
|
||||
let s10 = textureLoad(src, vec2<i32>(x1, y0), 0);
|
||||
let s01 = textureLoad(src, vec2<i32>(x0, y1), 0);
|
||||
let s11 = textureLoad(src, vec2<i32>(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<u32>) {
|
||||
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<f32>;
|
||||
if sx < 0.0 || sy < 0.0 || sx >= f32(params.src_w) || sy >= f32(params.src_h) {
|
||||
// Outside source bounds → transparent
|
||||
color = vec4<f32>(0.0);
|
||||
} else {
|
||||
// Bilinear sample at pixel center
|
||||
color = bilinear_sample(sx + 0.5, sy + 0.5);
|
||||
}
|
||||
|
||||
textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), color);
|
||||
}
|
||||
|
|
@ -402,6 +402,10 @@ struct VelloRenderContext {
|
|||
webcam_frame: Option<lightningbeam_core::webcam::CaptureFrame>,
|
||||
/// GPU brush dabs to dispatch in this frame's prepare() call.
|
||||
pending_raster_dabs: Option<PendingRasterDabs>,
|
||||
/// GPU affine-resample dispatch for the raster transform tool.
|
||||
pending_transform_dispatch: Option<PendingTransformDispatch>,
|
||||
/// When Some, override the float canvas blit with the display canvas during transform.
|
||||
transform_display: Option<TransformDisplayInfo>,
|
||||
/// 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<RasterTransformState>,
|
||||
/// GPU transform work to dispatch in prepare().
|
||||
pending_transform_dispatch: Option<PendingTransformDispatch>,
|
||||
/// Synthetic drag/click override for test mode replay (debug builds only)
|
||||
#[cfg(debug_assertions)]
|
||||
replay_override: Option<ReplayDragState>,
|
||||
|
|
@ -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<u8>,
|
||||
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<RasterTransformHandle>,
|
||||
/// Which handle the cursor is currently over (for visual feedback).
|
||||
hovered_handle: Option<RasterTransformHandle>,
|
||||
/// 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<u8>,
|
||||
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<u8>,
|
||||
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<Arc<Mutex<std::collections::HashMap<u64, TransformReadbackResult>>>> = 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<egui::Pos2> = 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<Document> - 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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue