From 6c9fcb192106c272fbac581f7f21a10983fc7872 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 21 Jun 2026 00:14:13 -0400 Subject: [PATCH] Phase 3.5a: place imported images on the canvas (image-filled rect) Replaces the DCEL "not yet supported" stubs so importing/dropping an image actually puts it in the vector scene. - AddShapeAction gains an `image_fill` + `AddShapeAction::image_rect(...)` constructor: a borderless rectangle (invisible edges) whose enclosed region is paint-bucketed and tagged with an image asset id. The renderer already prioritises `image_fill`. - Direct import (auto_place_asset): an imported image is placed centered on the canvas at native size on a vector layer. - Drag from the asset library onto the stage: image-filled rect at the drop point (centered), native size, using the asset's dimensions. Next: SetImageFillAction + an Info-Panel image-fill picker for existing shapes; then 3.5b container persistence. --- .../src/actions/add_shape.rs | 70 ++++++++++++++++--- .../lightningbeam-editor/src/main.rs | 23 +++--- .../lightningbeam-editor/src/panes/stage.rs | 15 +++- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs index bba8cdf..11abe5e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs @@ -23,6 +23,9 @@ pub struct AddShapeAction { stroke_color: Option, fill_color: Option, is_closed: bool, + /// When set, the enclosed region is filled with this image asset (instead of a + /// solid colour). The renderer prioritises `image_fill` over colour/gradient. + image_fill: Option, description_text: String, /// Snapshot of the graph before insertion (for undo). graph_before: Option, @@ -46,15 +49,53 @@ impl AddShapeAction { stroke_color, fill_color, is_closed, + image_fill: None, description_text: "Add shape".to_string(), graph_before: None, } } + /// A borderless, axis-aligned rectangle filled with an image asset — the result + /// of importing/dropping an image onto a vector layer. + pub fn image_rect( + layer_id: Uuid, + time: f64, + x: f64, + y: f64, + w: f64, + h: f64, + asset_id: Uuid, + ) -> Self { + let mut path = BezPath::new(); + path.move_to((x, y)); + path.line_to((x + w, y)); + path.line_to((x + w, y + h)); + path.line_to((x, y + h)); + path.close_path(); + Self { + layer_id, + time, + path, + stroke_style: None, // invisible edges — just the image + stroke_color: None, + fill_color: None, + is_closed: true, + image_fill: Some(asset_id), + description_text: "Add image".to_string(), + graph_before: None, + } + } + pub fn with_description(mut self, desc: impl Into) -> Self { self.description_text = desc.into(); self } + + /// Fill the created region with an image asset (image takes render priority). + pub fn with_image_fill(mut self, asset_id: Uuid) -> Self { + self.image_fill = Some(asset_id); + self + } } impl Action for AddShapeAction { @@ -87,16 +128,25 @@ impl Action for AddShapeAction { DEFAULT_SNAP_EPSILON, ); - // Apply fill if this is a closed shape with fill - if self.is_closed { - if let Some(ref fill) = self.fill_color { - // Compute centroid of the path's bounding box and paint-bucket fill - let bbox = self.path.bounding_box(); - let centroid = kurbo::Point::new( - (bbox.x0 + bbox.x1) / 2.0, - (bbox.y0 + bbox.y1) / 2.0, - ); - graph.paint_bucket(centroid, fill.clone(), FillRule::NonZero, 0.0); + // Apply fill if this is a closed shape with a colour and/or image fill. + if self.is_closed && (self.fill_color.is_some() || self.image_fill.is_some()) { + // Compute centroid of the path's bounding box and paint-bucket fill. + let bbox = self.path.bounding_box(); + let centroid = kurbo::Point::new( + (bbox.x0 + bbox.x1) / 2.0, + (bbox.y0 + bbox.y1) / 2.0, + ); + // paint_bucket needs a colour; an image-only fill uses a placeholder + // that the image overrides (cleared below). + let color = self.fill_color.clone().unwrap_or_else(|| ShapeColor::rgba(255, 255, 255, 255)); + if let Some(fid) = graph.paint_bucket(centroid, color, FillRule::NonZero, 0.0) { + if let Some(asset_id) = self.image_fill { + let fill = graph.fill_mut(fid); + fill.image_fill = Some(asset_id); + if self.fill_color.is_none() { + fill.color = None; // image-only: don't double-paint a colour + } + } } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 356a0b5..f811070 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -4921,15 +4921,22 @@ impl EditorApp { // Add clip instance or shape to the target layer if let Some(layer_id) = target_layer_id { - // For images, create a shape with image fill instead of a clip instance + // For images, create an image-filled rectangle on the (vector) layer, + // centered on the canvas at native size. if asset_info.clip_type == panes::DragClipType::Image { - // Get image dimensions - let (width, height) = asset_info.dimensions.unwrap_or((100.0, 100.0)); - - // TODO: Image fills on DCEL faces are a separate feature. - // For now, just log a message. - let _ = (layer_id, width, height); - eprintln!("Image drop to canvas not yet supported with DCEL backend"); + let (img_w, img_h) = asset_info.dimensions.unwrap_or((100.0, 100.0)); + let (doc_w, doc_h) = { + let d = self.action_executor.document(); + (d.width as f64, d.height as f64) + }; + let x = (doc_w - img_w) / 2.0; + let y = (doc_h - img_h) / 2.0; + let action = lightningbeam_core::actions::AddShapeAction::image_rect( + layer_id, self.playback_time, x, y, img_w, img_h, asset_info.clip_id, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to place image: {}", e); + } } else { // For clips, create a clip instance let mut clip_instance = ClipInstance::new(asset_info.clip_id) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 5fe99bc..a428306 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -11780,9 +11780,18 @@ impl PaneRenderer for StagePane { if let Some(layer_id) = target_layer_id { // For images, create a shape with image fill instead of a clip instance if dragging.clip_type == DragClipType::Image { - // TODO: Image fills on DCEL faces are a separate feature. - let _ = (layer_id, world_pos); - eprintln!("Image drag to stage not yet supported with DCEL backend"); + // Image-filled rectangle at the drop point (centered), native size. + let dims = shared.action_executor.document() + .get_image_asset(&dragging.clip_id) + .map(|a| (a.width as f64, a.height as f64)); + if let Some((img_w, img_h)) = dims { + let x = world_pos.x as f64 - img_w / 2.0; + let y = world_pos.y as f64 - img_h / 2.0; + let action = lightningbeam_core::actions::AddShapeAction::image_rect( + layer_id, drop_time, x, y, img_w, img_h, dragging.clip_id, + ); + let _ = shared.action_executor.execute(Box::new(action)); + } } else if dragging.clip_type == DragClipType::Effect { // Handle effect drops specially // Get effect definition from registry or document