feat: Enhance brush settings and add new layer paste functionality
- Added brush color variant and variant amount settings to the brush panel. - Introduced a checkbox for color variant and a slider for variant amount in the brush settings. - Implemented "Paste as New Layer" functionality, allowing users to paste clipboard images directly onto a new layer. - Updated menus to include the new paste option with a shortcut. - Improved layer panel iconography by replacing text buttons with SVG icons for visibility and lock toggles. - Created new SVG assets for closed eye and watercolor icons. - Enhanced history panel to provide richer descriptions for brush strokes and vector shape modifications. - Fixed the issue where the first brush stroke was missing from the history panel.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor">
|
||||
<g transform="translate(40.96,40.96) scale(17.920000) translate(-0.00,-0.00)">
|
||||
<path d="M12 6C5.6 6 1 12 1 12s4.6 6 11 6 11-6 11-6-4.6-6-11-6zm0 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4zm0-6.5c-1.4 0-2.5 1.1-2.5 2.5s1.1 2.5 2.5 2.5 2.5-1.1 2.5-2.5S13.4 9.5 12 9.5z"/>
|
||||
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 525 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Water droplet / splat icon -->
|
||||
<path d="M12 2C12 2 5 9.5 5 14a7 7 0 0 0 14 0c0-4.5-7-12-7-12z" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M12 2C12 2 5 9.5 5 14a7 7 0 0 0 14 0c0-4.5-7-12-7-12z"/>
|
||||
<!-- Small splat drops -->
|
||||
<circle cx="7" cy="18" r="1.2" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="17" cy="17" r="0.9" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="12" cy="20" r="1.0" fill="currentColor" opacity="0.35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 613 B |
@@ -349,8 +349,6 @@ pub struct HcieIcedApp {
|
||||
pub cached_custom_shapes: Vec<(String, String)>,
|
||||
/// Last drag position during vector shape editing (canvas coordinates).
|
||||
pub vector_drag_last: Option<(f32, f32)>,
|
||||
/// Timestamp of the last expensive vector rerasterization during a drag.
|
||||
pub vector_drag_last_render: Option<std::time::Instant>,
|
||||
/// Last composite bounds during drag — used to skip redundant re-renders.
|
||||
pub vector_drag_last_bounds: Option<(f32, f32, f32, f32)>,
|
||||
/// Last composite angle during drag — used to skip redundant re-renders.
|
||||
@@ -465,6 +463,9 @@ pub struct IcedDocument {
|
||||
/// Stashed paste pixels when the paste exceeds the canvas, awaiting
|
||||
/// the ExpandCanvas dialog decision. Consumed on Expand or Cancel.
|
||||
pub pending_paste: Option<(Vec<u8>, u32, u32)>,
|
||||
/// When true, the pending_paste pixels should be placed as a new layer
|
||||
/// instead of entering transform mode (set by "Paste as New Layer").
|
||||
pub pending_paste_as_new_layer: bool,
|
||||
/// Selection mask (alpha channel) for the current selection
|
||||
pub selection_mask: Option<Vec<u8>>,
|
||||
/// Whether the selection mask has changed since last GPU upload
|
||||
@@ -580,6 +581,10 @@ pub struct ToolState {
|
||||
pub brush_style: BrushStyle,
|
||||
/// Current brush spacing.
|
||||
pub brush_spacing: f32,
|
||||
/// True when each brush dab should vary its color slightly.
|
||||
pub brush_color_variant: bool,
|
||||
/// Magnitude of the per-dab color variation (0.0..1.0).
|
||||
pub brush_variant_amount: f32,
|
||||
/// Timestamp of the last composite refresh for throttling during strokes.
|
||||
pub last_composite_refresh: std::time::Instant,
|
||||
/// Timestamp of the last update() call for frame timing.
|
||||
@@ -614,6 +619,8 @@ impl Default for ToolState {
|
||||
brush_hardness: 0.8,
|
||||
brush_style: BrushStyle::Round,
|
||||
brush_spacing: 1.0,
|
||||
brush_color_variant: false,
|
||||
brush_variant_amount: 0.0,
|
||||
last_composite_refresh: std::time::Instant::now(),
|
||||
last_update_instant: std::time::Instant::now(),
|
||||
imported_brushes: Vec::new(),
|
||||
@@ -688,6 +695,9 @@ pub enum Message {
|
||||
|
||||
// ── Engine operations ───────────────────────────────
|
||||
CompositeRefresh,
|
||||
/// Polled after end_stroke to commit any pending history snapshots
|
||||
/// that the background thread hadn't finished yet.
|
||||
CompositeRefreshPending,
|
||||
Undo,
|
||||
Redo,
|
||||
|
||||
@@ -753,6 +763,8 @@ pub enum Message {
|
||||
BrushHardnessChanged(f32),
|
||||
BrushFlowChanged(f32),
|
||||
BrushSpacingChanged(f32),
|
||||
BrushColorVariantToggled(bool),
|
||||
BrushVariantAmountChanged(f32),
|
||||
BrushImportAbr,
|
||||
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>),
|
||||
ResetToolDefaults,
|
||||
@@ -955,11 +967,13 @@ pub enum Message {
|
||||
CopyImage,
|
||||
CutImage,
|
||||
PasteImage,
|
||||
PasteAsNewLayer,
|
||||
CopyText(String),
|
||||
PasteText,
|
||||
ClipboardResult(Result<Option<String>, String>),
|
||||
ImageCopied(Result<(), String>),
|
||||
ImagePasted(Result<Option<(Vec<u8>, u32, u32)>, String>),
|
||||
ImagePastedAsNewLayer(Result<Option<(Vec<u8>, u32, u32)>, String>),
|
||||
|
||||
// ── File I/O (with rfd) ─────────────────────────────
|
||||
OpenFileRfd,
|
||||
@@ -1282,6 +1296,8 @@ fn build_brush_tip(state: &ToolState) -> BrushTip {
|
||||
opacity: state.brush_opacity,
|
||||
hardness: state.brush_hardness,
|
||||
spacing: state.brush_spacing,
|
||||
color_variant: state.brush_color_variant,
|
||||
variant_amount: state.brush_variant_amount,
|
||||
..BrushTip::default()
|
||||
}
|
||||
}
|
||||
@@ -1666,6 +1682,7 @@ impl HcieIcedApp {
|
||||
transform_preview_base: None,
|
||||
internal_clipboard: None,
|
||||
pending_paste: None,
|
||||
pending_paste_as_new_layer: false,
|
||||
selection_mask: None,
|
||||
selection_mask_dirty: std::cell::Cell::new(false),
|
||||
selection_bounds: None,
|
||||
@@ -1804,7 +1821,6 @@ impl HcieIcedApp {
|
||||
vector_shapes_snapshot: None,
|
||||
cached_custom_shapes: Vec::new(),
|
||||
vector_drag_last: None,
|
||||
vector_drag_last_render: None,
|
||||
vector_drag_last_bounds: None,
|
||||
vector_drag_last_angle: None,
|
||||
vector_drag_session: None,
|
||||
@@ -2194,7 +2210,6 @@ impl HcieIcedApp {
|
||||
self.vector_shapes_snapshot = None;
|
||||
self.vector_drag_session = None;
|
||||
self.vector_drag_last = None;
|
||||
self.vector_drag_last_render = None;
|
||||
self.vector_drag_last_bounds = None;
|
||||
self.vector_drag_last_angle = None;
|
||||
if let Some(doc) = self.documents.get_mut(self.active_doc) {
|
||||
@@ -3331,6 +3346,17 @@ impl HcieIcedApp {
|
||||
self.documents[self.active_doc].modified = true;
|
||||
|
||||
self.refresh_composite_if_needed();
|
||||
|
||||
// If the background thread hasn't finished yet, schedule
|
||||
// a delayed poll so the history entry is committed.
|
||||
if self.documents[self.active_doc].engine.has_pending_history() {
|
||||
return Task::perform(
|
||||
async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(8)).await;
|
||||
},
|
||||
|_| Message::CompositeRefreshPending,
|
||||
);
|
||||
}
|
||||
} // end if !is_selection_tool
|
||||
}
|
||||
|
||||
@@ -3445,6 +3471,32 @@ impl HcieIcedApp {
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
|
||||
// Poll pending background history commits. The brush engine's
|
||||
// end_stroke runs a background thread that may not finish before
|
||||
// the first commit_pending_history() call. This message retries
|
||||
// at 16 ms intervals until the queue drains.
|
||||
Message::CompositeRefreshPending => {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
let committed = doc.engine.commit_pending_history();
|
||||
if committed && doc.engine.has_pending_history() {
|
||||
// Background thread still producing — schedule another poll.
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(
|
||||
async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(16)).await;
|
||||
},
|
||||
|_| Message::CompositeRefreshPending,
|
||||
);
|
||||
}
|
||||
// All done — refresh composite and update cached history list.
|
||||
let history_len = doc.engine.history_len();
|
||||
doc.cached_history = (0..history_len)
|
||||
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
|
||||
.collect();
|
||||
doc.history_current = doc.engine.history_current();
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
|
||||
// ── Layer operations ──────────────────────────
|
||||
Message::LayerSelect(id) => {
|
||||
if self.preview_baseline.is_some() {
|
||||
@@ -4688,6 +4740,16 @@ impl HcieIcedApp {
|
||||
self.settings.tool_settings.brush_spacing = spacing;
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
Message::BrushColorVariantToggled(variant) => {
|
||||
self.tool_state.brush_color_variant = variant;
|
||||
self.settings.update_from_tool_state(&self.tool_state);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
Message::BrushVariantAmountChanged(amount) => {
|
||||
self.tool_state.brush_variant_amount = amount;
|
||||
self.settings.update_from_tool_state(&self.tool_state);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
Message::SelectionToleranceChanged(tol) => {
|
||||
self.settings.tool_settings.magic_wand_tolerance = tol as f32;
|
||||
self.settings.tool_settings.flood_fill_tolerance = tol as f32;
|
||||
@@ -5076,9 +5138,26 @@ impl HcieIcedApp {
|
||||
// EGUI's "paste proceeds as-is" cancel behavior).
|
||||
if matches!(self.active_dialog, ActiveDialog::ExpandCanvas { .. }) {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
let as_new_layer = doc.pending_paste_as_new_layer;
|
||||
if let Some((pixels, w, h)) = doc.pending_paste.take() {
|
||||
doc.pending_paste_as_new_layer = false;
|
||||
let canvas_w = doc.engine.canvas_width();
|
||||
let canvas_h = doc.engine.canvas_height();
|
||||
if as_new_layer {
|
||||
// Paste as new layer: create layer with centered (possibly clipped) pixels.
|
||||
let centered = selection::clipboard::centered_layer_pixels(
|
||||
&pixels, w, h, canvas_w, canvas_h,
|
||||
);
|
||||
let id = doc.engine.add_layer("Pasted Layer");
|
||||
doc.engine.set_layer_pixels(id, centered);
|
||||
doc.engine.set_active_layer(id);
|
||||
doc.modified = true;
|
||||
self.refresh_composite_if_needed();
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
self.pending_document_close = None;
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
// Standard paste: enter transform mode.
|
||||
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
|
||||
doc.transform_selection_baseline =
|
||||
doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||||
@@ -5130,19 +5209,33 @@ impl HcieIcedApp {
|
||||
} = self.active_dialog
|
||||
{
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
let as_new_layer = doc.pending_paste_as_new_layer;
|
||||
doc.engine.resize_canvas(paste_w, paste_h);
|
||||
let canvas_w = doc.engine.canvas_width();
|
||||
let canvas_h = doc.engine.canvas_height();
|
||||
if let Some((pixels, w, h)) = doc.pending_paste.take() {
|
||||
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
|
||||
doc.transform_selection_baseline =
|
||||
doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||||
doc.transform_source = None;
|
||||
doc.transform_preview_base = Some(doc.composite_raw.clone());
|
||||
doc.selection_transform = selection::clipboard::centered_transform(
|
||||
pixels, w, h, canvas_w, canvas_h,
|
||||
);
|
||||
render_transform_preview(doc, None);
|
||||
doc.pending_paste_as_new_layer = false;
|
||||
if as_new_layer {
|
||||
// Paste as new layer: create layer with centered pixels on expanded canvas.
|
||||
let centered = selection::clipboard::centered_layer_pixels(
|
||||
&pixels, w, h, canvas_w, canvas_h,
|
||||
);
|
||||
let id = doc.engine.add_layer("Pasted Layer");
|
||||
doc.engine.set_layer_pixels(id, centered);
|
||||
doc.engine.set_active_layer(id);
|
||||
doc.modified = true;
|
||||
} else {
|
||||
// Standard paste: enter transform mode.
|
||||
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
|
||||
doc.transform_selection_baseline =
|
||||
doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||||
doc.transform_source = None;
|
||||
doc.transform_preview_base = Some(doc.composite_raw.clone());
|
||||
doc.selection_transform = selection::clipboard::centered_transform(
|
||||
pixels, w, h, canvas_w, canvas_h,
|
||||
);
|
||||
render_transform_preview(doc, None);
|
||||
}
|
||||
}
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
self.refresh_composite_if_needed();
|
||||
@@ -5261,6 +5354,7 @@ impl HcieIcedApp {
|
||||
transform_preview_base: None,
|
||||
internal_clipboard: None,
|
||||
pending_paste: None,
|
||||
pending_paste_as_new_layer: false,
|
||||
selection_mask: None,
|
||||
selection_mask_dirty: std::cell::Cell::new(false),
|
||||
selection_bounds: None,
|
||||
@@ -6392,19 +6486,6 @@ impl HcieIcedApp {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Frame-skip throttle: skip processing if less than ~16ms since
|
||||
// the last update to keep the overlay drawing at ~60 FPS and avoid
|
||||
// flooding the message queue.
|
||||
let now = std::time::Instant::now();
|
||||
let min_interval = std::time::Duration::from_secs_f32(1.0 / 60.0);
|
||||
if let Some(last) = self.vector_drag_last_render {
|
||||
if now.duration_since(last) < min_interval {
|
||||
// Still update pointer tracking even when skipping preview
|
||||
self.vector_drag_last = Some((x, y));
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
|
||||
let shape = crate::vector_edit::transform_shape(
|
||||
&session,
|
||||
(x, y),
|
||||
@@ -6422,7 +6503,6 @@ impl HcieIcedApp {
|
||||
self.documents[self.active_doc].vector_drag_preview = Some(shape);
|
||||
self.documents[self.active_doc].modified = true;
|
||||
self.vector_drag_last = Some((x, y));
|
||||
self.vector_drag_last_render = Some(now);
|
||||
self.vector_drag_last_bounds = Some((x1, y1, x2, y2));
|
||||
self.vector_drag_last_angle = Some(angle);
|
||||
self.settings.tool_settings.vector_angle = angle.to_degrees();
|
||||
@@ -6474,7 +6554,6 @@ impl HcieIcedApp {
|
||||
}
|
||||
// Commit the move/resize/rotate: if shapes changed, push history snapshot.
|
||||
self.vector_drag_last = None;
|
||||
self.vector_drag_last_render = None;
|
||||
self.vector_drag_last_bounds = None;
|
||||
self.vector_drag_last_angle = None;
|
||||
self.vector_drag_session = None;
|
||||
@@ -7534,6 +7613,32 @@ impl HcieIcedApp {
|
||||
Message::ImagePasted,
|
||||
);
|
||||
}
|
||||
Message::PasteAsNewLayer => {
|
||||
// Check internal clipboard first
|
||||
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone() {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
let canvas_w = doc.engine.canvas_width();
|
||||
let canvas_h = doc.engine.canvas_height();
|
||||
let centered = selection::clipboard::centered_layer_pixels(
|
||||
&transform.pixels,
|
||||
transform.width,
|
||||
transform.height,
|
||||
canvas_w,
|
||||
canvas_h,
|
||||
);
|
||||
let id = doc.engine.add_layer("Pasted Layer");
|
||||
doc.engine.set_layer_pixels(id, centered);
|
||||
doc.engine.set_active_layer(id);
|
||||
doc.modified = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
// Fall back to system clipboard
|
||||
return Task::perform(
|
||||
async { crate::io::clipboard::paste_image_from_clipboard() },
|
||||
Message::ImagePastedAsNewLayer,
|
||||
);
|
||||
}
|
||||
Message::CopyText(text) => {
|
||||
let text = text.clone();
|
||||
return Task::perform(
|
||||
@@ -7597,6 +7702,40 @@ impl HcieIcedApp {
|
||||
Message::ImagePasted(Err(e)) => {
|
||||
log::error!("Failed to paste image: {}", e);
|
||||
}
|
||||
Message::ImagePastedAsNewLayer(Ok(Some((pixels, w, h)))) => {
|
||||
log::info!("Paste as new layer: {}x{}", w, h);
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
let canvas_w = doc.engine.canvas_width();
|
||||
let canvas_h = doc.engine.canvas_height();
|
||||
if w > canvas_w || h > canvas_h {
|
||||
// Stash and ask before pasting — need canvas expansion.
|
||||
doc.pending_paste = Some((pixels, w, h));
|
||||
doc.pending_paste_as_new_layer = true;
|
||||
self.active_dialog = ActiveDialog::ExpandCanvas {
|
||||
paste_w: w,
|
||||
paste_h: h,
|
||||
canvas_w,
|
||||
canvas_h,
|
||||
};
|
||||
} else {
|
||||
// Fits — create new layer with centered pixels directly.
|
||||
let centered = selection::clipboard::centered_layer_pixels(
|
||||
&pixels, w, h, canvas_w, canvas_h,
|
||||
);
|
||||
let id = doc.engine.add_layer("Pasted Layer");
|
||||
doc.engine.set_layer_pixels(id, centered);
|
||||
doc.engine.set_active_layer(id);
|
||||
doc.modified = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
}
|
||||
Message::ImagePastedAsNewLayer(Ok(None)) => {
|
||||
log::info!("No image in clipboard for paste-as-new-layer");
|
||||
}
|
||||
Message::ImagePastedAsNewLayer(Err(e)) => {
|
||||
log::error!("Failed to paste as new layer: {}", e);
|
||||
}
|
||||
|
||||
// ── File I/O (with rfd) ───────────────────────
|
||||
Message::OpenFileRfd => {
|
||||
@@ -8090,6 +8229,7 @@ impl HcieIcedApp {
|
||||
transform_preview_base: None,
|
||||
internal_clipboard: None,
|
||||
pending_paste: None,
|
||||
pending_paste_as_new_layer: false,
|
||||
selection_mask: None,
|
||||
selection_mask_dirty: std::cell::Cell::new(false),
|
||||
selection_bounds: None,
|
||||
@@ -8470,6 +8610,9 @@ impl HcieIcedApp {
|
||||
MenuCommand::PasteSpecial => {
|
||||
return Task::perform(async {}, |_| Message::PasteImage)
|
||||
}
|
||||
MenuCommand::PasteAsNewLayer => {
|
||||
return Task::perform(async {}, |_| Message::PasteAsNewLayer)
|
||||
}
|
||||
MenuCommand::Fill => {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
let fg = self.fg_color;
|
||||
|
||||
@@ -543,6 +543,497 @@ impl OverlayProgram {
|
||||
(cx + dx * cos - dy * sin, cy + dx * sin + dy * cos)
|
||||
}
|
||||
|
||||
/// Draws the actual shape geometry for a vector shape preview during a drag.
|
||||
///
|
||||
/// Instead of drawing just the rotated bounding box, this renders the real
|
||||
/// shape outline (ellipse, star, polygon, heart, etc.) with the shape's
|
||||
/// rotation applied, giving the user an accurate preview of the final result.
|
||||
///
|
||||
/// Used by both the in-progress `vector_draw` creation preview and the
|
||||
/// `vector_drag_preview` transform overlay.
|
||||
fn draw_vector_shape_preview(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
shape: &hcie_engine_api::VectorShape,
|
||||
origin_x: f32,
|
||||
origin_y: f32,
|
||||
zoom: f32,
|
||||
stroke_style: Stroke,
|
||||
) {
|
||||
let (px1, py1, px2, py2) = shape.normalized_bounds();
|
||||
let pang = shape.angle();
|
||||
let cx = (px1 + px2) * 0.5;
|
||||
let cy = (py1 + py2) * 0.5;
|
||||
let bw = (px2 - px1).abs();
|
||||
let bh = (py2 - py1).abs();
|
||||
let cos_a = pang.cos();
|
||||
let sin_a = pang.sin();
|
||||
let rotate = |x: f32, y: f32| -> (f32, f32) {
|
||||
let dx = x - cx;
|
||||
let dy = y - cy;
|
||||
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
|
||||
};
|
||||
let sx = |x: f32, y: f32| -> Point {
|
||||
let (rx, ry) = rotate(x, y);
|
||||
Point::new(origin_x + rx * zoom, origin_y + ry * zoom)
|
||||
};
|
||||
|
||||
match shape {
|
||||
hcie_engine_api::VectorShape::Circle { .. } => {
|
||||
let segments = (((bw + bh) * 0.5).max(1.0) * 6.0).max(32.0).ceil() as i32;
|
||||
let ellipse_path = Path::new(|b| {
|
||||
for i in 0..=segments {
|
||||
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
|
||||
let ex = cx + (bw / 2.0) * t.cos();
|
||||
let ey = cy + (bh / 2.0) * t.sin();
|
||||
let (rex, rey) = rotate(ex, ey);
|
||||
let px = origin_x + rex * zoom;
|
||||
let py = origin_y + rey * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(px, py));
|
||||
} else {
|
||||
b.line_to(Point::new(px, py));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&ellipse_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Line { x1, y1, x2, y2, .. } => {
|
||||
let p0 = sx(*x1, *y1);
|
||||
let p1 = sx(*x2, *y2);
|
||||
frame.stroke(&Path::line(p0, p1), stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Star {
|
||||
points,
|
||||
inner_radius,
|
||||
..
|
||||
} => {
|
||||
let outer_r = (bw.min(bh) / 2.0).max(1.0);
|
||||
let inner_r = outer_r * inner_radius.max(0.1);
|
||||
let n = (*points).max(3);
|
||||
let star_path = Path::new(|b| {
|
||||
for i in 0..(n * 2) {
|
||||
let t = std::f32::consts::PI * 2.0 * i as f32 / (n * 2) as f32
|
||||
- std::f32::consts::PI / 2.0;
|
||||
let r = if i % 2 == 0 { outer_r } else { inner_r };
|
||||
let px = cx + r * t.cos();
|
||||
let py = cy + r * t.sin();
|
||||
let (rpx, rpy) = rotate(px, py);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&star_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Polygon { sides, .. } => {
|
||||
let r = (bw.min(bh) / 2.0).max(1.0);
|
||||
let n = (*sides).max(3);
|
||||
let poly_path = Path::new(|b| {
|
||||
for i in 0..n {
|
||||
let t = std::f32::consts::PI * 2.0 * i as f32 / n as f32
|
||||
- std::f32::consts::PI / 2.0;
|
||||
let px = cx + r * t.cos();
|
||||
let py = cy + r * t.sin();
|
||||
let (rpx, rpy) = rotate(px, py);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&poly_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Arrow { x1, y1, x2, y2, .. } => {
|
||||
let dx = x2 - x1;
|
||||
let dy = y2 - y1;
|
||||
let head_angle = dy.atan2(dx);
|
||||
let arrow_angle = std::f32::consts::PI / 6.0;
|
||||
let arrow_thickness = 1.0;
|
||||
let head_len = arrow_thickness * 3.0;
|
||||
let p0 = sx(*x1, *y1);
|
||||
let p1 = sx(*x2, *y2);
|
||||
let rx2 = origin_x + x2 * zoom;
|
||||
let ry2 = origin_y + y2 * zoom;
|
||||
let rx3 = rx2 - head_len * (head_angle + arrow_angle).cos();
|
||||
let ry3 = ry2 - head_len * (head_angle + arrow_angle).sin();
|
||||
let rx4 = rx2 - head_len * (head_angle - arrow_angle).cos();
|
||||
let ry4 = ry2 - head_len * (head_angle - arrow_angle).sin();
|
||||
frame.stroke(&Path::line(p0, p1), stroke_style);
|
||||
frame.stroke(&Path::line(p1, Point::new(rx3, ry3)), stroke_style);
|
||||
frame.stroke(&Path::line(p1, Point::new(rx4, ry4)), stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Rhombus { .. } => {
|
||||
let top = sx(cx, py1);
|
||||
let right = sx(px2, cy);
|
||||
let bottom = sx(cx, py2);
|
||||
let left = sx(px1, cy);
|
||||
let rhombus_path = Path::new(|b| {
|
||||
b.move_to(top);
|
||||
b.line_to(right);
|
||||
b.line_to(bottom);
|
||||
b.line_to(left);
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&rhombus_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Cylinder { .. } => {
|
||||
let segments = 36;
|
||||
let left_scr = origin_x + px1 * zoom;
|
||||
let right_scr = origin_x + px2 * zoom;
|
||||
let top_scr = origin_y + py1 * zoom;
|
||||
let bottom_scr = origin_y + py2 * zoom;
|
||||
let hw = (right_scr - left_scr) / 2.0;
|
||||
let ellipse_ry = bh * 0.1 * zoom;
|
||||
let top_cy = top_scr + ellipse_ry;
|
||||
let bot_cy = bottom_scr - ellipse_ry;
|
||||
let draw_ellipse = |frame: &mut Frame, cy: f32| {
|
||||
let ellipse_path = Path::new(|b| {
|
||||
for i in 0..=segments {
|
||||
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
|
||||
let px = left_scr + hw + hw * t.cos();
|
||||
let py = cy + ellipse_ry * t.sin();
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(px, py));
|
||||
} else {
|
||||
b.line_to(Point::new(px, py));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&ellipse_path, stroke_style.clone());
|
||||
};
|
||||
draw_ellipse(frame, top_cy);
|
||||
draw_ellipse(frame, bot_cy);
|
||||
frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(left_scr, top_cy),
|
||||
Point::new(left_scr, bot_cy),
|
||||
),
|
||||
stroke_style.clone(),
|
||||
);
|
||||
frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(right_scr, top_cy),
|
||||
Point::new(right_scr, bot_cy),
|
||||
),
|
||||
stroke_style.clone(),
|
||||
);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Heart { .. } => {
|
||||
let hw = (bw / 2.0).max(1.0);
|
||||
let hh = (bh / 2.0).max(1.0);
|
||||
let steps = 48;
|
||||
let heart_path = Path::new(|b| {
|
||||
for i in 0..=steps {
|
||||
let t = std::f32::consts::TAU * i as f32 / steps as f32;
|
||||
let hx = cx + hw * (16.0 * t.sin().powi(3)) / 16.0;
|
||||
let hy = cy
|
||||
- hh * (13.0 * t.cos()
|
||||
- 5.0 * (2.0 * t).cos()
|
||||
- 2.0 * (3.0 * t).cos()
|
||||
- (4.0 * t).cos())
|
||||
/ 16.0;
|
||||
let (rpx, rpy) = rotate(hx, hy);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&heart_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Bubble { .. } => {
|
||||
let rx = (bw / 2.0).max(1.0);
|
||||
let ry = (bh / 2.0).max(1.0);
|
||||
let segments = 48;
|
||||
let bubble_path = Path::new(|b| {
|
||||
for i in 0..=segments {
|
||||
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
|
||||
let hx = cx + rx * t.cos();
|
||||
let hy = cy + ry * t.sin();
|
||||
let (rpx, rpy) = rotate(hx, hy);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&bubble_path, stroke_style.clone());
|
||||
let tail_inner = sx(cx + rx * 0.2, cy + ry * 0.8);
|
||||
let tail_tip = sx(cx - rx * 0.6, cy + ry * 1.4);
|
||||
let tail_outer = sx(cx - rx * 0.6, cy + ry * 0.8);
|
||||
let tail_path = Path::new(|b| {
|
||||
b.move_to(tail_inner);
|
||||
b.line_to(tail_tip);
|
||||
b.line_to(tail_outer);
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&tail_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Gear { .. } => {
|
||||
let r_base = (bw.min(bh) / 2.0).max(1.0);
|
||||
let outer_r = r_base * 0.9;
|
||||
let inner_r = r_base * 0.6;
|
||||
let hole_r = r_base * 0.25;
|
||||
let teeth = 8;
|
||||
let gear_path = Path::new(|b| {
|
||||
for i in 0..(teeth * 2) {
|
||||
let a = std::f32::consts::PI * 2.0 * i as f32 / (teeth * 2) as f32
|
||||
- std::f32::consts::PI / 2.0;
|
||||
let r = if i % 2 == 0 { outer_r } else { inner_r };
|
||||
let gx = cx + r * a.cos();
|
||||
let gy = cy + r * a.sin();
|
||||
let (rpx, rpy) = rotate(gx, gy);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&gear_path, stroke_style.clone());
|
||||
let hole_path = Path::new(|b| {
|
||||
let segments = 36;
|
||||
for i in 0..=segments {
|
||||
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
|
||||
let hx = cx + hole_r * t.cos();
|
||||
let hy = cy + hole_r * t.sin();
|
||||
let (rpx, rpy) = rotate(hx, hy);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&hole_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Cross { .. } => {
|
||||
let arm = (bw.min(bh) * 0.25).max(1.0);
|
||||
let cross_path = Path::new(|b| {
|
||||
let points = [
|
||||
(cx - arm, py1),
|
||||
(cx + arm, py1),
|
||||
(cx + arm, cy - arm),
|
||||
(px2, cy - arm),
|
||||
(px2, cy + arm),
|
||||
(cx + arm, cy + arm),
|
||||
(cx + arm, py2),
|
||||
(cx - arm, py2),
|
||||
(cx - arm, cy + arm),
|
||||
(px1, cy + arm),
|
||||
(px1, cy - arm),
|
||||
(cx - arm, cy - arm),
|
||||
];
|
||||
for (i, (px, py)) in points.iter().enumerate() {
|
||||
let (rpx, rpy) = rotate(*px, *py);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&cross_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Crescent { .. } => {
|
||||
let rx = (bw / 2.0).max(1.0);
|
||||
let ry = (bh / 2.0).max(1.0);
|
||||
let segments = 48;
|
||||
let start_angle = -std::f32::consts::PI * 0.2;
|
||||
let end_angle = std::f32::consts::PI * 1.2;
|
||||
let crescent_path = Path::new(|b| {
|
||||
for i in 0..=segments {
|
||||
let a = start_angle
|
||||
+ (end_angle - start_angle) * i as f32 / segments as f32;
|
||||
let hx = cx + rx * a.cos();
|
||||
let hy = cy + ry * a.sin();
|
||||
let (rpx, rpy) = rotate(hx, hy);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
let inner_cx = cx + rx * 0.4;
|
||||
let inner_rx = rx * 0.7;
|
||||
let inner_ry = ry * 0.7;
|
||||
let p_start_x = cx + rx * start_angle.cos();
|
||||
let p_start_y = cy + ry * start_angle.sin();
|
||||
let inner_p_start_x = inner_cx + inner_rx * start_angle.cos();
|
||||
let inner_p_start_y = cy + inner_ry * start_angle.sin();
|
||||
let diff_start_x = p_start_x - inner_p_start_x;
|
||||
let diff_start_y = p_start_y - inner_p_start_y;
|
||||
let p_end_x = cx + rx * end_angle.cos();
|
||||
let p_end_y = cy + ry * end_angle.sin();
|
||||
let inner_p_end_x = inner_cx + inner_rx * end_angle.cos();
|
||||
let inner_p_end_y = cy + inner_ry * end_angle.sin();
|
||||
let diff_end_x = p_end_x - inner_p_end_x;
|
||||
let diff_end_y = p_end_y - inner_p_end_y;
|
||||
for i in (0..=segments).rev() {
|
||||
let t = i as f32 / segments as f32;
|
||||
let a = start_angle + (end_angle - start_angle) * t;
|
||||
let raw_x = inner_cx + inner_rx * a.cos();
|
||||
let raw_y = cy + inner_ry * a.sin();
|
||||
let offset_x = (1.0 - t) * diff_start_x + t * diff_end_x;
|
||||
let offset_y = (1.0 - t) * diff_start_y + t * diff_end_y;
|
||||
let (rpx, rpy) = rotate(raw_x + offset_x, raw_y + offset_y);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&crescent_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Bolt { .. } => {
|
||||
let nx = (bw * 0.18).max(1.0);
|
||||
let bolt_path = Path::new(|b| {
|
||||
let points = [
|
||||
(cx + nx, py1),
|
||||
(px2, py1 + bh * 0.28),
|
||||
(cx - nx, cy - bh * 0.05),
|
||||
(cx + nx, cy + bh * 0.05),
|
||||
(px1, py2 - bh * 0.28),
|
||||
(cx - nx, py2),
|
||||
];
|
||||
for (i, (px, py)) in points.iter().enumerate() {
|
||||
let (rpx, rpy) = rotate(*px, *py);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&bolt_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Arrow4 { .. } => {
|
||||
let arm_w = (bw * 0.125).max(1.0);
|
||||
let arm_h = (bh * 0.125).max(1.0);
|
||||
let head_extend_x = bw * 0.5 * 0.3;
|
||||
let head_extend_y = bh * 0.5 * 0.3;
|
||||
let arrow4_path = Path::new(|b| {
|
||||
let points = [
|
||||
(cx - arm_w, cy - arm_h),
|
||||
(cx - arm_w, py1 + head_extend_y),
|
||||
(cx, py1),
|
||||
(cx + arm_w, py1 + head_extend_y),
|
||||
(cx + arm_w, cy - arm_h),
|
||||
(px2 - head_extend_x, cy - arm_h),
|
||||
(px2, cy),
|
||||
(px2 - head_extend_x, cy + arm_h),
|
||||
(cx + arm_w, cy + arm_h),
|
||||
(cx + arm_w, py2 - head_extend_y),
|
||||
(cx, py2),
|
||||
(cx - arm_w, py2 - head_extend_y),
|
||||
(cx - arm_w, cy + arm_h),
|
||||
(px1 + head_extend_x, cy + arm_h),
|
||||
(px1, cy),
|
||||
(px1 + head_extend_x, cy - arm_h),
|
||||
];
|
||||
for (i, (px, py)) in points.iter().enumerate() {
|
||||
let (rpx, rpy) = rotate(*px, *py);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&arrow4_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::Rect { .. } => {
|
||||
let corners = [(px1, py1), (px2, py1), (px2, py2), (px1, py2)];
|
||||
let v_path = Path::new(|b| {
|
||||
for (i, &(px, py)) in corners.iter().enumerate() {
|
||||
let (rpx, rpy) = rotate(px, py);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&v_path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::FreePath { pts, .. } => {
|
||||
if pts.is_empty() {
|
||||
return;
|
||||
}
|
||||
let path = Path::new(|b| {
|
||||
for (i, pt) in pts.iter().enumerate() {
|
||||
let (rpx, rpy) = rotate(pt[0], pt[1]);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
});
|
||||
frame.stroke(&path, stroke_style);
|
||||
}
|
||||
hcie_engine_api::VectorShape::SvgShape { .. } => {
|
||||
let corners = [(px1, py1), (px2, py1), (px2, py2), (px1, py2)];
|
||||
let svg_path = Path::new(|b| {
|
||||
for (i, &(px, py)) in corners.iter().enumerate() {
|
||||
let (rpx, rpy) = rotate(px, py);
|
||||
let spx = origin_x + rpx * zoom;
|
||||
let spy = origin_y + rpy * zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(spx, spy));
|
||||
} else {
|
||||
b.line_to(Point::new(spx, spy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&svg_path, stroke_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a small cursor glyph next to the hovered vector handle.
|
||||
///
|
||||
/// Purpose: Gives immediate visual feedback for the operation that will
|
||||
@@ -1295,42 +1786,20 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
// A solid bright outline is used instead of dashed so the preview is
|
||||
// clearly visible during fast pointer movements.
|
||||
if let Some(preview) = self.vector_drag_preview.as_ref() {
|
||||
let (px1, py1, px2, py2) = preview.normalized_bounds();
|
||||
let pang = preview.angle();
|
||||
let cx = (px1 + px2) * 0.5;
|
||||
let cy = (py1 + py2) * 0.5;
|
||||
let cos_a = pang.cos();
|
||||
let sin_a = pang.sin();
|
||||
let rotate = |x: f32, y: f32| -> (f32, f32) {
|
||||
let dx = x - cx;
|
||||
let dy = y - cy;
|
||||
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
|
||||
let stroke_style = Stroke {
|
||||
style: canvas::stroke::Style::Solid(
|
||||
iced::Color::from_rgba(0.2, 0.9, 0.4, 0.95),
|
||||
),
|
||||
width: (2.5 / self.zoom.max(1.0)).max(1.0),
|
||||
..Default::default()
|
||||
};
|
||||
let corners = [(px1, py1), (px2, py1), (px2, py2), (px1, py2)];
|
||||
let rotated_corners: Vec<(f32, f32)> =
|
||||
corners.iter().map(|&(x, y)| rotate(x, y)).collect();
|
||||
let preview_path = Path::new(|b| {
|
||||
for (i, &(rx, ry)) in rotated_corners.iter().enumerate() {
|
||||
let sx = origin_x + rx * self.zoom;
|
||||
let sy = origin_y + ry * self.zoom;
|
||||
if i == 0 {
|
||||
b.move_to(Point::new(sx, sy));
|
||||
} else {
|
||||
b.line_to(Point::new(sx, sy));
|
||||
}
|
||||
}
|
||||
b.close();
|
||||
});
|
||||
// Solid bright outline for fast visible feedback
|
||||
frame.stroke(
|
||||
&preview_path,
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(
|
||||
iced::Color::from_rgba(0.2, 0.9, 0.4, 0.95),
|
||||
),
|
||||
width: (2.5 / self.zoom.max(1.0)).max(1.0),
|
||||
..Default::default()
|
||||
},
|
||||
self.draw_vector_shape_preview(
|
||||
&mut frame,
|
||||
preview,
|
||||
origin_x,
|
||||
origin_y,
|
||||
self.zoom,
|
||||
stroke_style,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,8 @@ pub fn panel_body<'a>(
|
||||
app.tool_state.brush_size,
|
||||
app.tool_state.brush_opacity,
|
||||
app.tool_state.brush_hardness,
|
||||
app.tool_state.brush_color_variant,
|
||||
app.tool_state.brush_variant_amount,
|
||||
app.brush_category,
|
||||
&app.tool_state.imported_brushes,
|
||||
app.tool_state.active_brush_preset.as_ref(),
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use crate::widgets::plain_slider::plain_slider;
|
||||
use hcie_engine_api::BrushStyle;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, svg, text};
|
||||
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, svg, text};
|
||||
use iced::{Element, Length};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -412,6 +412,8 @@ pub fn view(
|
||||
current_size: f32,
|
||||
current_opacity: f32,
|
||||
current_hardness: f32,
|
||||
brush_color_variant: bool,
|
||||
brush_variant_amount: f32,
|
||||
active_category: BrushCategory,
|
||||
imported_presets: &[hcie_engine_api::BrushPreset],
|
||||
active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
|
||||
@@ -422,7 +424,7 @@ pub fn view(
|
||||
(BrushCategory::All, "All brushes", "icons/panel-brush.svg"),
|
||||
(BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
|
||||
(BrushCategory::Painting, "Painting", "icons/brush.svg"),
|
||||
(BrushCategory::Watercolor, "Watercolor", "icons/brush.svg"),
|
||||
(BrushCategory::Watercolor, "Watercolor", "icons/watercolor.svg"),
|
||||
(BrushCategory::Effects, "Effects", "icons/glow.svg"),
|
||||
(BrushCategory::Custom, "Custom", "icons/star.svg"),
|
||||
(BrushCategory::Imported, "Imported", "icons/import_abr.svg"),
|
||||
@@ -720,6 +722,21 @@ pub fn view(
|
||||
colors,
|
||||
Message::BrushHardnessChanged,
|
||||
);
|
||||
let variant_slider = plain_slider(
|
||||
"Variant",
|
||||
brush_variant_amount,
|
||||
0.0..=1.0,
|
||||
0.01,
|
||||
"%",
|
||||
0,
|
||||
colors,
|
||||
Message::BrushVariantAmountChanged,
|
||||
);
|
||||
|
||||
let color_variant_check = checkbox("Color variant", brush_color_variant)
|
||||
.on_toggle(Message::BrushColorVariantToggled)
|
||||
.size(16)
|
||||
.spacing(6);
|
||||
|
||||
// Live preview — show current brush tip
|
||||
let preview_handle = get_brush_preview(current_style);
|
||||
@@ -763,6 +780,8 @@ pub fn view(
|
||||
size_slider,
|
||||
opacity_slider,
|
||||
hardness_slider,
|
||||
color_variant_check,
|
||||
variant_slider,
|
||||
]
|
||||
.spacing(2)
|
||||
.padding(4);
|
||||
|
||||
@@ -21,9 +21,10 @@ use crate::theme::ThemeColors;
|
||||
use crate::widgets::plain_slider::plain_slider;
|
||||
use hcie_engine_api::{BlendMode, LayerInfo, LayerType};
|
||||
use iced::widget::{
|
||||
button, column, container, horizontal_rule, pick_list, row, scrollable, text, Space,
|
||||
button, column, container, horizontal_rule, pick_list, row, scrollable, svg, text, Space,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
use std::path::Path;
|
||||
|
||||
/// Wrapper for BlendMode to provide Display for pick_list.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -134,6 +135,38 @@ fn flat_btn_style(colors: ThemeColors) -> iced::widget::button::Style {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to create a small SVG icon button.
|
||||
/// Falls back to a text button if the SVG file is missing.
|
||||
fn svg_icon_btn<'a>(
|
||||
icon_path: &str,
|
||||
_tip: &'a str,
|
||||
msg: Message,
|
||||
colors: ThemeColors,
|
||||
size: f32,
|
||||
) -> Element<'a, Message> {
|
||||
let full_path = Path::new("hcie-iced-app/assets").join(icon_path);
|
||||
if full_path.exists() {
|
||||
let icon = svg(svg::Handle::from_path(&full_path))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(move |_theme: &iced::Theme, _status: svg::Status| svg::Style {
|
||||
color: Some(colors.text_primary),
|
||||
});
|
||||
button(icon)
|
||||
.on_press(msg)
|
||||
.padding([2, 3])
|
||||
.style(move |_theme, _status: iced::widget::button::Status| flat_btn_style(colors))
|
||||
.into()
|
||||
} else {
|
||||
// Fallback to text
|
||||
button(text(_tip).size((size * 0.6) as u16))
|
||||
.on_press(msg)
|
||||
.padding([2, 3])
|
||||
.style(move |_theme, _status: iced::widget::button::Status| flat_btn_style(colors))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the thumbnail element for a layer row from the cache or as a fallback icon.
|
||||
fn layer_thumbnail<'a>(
|
||||
entry: &LayerEntry<'a>,
|
||||
@@ -292,32 +325,27 @@ pub fn view<'a>(
|
||||
};
|
||||
|
||||
// Visibility toggle (eye icon)
|
||||
let vis_char = if info.visible {
|
||||
"\u{1F441}"
|
||||
let vis_icon_path = if info.visible {
|
||||
"icons/visible.svg"
|
||||
} else {
|
||||
"\u{1F441}"
|
||||
"icons/eye_closed.svg"
|
||||
};
|
||||
let vis_color = if info.visible {
|
||||
colors.text_primary
|
||||
} else {
|
||||
iced::Color {
|
||||
a: colors.text_secondary.a * 0.4,
|
||||
..colors.text_secondary
|
||||
}
|
||||
};
|
||||
let vis_btn = button(text(vis_char).size(11).style(move |_theme: &iced::Theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(vis_color),
|
||||
}
|
||||
}))
|
||||
.on_press(Message::LayerToggleVisibility(info.id))
|
||||
.padding([0, 2])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let vis_tip = if info.visible { "Hide" } else { "Show" };
|
||||
let vis_btn = svg_icon_btn(
|
||||
vis_icon_path,
|
||||
vis_tip,
|
||||
Message::LayerToggleVisibility(info.id),
|
||||
colors,
|
||||
14.0,
|
||||
);
|
||||
|
||||
let row_lock = button(text(if info.locked { "L" } else { "-" }).size(9))
|
||||
.on_press(Message::LayerToggleLock(info.id))
|
||||
.padding([0, 2])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let row_lock = svg_icon_btn(
|
||||
"icons/lock.svg",
|
||||
if info.locked { "Unlock" } else { "Lock" },
|
||||
Message::LayerToggleLock(info.id),
|
||||
colors,
|
||||
12.0,
|
||||
);
|
||||
|
||||
// Thumbnail
|
||||
let thumb_elem = layer_thumbnail(entry, thumb_cache, thumb_gen, colors);
|
||||
@@ -454,22 +482,31 @@ pub fn view<'a>(
|
||||
}
|
||||
|
||||
// ── Bottom toolbar ─────────────────────────────────────
|
||||
let add_btn = button(text("+Layer").size(BODY))
|
||||
.on_press(Message::LayerAdd)
|
||||
.padding([5, 8])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let group_btn = button(text("+Group").size(BODY))
|
||||
.on_press(Message::LayerAddGroup)
|
||||
.padding([5, 8])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let add_btn = svg_icon_btn(
|
||||
"icons/new_file.svg",
|
||||
"New Layer",
|
||||
Message::LayerAdd,
|
||||
colors,
|
||||
16.0,
|
||||
);
|
||||
let group_btn = svg_icon_btn(
|
||||
"icons/panel-layers.svg",
|
||||
"New Group",
|
||||
Message::LayerAddGroup,
|
||||
colors,
|
||||
16.0,
|
||||
);
|
||||
let flatten_btn = button(text("Flatten").size(BODY))
|
||||
.on_press(Message::LayerFlatten)
|
||||
.padding([5, 8])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let del_btn = button(text("\u{2716}").size(BODY))
|
||||
.on_press(Message::LayerDelete(active_layer_id))
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let del_btn = svg_icon_btn(
|
||||
"icons/close.svg",
|
||||
"Delete Layer",
|
||||
Message::LayerDelete(active_layer_id),
|
||||
colors,
|
||||
14.0,
|
||||
);
|
||||
let move_up_btn = button(text("\u{25B2}").size(BODY))
|
||||
.on_press(Message::LayerMoveUp(active_layer_id))
|
||||
.padding([3, 4])
|
||||
|
||||
@@ -46,6 +46,7 @@ pub enum MenuCommand {
|
||||
ClearPixels,
|
||||
Paste,
|
||||
PasteSpecial,
|
||||
PasteAsNewLayer,
|
||||
Fill,
|
||||
Stroke,
|
||||
FreeTransform,
|
||||
@@ -343,9 +344,14 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::separator(),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Paste Special",
|
||||
"Shift+Ctrl+V",
|
||||
"",
|
||||
MenuCommand::PasteSpecial,
|
||||
),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Paste as New Layer",
|
||||
"Ctrl+Shift+V",
|
||||
MenuCommand::PasteAsNewLayer,
|
||||
),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command_with_shortcut("Fill...", "Shift+F5", MenuCommand::Fill),
|
||||
MenuItem::command("Stroke...", MenuCommand::Stroke),
|
||||
@@ -1083,6 +1089,11 @@ pub fn canvas_context_menu<'a>(
|
||||
"Ctrl+V",
|
||||
MenuCommand::Paste,
|
||||
));
|
||||
items.push(MenuItem::command_with_shortcut(
|
||||
"Paste as New Layer",
|
||||
"Ctrl+Shift+V",
|
||||
MenuCommand::PasteAsNewLayer,
|
||||
));
|
||||
items.push(MenuItem::separator());
|
||||
items.push(MenuItem::command("New Layer", MenuCommand::AddLayer));
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::settings::AppSettings;
|
||||
use crate::theme::ThemeColors;
|
||||
use crate::widgets::plain_slider::plain_slider;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
|
||||
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, text, text_input};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the tool settings panel for the active tool.
|
||||
@@ -123,6 +123,17 @@ fn brush_settings(
|
||||
|v| Message::BrushSpacingChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
checkbox("Color Variant", ts.brush_color_variant)
|
||||
.on_toggle(Message::BrushColorVariantToggled)
|
||||
.size(11),
|
||||
prop_slider(
|
||||
"Variant Amount",
|
||||
ts.brush_variant_amount * 100.0,
|
||||
0.0,
|
||||
100.0,
|
||||
|v| Message::BrushVariantAmountChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
@@ -168,3 +168,33 @@ pub fn centered_transform(
|
||||
rotation: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a full canvas-sized RGBA buffer with clipboard image centered.
|
||||
///
|
||||
/// Returns a `Vec<u8>` of size `canvas_w * canvas_h * 4` with the clipboard image
|
||||
/// placed at the center, surrounded by transparent pixels. Used by the
|
||||
/// "Paste as New Layer" feature to write directly into a new layer.
|
||||
pub fn centered_layer_pixels(
|
||||
pixels: &[u8],
|
||||
img_w: u32,
|
||||
img_h: u32,
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; (canvas_w * canvas_h * 4) as usize];
|
||||
let offset_x = ((canvas_w as i32 - img_w as i32) / 2).max(0) as u32;
|
||||
let offset_y = ((canvas_h as i32 - img_h as i32) / 2).max(0) as u32;
|
||||
let copy_w = img_w.min(canvas_w - offset_x);
|
||||
let copy_h = img_h.min(canvas_h - offset_y);
|
||||
|
||||
for y in 0..copy_h {
|
||||
let src_start = ((y * img_w + 0) * 4) as usize;
|
||||
let src_end = ((y * img_w + copy_w) * 4) as usize;
|
||||
let dst_start = (((y + offset_y) * canvas_w + offset_x) * 4) as usize;
|
||||
if src_end <= pixels.len() && dst_start + (copy_w * 4) as usize <= buf.len() {
|
||||
buf[dst_start..dst_start + (copy_w * 4) as usize]
|
||||
.copy_from_slice(&pixels[src_start..src_end]);
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ pub struct ToolSettings {
|
||||
pub brush_hardness: f32,
|
||||
pub brush_flow: f32,
|
||||
pub brush_spacing: f32,
|
||||
/// True when each brush dab should vary its color slightly.
|
||||
#[serde(default)]
|
||||
pub brush_color_variant: bool,
|
||||
/// Magnitude of the per-dab color variation (0.0..1.0).
|
||||
#[serde(default)]
|
||||
pub brush_variant_amount: f32,
|
||||
pub eraser_size: f32,
|
||||
pub eraser_opacity: f32,
|
||||
pub pen_size: f32,
|
||||
@@ -184,6 +190,8 @@ impl Default for ToolSettings {
|
||||
brush_hardness: 0.8,
|
||||
brush_flow: 1.0,
|
||||
brush_spacing: 1.0,
|
||||
brush_color_variant: false,
|
||||
brush_variant_amount: 0.0,
|
||||
eraser_size: 20.0,
|
||||
eraser_opacity: 1.0,
|
||||
pen_size: 2.0,
|
||||
@@ -334,6 +342,8 @@ impl AppSettings {
|
||||
self.tool_settings.brush_size = tool_state.brush_size;
|
||||
self.tool_settings.brush_opacity = tool_state.brush_opacity;
|
||||
self.tool_settings.brush_hardness = tool_state.brush_hardness;
|
||||
self.tool_settings.brush_color_variant = tool_state.brush_color_variant;
|
||||
self.tool_settings.brush_variant_amount = tool_state.brush_variant_amount;
|
||||
self.tool_settings.last_active_tool = format!("{:?}", tool_state.active_tool);
|
||||
}
|
||||
|
||||
@@ -342,6 +352,8 @@ impl AppSettings {
|
||||
tool_state.brush_size = self.tool_settings.brush_size;
|
||||
tool_state.brush_opacity = self.tool_settings.brush_opacity;
|
||||
tool_state.brush_hardness = self.tool_settings.brush_hardness;
|
||||
tool_state.brush_color_variant = self.tool_settings.brush_color_variant;
|
||||
tool_state.brush_variant_amount = self.tool_settings.brush_variant_amount;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
//! **Logic & Workflow:** An Iced Canvas retains transient drag/edit state, maps pointer positions
|
||||
//! to stepped values, captures numeric keyboard input while hovered, and draws a recessed track,
|
||||
//! gradient value fill, spinner arrows, centered text, and hover elevation.
|
||||
//! **Interaction model:**
|
||||
//! - Click and drag anywhere on the track to adjust the value.
|
||||
//! - Type any numeric character while hovering to start direct text input.
|
||||
//! - Press `Enter` to commit the typed value, `Escape` to cancel.
|
||||
//! - Click the spinner arrows for single-step increments/decrements.
|
||||
//! **Side Effects / Dependencies:** Emits caller-provided messages; all state remains local to the
|
||||
//! widget tree and all colors derive from `ThemeColors`.
|
||||
|
||||
@@ -17,7 +22,6 @@ use std::sync::Arc;
|
||||
|
||||
const CONTROL_HEIGHT: f32 = 22.0;
|
||||
const SPINNER_WIDTH: f32 = 24.0;
|
||||
const VALUE_EDIT_WIDTH: f32 = 72.0;
|
||||
|
||||
/// Persistent interaction state for one slider instance.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -259,6 +263,7 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
|
||||
return (canvas::event::Status::Ignored, None);
|
||||
};
|
||||
|
||||
// Spinner arrows remain step controls.
|
||||
if position.x >= bounds.width - SPINNER_WIDTH {
|
||||
let direction = if position.y < bounds.height / 2.0 {
|
||||
1.0
|
||||
@@ -272,18 +277,10 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
|
||||
);
|
||||
}
|
||||
|
||||
let edit_left = (bounds.width - SPINNER_WIDTH - VALUE_EDIT_WIDTH) / 2.0;
|
||||
let edit_right = edit_left + VALUE_EDIT_WIDTH;
|
||||
if position.x >= edit_left && position.x <= edit_right {
|
||||
state.editing = true;
|
||||
state.buffer = if self.suffix == "%" {
|
||||
format!("{:.*}", self.decimals, self.value * 100.0)
|
||||
} else {
|
||||
format!("{:.*}", self.decimals, self.value)
|
||||
};
|
||||
return (canvas::event::Status::Captured, None);
|
||||
}
|
||||
|
||||
// Anywhere else on the track starts a drag; explicit edit mode
|
||||
// is no longer required before typing.
|
||||
state.editing = false;
|
||||
state.buffer.clear();
|
||||
state.dragging = true;
|
||||
let value = self.value_at(position.x, bounds.width);
|
||||
(
|
||||
@@ -291,6 +288,15 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
|
||||
Some((self.on_change)(value)),
|
||||
)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
|
||||
// Right click outside edit mode is ignored; while editing it cancels.
|
||||
if state.editing {
|
||||
state.editing = false;
|
||||
state.buffer.clear();
|
||||
return (canvas::event::Status::Captured, None);
|
||||
}
|
||||
(canvas::event::Status::Ignored, None)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||||
if let Some(position) = local {
|
||||
let value = self.value_at(position.x, bounds.width);
|
||||
@@ -338,6 +344,7 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
|
||||
return (canvas::event::Status::Captured, None);
|
||||
}
|
||||
} else if let Some(text) = text.filter(|text| is_numeric_fragment(text)) {
|
||||
// Typing while hovering immediately starts editing; no click needed.
|
||||
PlainSlider::<Message>::begin_typing(state, &text);
|
||||
return (canvas::event::Status::Captured, None);
|
||||
}
|
||||
|
||||
@@ -130,16 +130,19 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
|
||||
let cy = h as f32 / 2.0;
|
||||
let base_radius = w as f32 * 0.38;
|
||||
|
||||
// Draw 4 overlapping layers for depth
|
||||
// ── Core puddles: overlapping irregular blobs ─────────────────────────
|
||||
for (li, &(sx, sy)) in LAYER_SEEDS.iter().enumerate() {
|
||||
let layer_hue = bh + (li as f32 - 1.5) * 8.0;
|
||||
let layer_sat = (bs + (li as f32 - 1.5) * 0.05).clamp(0.0, 1.0);
|
||||
let layer_lum = (bl + (li as f32 - 1.5) * 0.03).clamp(0.0, 1.0);
|
||||
// Randomised offsets so every splat feels different
|
||||
let angle = fbm(sx * 10.0, sy * 10.0, 2) * std::f32::consts::TAU;
|
||||
let offset_r = w as f32 * 0.06 * fbm(sx * 20.0, sy * 20.0, 2);
|
||||
let offset_x = angle.cos() * offset_r;
|
||||
let offset_y = angle.sin() * offset_r;
|
||||
let layer_hue = bh + (li as f32 - 1.5) * 12.0 + fbm(sx * 5.0, sy * 5.0, 2) * 10.0;
|
||||
let layer_sat = (bs + (li as f32 - 1.5) * 0.06).clamp(0.0, 1.0);
|
||||
let layer_lum = (bl + (li as f32 - 1.5) * 0.04).clamp(0.0, 1.0);
|
||||
let rgb = hsl_to_rgb(layer_hue.rem_euclid(360.0), layer_sat, layer_lum);
|
||||
let layer_alpha = 0.35 - li as f32 * 0.04;
|
||||
let offset_x = (li as f32 - 1.5) * 2.5;
|
||||
let offset_y = (li as f32 - 1.5) * 1.8;
|
||||
let radius = base_radius * (1.0 + li as f32 * 0.06);
|
||||
let layer_alpha = 0.32 - li as f32 * 0.03;
|
||||
let radius = base_radius * (1.0 + li as f32 * 0.08);
|
||||
|
||||
for py in 0..h {
|
||||
for px in 0..w {
|
||||
@@ -147,14 +150,14 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
|
||||
let dy = py as f32 - (cy + offset_y);
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
// Angular displacement for organic edge
|
||||
let angle = dy.atan2(dx);
|
||||
// More aggressive noise displacement for ragged edges
|
||||
let a = dy.atan2(dx);
|
||||
let noise_val = fbm(
|
||||
angle * 2.0 + sx * 10.0,
|
||||
dist / radius + sy * 5.0,
|
||||
3,
|
||||
a * 3.0 + sx * 10.0,
|
||||
dist / radius * 1.5 + sy * 7.0,
|
||||
4,
|
||||
);
|
||||
let displaced = radius * (0.7 + 0.3 * noise_val);
|
||||
let displaced = radius * (0.55 + 0.45 * noise_val);
|
||||
|
||||
if dist > displaced {
|
||||
continue;
|
||||
@@ -162,20 +165,20 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
|
||||
|
||||
let t = dist / displaced;
|
||||
|
||||
// Radial alpha: solid center, soft edge
|
||||
let mut alpha = (1.0 - t * t) * layer_alpha;
|
||||
// Chaotic radial alpha: not a smooth gradient
|
||||
let mut alpha = (1.0 - t * t).powf(0.7 + noise_val * 0.6) * layer_alpha;
|
||||
|
||||
// Wet edge: slightly more opaque/darker near boundary (60-90%)
|
||||
if t > 0.6 && t < 0.95 {
|
||||
let edge_t = (t - 0.6) / 0.35;
|
||||
alpha += 0.08 * (1.0 - edge_t);
|
||||
// Wet edge ring
|
||||
if t > 0.55 && t < 0.95 {
|
||||
let edge_t = (t - 0.55) / 0.40;
|
||||
alpha += 0.10 * (1.0 - edge_t) * (0.5 + 0.5 * noise_val);
|
||||
}
|
||||
|
||||
// Granulation in outer region
|
||||
if t > 0.35 {
|
||||
let gran = fbm(px as f32 * 0.4 + sx * 100.0, py as f32 * 0.4 + sy * 100.0, 2);
|
||||
let gran_mask = ((t - 0.35) / 0.65).min(1.0);
|
||||
alpha *= 0.7 + 0.3 * gran * gran_mask;
|
||||
// Granulation / paper texture in outer region
|
||||
if t > 0.25 {
|
||||
let gran = fbm(px as f32 * 0.55 + sx * 100.0, py as f32 * 0.55 + sy * 100.0, 3);
|
||||
let gran_mask = ((t - 0.25) / 0.75).min(1.0);
|
||||
alpha *= 0.65 + 0.35 * gran * gran_mask;
|
||||
}
|
||||
|
||||
alpha = alpha.clamp(0.0, 1.0);
|
||||
@@ -203,6 +206,53 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spontaneous splatter droplets ───────────────────────────────────────
|
||||
let splatter_count = 18;
|
||||
for i in 0..splatter_count {
|
||||
let seed_a = i as f32 * 1.7;
|
||||
let seed_r = i as f32 * 2.3;
|
||||
let a = fbm(seed_a * 10.0, seed_a * 20.0, 2) * std::f32::consts::TAU;
|
||||
let dist = base_radius * (0.45 + fbm(seed_r * 15.0, seed_r * 25.0, 2) * 0.9);
|
||||
let sx = cx + a.cos() * dist;
|
||||
let sy = cy + a.sin() * dist;
|
||||
let s_radius = base_radius * (0.04 + fbm(seed_a * 30.0, seed_r * 30.0, 2) * 0.12);
|
||||
let s_alpha = 0.25 + fbm(seed_a * 50.0, seed_r * 50.0, 2) * 0.35;
|
||||
let s_hue = bh + fbm(seed_a * 8.0, seed_r * 8.0, 2) * 15.0;
|
||||
let s_rgb = hsl_to_rgb(s_hue.rem_euclid(360.0), bs, bl);
|
||||
|
||||
let x0 = (sx - s_radius).max(0.0) as u32;
|
||||
let x1 = (sx + s_radius).min(w as f32 - 1.0) as u32;
|
||||
let y0 = (sy - s_radius).max(0.0) as u32;
|
||||
let y1 = (sy + s_radius).min(h as f32 - 1.0) as u32;
|
||||
for py in y0..=y1 {
|
||||
for px in x0..=x1 {
|
||||
let dx = px as f32 - sx;
|
||||
let dy = py as f32 - sy;
|
||||
let d = (dx * dx + dy * dy).sqrt();
|
||||
if d > s_radius {
|
||||
continue;
|
||||
}
|
||||
let alpha = (1.0 - d / s_radius) * s_alpha;
|
||||
let idx = ((py * w + px) * 4) as usize;
|
||||
let src_a = alpha;
|
||||
let dst_a = pixels[idx + 3] as f32 / 255.0;
|
||||
let out_a = src_a + dst_a * (1.0 - src_a);
|
||||
if out_a > 0.0 {
|
||||
pixels[idx] = ((s_rgb[0] as f32 * src_a
|
||||
+ pixels[idx] as f32 * dst_a * (1.0 - src_a))
|
||||
/ out_a) as u8;
|
||||
pixels[idx + 1] = ((s_rgb[1] as f32 * src_a
|
||||
+ pixels[idx + 1] as f32 * dst_a * (1.0 - src_a))
|
||||
/ out_a) as u8;
|
||||
pixels[idx + 2] = ((s_rgb[2] as f32 * src_a
|
||||
+ pixels[idx + 2] as f32 * dst_a * (1.0 - src_a))
|
||||
/ out_a) as u8;
|
||||
pixels[idx + 3] = (out_a * 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pixels
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user