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.
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 00:14:13 -04:00
parent aea26f6192
commit 6c9fcb1921
3 changed files with 87 additions and 21 deletions

View File

@ -23,6 +23,9 @@ pub struct AddShapeAction {
stroke_color: Option<ShapeColor>,
fill_color: Option<ShapeColor>,
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<Uuid>,
description_text: String,
/// Snapshot of the graph before insertion (for undo).
graph_before: Option<VectorGraph>,
@ -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<String>) -> 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
}
}
}
}
}

View File

@ -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)

View File

@ -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