feat(svg-editor): Implement SVG shape loading and editing functionality
- Add SVG shape loading from disk, allowing users to customize shapes by placing SVG files in the designated directory. - Introduce a new `ShapeCatalog` to manage SVG shapes, prioritizing user-defined shapes over built-in templates. - Implement an SVG editor with node-based editing capabilities, enabling users to manipulate SVG paths directly. - Create a new `SvgEditable` structure to represent editable SVG shapes, supporting operations like adding, removing, and moving nodes. - Enhance the GUI with an SVG editor panel for visual editing of SVG shapes, including controls for node manipulation and saving changes. - Ensure compatibility with existing shape tools and maintain a seamless user experience across the application.
This commit is contained in:
@@ -176,6 +176,9 @@ pub struct HcieApp {
|
||||
/// Used to keep documents in the center area and prevent swapping with utility panels.
|
||||
pub canonical_doc_node: Option<egui_dock::NodePath>,
|
||||
|
||||
/// SVG editor modal state. `Some` when the editor is open.
|
||||
pub svg_editor_state: Option<panels::svg_editor_panel::SvgEditorState>,
|
||||
|
||||
pub paste_counter: u64,
|
||||
/// Hash of last clipboard image to detect new clipboard content.
|
||||
pub clipboard_hash: u64,
|
||||
@@ -631,6 +634,7 @@ impl HcieApp {
|
||||
app_icon: None,
|
||||
pending_paste_data: None,
|
||||
hide_panels: false,
|
||||
svg_editor_state: None,
|
||||
};
|
||||
if std::env::var("FLOAT_TEST_PANELS").is_ok() {
|
||||
app.panels_to_float.push(dock::HciePane::Brushes);
|
||||
@@ -1178,6 +1182,41 @@ impl HcieApp {
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
AppEvent::OpenSvgEditor {
|
||||
layer_id,
|
||||
shape_idx,
|
||||
} => {
|
||||
if let Some(doc) = self.documents.get(self.active_doc) {
|
||||
if let Some(shapes) = doc.engine.active_vector_shapes() {
|
||||
if let Some(shape) = shapes.get(shape_idx) {
|
||||
if let hcie_engine_api::VectorShape::SvgShape { svg, .. } = shape {
|
||||
match hcie_engine_api::SvgEditable::from_svg(svg) {
|
||||
Ok(editable) => {
|
||||
self.svg_editor_state = Some(
|
||||
panels::svg_editor_panel::SvgEditorState {
|
||||
layer_id,
|
||||
shape_idx,
|
||||
editable,
|
||||
selected_poly: 0,
|
||||
selected_node: None,
|
||||
drag_node: None,
|
||||
rendered_preview: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_message = Some((
|
||||
format!("SVG Editor: {}", e),
|
||||
std::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(3),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::ToolTextStart { x, y } => {
|
||||
if self.state.text_edit.active {
|
||||
let (layer_id, draft_text) = (
|
||||
@@ -4812,6 +4851,11 @@ impl eframe::App for HcieApp {
|
||||
}
|
||||
self.ui_main_assembly(&ctx);
|
||||
|
||||
// SVG Editor modal (overlays the main UI)
|
||||
if self.svg_editor_state.is_some() {
|
||||
panels::svg_editor_panel::show_svg_editor(self, &ctx);
|
||||
}
|
||||
|
||||
// Dev-only: handle screenshot events triggered by F12
|
||||
if self.screenshot_requested {
|
||||
let mut captured = None;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod panels;
|
||||
pub use panels::*;
|
||||
pub mod color;
|
||||
pub mod svg_editor_panel;
|
||||
|
||||
@@ -1733,6 +1733,23 @@ pub fn show_properties(
|
||||
}
|
||||
|
||||
changed |= ui.add(PlainSlider::new("Hardness", &mut state.tool_configs.vector.hardness, 0.0..=1.0, state.settings.theme)).changed();
|
||||
|
||||
// Edit SVG button for CustomShape (visible in Properties panel without switching to VectorSelect)
|
||||
if matches!(state.active_tool, Tool::CustomShape(_)) {
|
||||
if let Some(shape_idx) = state.selected_vector_shape {
|
||||
let layer_id = doc.engine.active_layer_id();
|
||||
if let Some(shapes) = doc.engine.active_vector_shapes() {
|
||||
if let Some(shape) = shapes.get(shape_idx) {
|
||||
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
|
||||
ui.add_space(4.0);
|
||||
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
|
||||
event_bus.push(crate::event_bus::AppEvent::OpenSvgEditor { layer_id, shape_idx });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Tool::Text => {
|
||||
crate::app::tools::text_editor::draw_text_properties(doc, state, event_bus, ui);
|
||||
@@ -1748,8 +1765,20 @@ pub fn show_properties(
|
||||
let layer_id = doc.engine.active_layer_id();
|
||||
ui.label(RichText::new(format!("Shape #{}", shape_idx + 1)).strong().color(colors.accent));
|
||||
ui.add_space(4.0);
|
||||
if let Some(shapes) = doc.engine.active_vector_shapes() {
|
||||
if let Some(shape) = shapes.get(shape_idx) {
|
||||
if let Some(shapes) = doc.engine.active_vector_shapes() {
|
||||
if let Some(shape) = shapes.get(shape_idx) {
|
||||
// ── Edit SVG button for SvgShape variants ──
|
||||
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
|
||||
event_bus.push(crate::event_bus::AppEvent::OpenSvgEditor {
|
||||
layer_id,
|
||||
shape_idx,
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
let stroke = shape.stroke();
|
||||
let mut new_stroke = stroke;
|
||||
changed |= ui.add(PlainSlider::new("Stroke", &mut new_stroke, 0.0..=100.0, state.settings.theme).suffix("px")).changed();
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
use crate::app::{HcieApp, ThemeColors};
|
||||
use eframe::egui;
|
||||
use egui::Vec2;
|
||||
use hcie_engine_api::SvgEditable;
|
||||
|
||||
// ── SvgEditorState ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runtime state for the SVG editor panel.
|
||||
pub struct SvgEditorState {
|
||||
pub layer_id: u64,
|
||||
pub shape_idx: usize,
|
||||
pub editable: SvgEditable,
|
||||
pub selected_poly: usize,
|
||||
pub selected_node: Option<usize>,
|
||||
pub drag_node: Option<(usize, usize)>, // (poly_idx, node_idx)
|
||||
pub rendered_preview: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
// ── Coordinate helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Map an SVG viewBox coordinate to a pixel position within the canvas rect.
|
||||
fn svg_to_screen(
|
||||
sx: f32,
|
||||
sy: f32,
|
||||
vb: (f32, f32, f32, f32),
|
||||
canvas_rect: egui::Rect,
|
||||
) -> egui::Pos2 {
|
||||
let (vbx, vby, vbw, vbh) = vb;
|
||||
let margin = 16.0;
|
||||
let draw_w = canvas_rect.width() - margin * 2.0;
|
||||
let draw_h = canvas_rect.height() - margin * 2.0;
|
||||
let scale = (draw_w / vbw).min(draw_h / vbh).max(1.0);
|
||||
let ox = canvas_rect.left() + margin + (draw_w - vbw * scale) * 0.5;
|
||||
let oy = canvas_rect.top() + margin + (draw_h - vbh * scale) * 0.5;
|
||||
egui::pos2(ox + (sx - vbx) * scale, oy + (sy - vby) * scale)
|
||||
}
|
||||
|
||||
/// Map a pixel position back to SVG viewBox coordinates.
|
||||
fn screen_to_svg(
|
||||
p: egui::Pos2,
|
||||
vb: (f32, f32, f32, f32),
|
||||
canvas_rect: egui::Rect,
|
||||
) -> (f32, f32) {
|
||||
let (vbx, vby, vbw, vbh) = vb;
|
||||
let margin = 16.0;
|
||||
let draw_w = canvas_rect.width() - margin * 2.0;
|
||||
let draw_h = canvas_rect.height() - margin * 2.0;
|
||||
let scale = (draw_w / vbw).min(draw_h / vbh).max(1.0);
|
||||
let ox = canvas_rect.left() + margin + (draw_w - vbw * scale) * 0.5;
|
||||
let oy = canvas_rect.top() + margin + (draw_h - vbh * scale) * 0.5;
|
||||
((p.x - ox) / scale + vbx, (p.y - oy) / scale + vby)
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn show_svg_editor(app: &mut HcieApp, ctx: &egui::Context) {
|
||||
let colors = ThemeColors::get(app.state.settings.theme);
|
||||
|
||||
// Extract state pointer — safe because we don't re-borrow app inside the window closure.
|
||||
let state_ptr: *mut SvgEditorState = match &mut app.svg_editor_state {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut action: SvgEditorAction = SvgEditorAction::None;
|
||||
|
||||
let window = egui::Window::new("SVG Editor")
|
||||
.id(egui::Id::new("svg_editor_window"))
|
||||
.default_size(ctx.used_size() * 0.6)
|
||||
.min_size([400.0, 300.0])
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]);
|
||||
|
||||
window.show(ctx, |ui| {
|
||||
// SAFETY: We do not access `app` through any other path inside this closure.
|
||||
let state = unsafe { &mut *state_ptr };
|
||||
let available = ui.available_size();
|
||||
let canvas_height = (available.y - 120.0).max(200.0);
|
||||
let vb = state.editable.view_box;
|
||||
|
||||
// ── Canvas area ─────────────────────────────────────────────────
|
||||
let (canvas_rect, canvas_response) = ui.allocate_exact_size(
|
||||
Vec2::new(ui.available_width(), canvas_height),
|
||||
egui::Sense::click_and_drag(),
|
||||
);
|
||||
let painter = ui.painter_at(canvas_rect);
|
||||
|
||||
// Draw background
|
||||
painter.rect_filled(canvas_rect, 0.0, colors.bg_dark_depth);
|
||||
|
||||
// Draw filled polygon (preview)
|
||||
draw_polygons(&painter, &state.editable, vb, canvas_rect, colors);
|
||||
// Draw nodes
|
||||
draw_nodes(&painter, &state.editable, vb, canvas_rect, colors, state);
|
||||
// Draw edge midpoints
|
||||
draw_midpoints(&painter, &state.editable, vb, canvas_rect, colors, state);
|
||||
|
||||
// ── Mouse handling ─────────────────────────────────────────────
|
||||
if canvas_response.hovered() {
|
||||
let pointer_pos = ui.input(|i| i.pointer.interact_pos());
|
||||
if let Some(pos) = pointer_pos {
|
||||
if canvas_response.clicked() {
|
||||
// Try to select a node
|
||||
let hit = hit_test_node(&state.editable, vb, canvas_rect, pos);
|
||||
if let Some((pi, ni)) = hit {
|
||||
state.selected_poly = pi;
|
||||
state.selected_node = Some(ni);
|
||||
} else {
|
||||
// Try to add a node on midpoint click
|
||||
let mid_hit = hit_test_midpoint(&state.editable, vb, canvas_rect, pos);
|
||||
if let Some((pi, ei)) = mid_hit {
|
||||
state.editable.add_node(pi, ei);
|
||||
state.selected_poly = pi;
|
||||
state.selected_node = Some((ei + 1) % state.editable.polygons[pi].len());
|
||||
} else {
|
||||
state.selected_node = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag existing node
|
||||
if canvas_response.dragged_by(egui::PointerButton::Primary) {
|
||||
if let Some((pi, ni)) = state.drag_node {
|
||||
let (sx, sy) = screen_to_svg(pos, vb, canvas_rect);
|
||||
state.editable.move_node(pi, ni, sx, sy);
|
||||
state.rendered_preview = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track drag start/stop
|
||||
if canvas_response.drag_started_by(egui::PointerButton::Primary) {
|
||||
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
|
||||
let hit = hit_test_node(&state.editable, vb, canvas_rect, pos);
|
||||
if let Some((pi, ni)) = hit {
|
||||
state.selected_poly = pi;
|
||||
state.selected_node = Some(ni);
|
||||
state.drag_node = Some((pi, ni));
|
||||
}
|
||||
}
|
||||
}
|
||||
if state.drag_node.is_some() && !canvas_response.dragged_by(egui::PointerButton::Primary) {
|
||||
// Drag ended this frame
|
||||
state.drag_node = None;
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// ── Polygon selector and controls ──────────────────────────────
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Polygon:");
|
||||
let poly_count = state.editable.polygons.len();
|
||||
let mut poly_sel = state.selected_poly;
|
||||
egui::ComboBox::from_id_salt("svg_poly_sel")
|
||||
.width(120.0)
|
||||
.selected_text(format!("#{} ({} nodes)", poly_sel + 1, {
|
||||
if poly_sel < state.editable.polygons.len() {
|
||||
state.editable.polygons[poly_sel].len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}))
|
||||
.show_ui(ui, |ui| {
|
||||
for i in 0..poly_count {
|
||||
let label =
|
||||
format!("#{} ({} nodes)", i + 1, state.editable.polygons[i].len());
|
||||
ui.selectable_value(&mut poly_sel, i, label);
|
||||
}
|
||||
});
|
||||
state.selected_poly = poly_sel.min(poly_count.max(1) - 1);
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.button("+ Add Node").on_hover_text("Add node after selected")
|
||||
.clicked()
|
||||
{
|
||||
if let Some(ni) = state.selected_node {
|
||||
state.editable.add_node(state.selected_poly, ni);
|
||||
state.selected_node = Some(ni + 1);
|
||||
state.rendered_preview = None;
|
||||
}
|
||||
}
|
||||
if ui.button("✕ Remove").on_hover_text("Remove selected node")
|
||||
.clicked()
|
||||
{
|
||||
if let Some(ni) = state.selected_node {
|
||||
state.editable.remove_node(state.selected_poly, ni);
|
||||
state.selected_node = None;
|
||||
state.rendered_preview = None;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// ── Node list ──────────────────────────────────────────────────
|
||||
if state.selected_poly < state.editable.polygons.len() {
|
||||
let poly = &state.editable.polygons[state.selected_poly];
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(80.0)
|
||||
.show(ui, |ui| {
|
||||
for (i, node) in poly.iter().enumerate() {
|
||||
let is_sel = state.selected_node == Some(i);
|
||||
let label = format!("#{}: ({:.1}, {:.1})", i, node.x, node.y);
|
||||
if ui.selectable_label(is_sel, label).clicked() {
|
||||
state.selected_node = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// ── Close / Save / Cancel ──────────────────────────────────────
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
action = SvgEditorAction::Save;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
action = SvgEditorAction::Close;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
match action {
|
||||
SvgEditorAction::Save => {
|
||||
let action_layer_id;
|
||||
let action_shape_idx;
|
||||
let new_svg;
|
||||
{
|
||||
let state = unsafe { &mut *state_ptr };
|
||||
new_svg = state.editable.to_svg();
|
||||
action_layer_id = state.layer_id;
|
||||
action_shape_idx = state.shape_idx;
|
||||
}
|
||||
if let Some(doc) = app.documents.get_mut(app.active_doc) {
|
||||
doc.engine
|
||||
.set_vector_shape_svg(action_layer_id, action_shape_idx, &new_svg);
|
||||
app.event_bus.push(crate::event_bus::AppEvent::RenderRequested);
|
||||
}
|
||||
app.svg_editor_state = None;
|
||||
}
|
||||
SvgEditorAction::Close => {
|
||||
app.svg_editor_state = None;
|
||||
}
|
||||
SvgEditorAction::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum SvgEditorAction {
|
||||
None,
|
||||
Save,
|
||||
Close,
|
||||
}
|
||||
|
||||
// ── Drawing helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_polygons(
|
||||
painter: &egui::Painter,
|
||||
editable: &SvgEditable,
|
||||
vb: (f32, f32, f32, f32),
|
||||
canvas_rect: egui::Rect,
|
||||
colors: ThemeColors,
|
||||
) {
|
||||
let fill_color = egui::Color32::from_rgba_premultiplied(
|
||||
colors.accent.r(),
|
||||
colors.accent.g(),
|
||||
colors.accent.b(),
|
||||
60,
|
||||
);
|
||||
let stroke = egui::Stroke::new(1.5, colors.text_primary);
|
||||
|
||||
for poly in &editable.polygons {
|
||||
if poly.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let points: Vec<egui::Pos2> = poly
|
||||
.iter()
|
||||
.map(|n| svg_to_screen(n.x, n.y, vb, canvas_rect))
|
||||
.collect();
|
||||
|
||||
// Fill
|
||||
if poly.len() >= 3 {
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
points.clone(),
|
||||
fill_color,
|
||||
stroke,
|
||||
));
|
||||
} else {
|
||||
// Line
|
||||
painter.add(egui::Shape::line_segment(
|
||||
[points[0], points[1]],
|
||||
stroke,
|
||||
));
|
||||
}
|
||||
|
||||
// Outline edges
|
||||
for i in 0..poly.len() {
|
||||
let a = points[i];
|
||||
let b = points[(i + 1) % poly.len()];
|
||||
painter.add(egui::Shape::line_segment([a, b], stroke));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_nodes(
|
||||
painter: &egui::Painter,
|
||||
editable: &SvgEditable,
|
||||
vb: (f32, f32, f32, f32),
|
||||
canvas_rect: egui::Rect,
|
||||
colors: ThemeColors,
|
||||
state: &SvgEditorState,
|
||||
) {
|
||||
let node_radius = 5.0;
|
||||
let fill = egui::Color32::WHITE;
|
||||
let selected_fill = colors.accent;
|
||||
|
||||
for (pi, poly) in editable.polygons.iter().enumerate() {
|
||||
for (ni, node) in poly.iter().enumerate() {
|
||||
let pos = svg_to_screen(node.x, node.y, vb, canvas_rect);
|
||||
let is_sel = pi == state.selected_poly && state.selected_node == Some(ni);
|
||||
let color = if is_sel { selected_fill } else { fill };
|
||||
painter.circle(pos, node_radius, color, egui::Stroke::new(1.5, colors.text_primary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_midpoints(
|
||||
painter: &egui::Painter,
|
||||
editable: &SvgEditable,
|
||||
vb: (f32, f32, f32, f32),
|
||||
canvas_rect: egui::Rect,
|
||||
colors: ThemeColors,
|
||||
_state: &SvgEditorState,
|
||||
) {
|
||||
let _ = _state;
|
||||
let mid_radius = 3.0;
|
||||
let fill = egui::Color32::from_gray(180);
|
||||
|
||||
for (_pi, poly) in editable.polygons.iter().enumerate() {
|
||||
if poly.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for ei in 0..poly.len() {
|
||||
let a = svg_to_screen(poly[ei].x, poly[ei].y, vb, canvas_rect);
|
||||
let b = svg_to_screen(poly[(ei + 1) % poly.len()].x, poly[(ei + 1) % poly.len()].y, vb, canvas_rect);
|
||||
let mid = egui::pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
|
||||
let is_hovered = {
|
||||
let pointer_pos = painter.ctx().input(|i| i.pointer.interact_pos());
|
||||
pointer_pos.map_or(false, |p| p.distance(mid) < 8.0)
|
||||
};
|
||||
let color = if is_hovered { colors.accent } else { fill };
|
||||
painter.circle(mid, mid_radius, color, egui::Stroke::new(1.0, colors.text_secondary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hit testing ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn hit_test_node(
|
||||
editable: &SvgEditable,
|
||||
vb: (f32, f32, f32, f32),
|
||||
canvas_rect: egui::Rect,
|
||||
pos: egui::Pos2,
|
||||
) -> Option<(usize, usize)> {
|
||||
let threshold = 8.0;
|
||||
let mut best: Option<(usize, usize, f32)> = None;
|
||||
for (pi, poly) in editable.polygons.iter().enumerate() {
|
||||
for (ni, node) in poly.iter().enumerate() {
|
||||
let sp = svg_to_screen(node.x, node.y, vb, canvas_rect);
|
||||
let dist = pos.distance(sp);
|
||||
if dist < threshold {
|
||||
let better = best.as_ref().map_or(true, |(_, _, d)| dist < *d);
|
||||
if better {
|
||||
best = Some((pi, ni, dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(pi, ni, _)| (pi, ni))
|
||||
}
|
||||
|
||||
fn hit_test_midpoint(
|
||||
editable: &SvgEditable,
|
||||
vb: (f32, f32, f32, f32),
|
||||
canvas_rect: egui::Rect,
|
||||
pos: egui::Pos2,
|
||||
) -> Option<(usize, usize)> {
|
||||
let threshold = 8.0;
|
||||
let mut best: Option<(usize, usize, f32)> = None;
|
||||
for (pi, poly) in editable.polygons.iter().enumerate() {
|
||||
if poly.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for ei in 0..poly.len() {
|
||||
let a = svg_to_screen(poly[ei].x, poly[ei].y, vb, canvas_rect);
|
||||
let b = svg_to_screen(
|
||||
poly[(ei + 1) % poly.len()].x,
|
||||
poly[(ei + 1) % poly.len()].y,
|
||||
vb,
|
||||
canvas_rect,
|
||||
);
|
||||
let mid = egui::pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
|
||||
let dist = pos.distance(mid);
|
||||
if dist < threshold {
|
||||
let better = best.as_ref().map_or(true, |(_, _, d)| dist < *d);
|
||||
if better {
|
||||
best = Some((pi, ei, dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(pi, ei, _)| (pi, ei))
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn save_svg(app: &mut HcieApp) {
|
||||
let state = match &app.svg_editor_state {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
let new_svg = state.editable.to_svg();
|
||||
let layer_id = state.layer_id;
|
||||
let shape_idx = state.shape_idx;
|
||||
if let Some(doc) = app.documents.get_mut(app.active_doc) {
|
||||
doc.engine
|
||||
.set_vector_shape_svg(layer_id, shape_idx, &new_svg);
|
||||
app.event_bus.push(crate::event_bus::AppEvent::RenderRequested);
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,28 @@ pub fn show_geometry(
|
||||
.size(11.0),
|
||||
);
|
||||
});
|
||||
// ── Edit SVG button for SvgShape variants ────────────────────
|
||||
{
|
||||
let svg_shapes = doc.engine.active_vector_shapes();
|
||||
if let Some(shapes) = svg_shapes {
|
||||
if shape_idx < shapes.len() {
|
||||
if matches!(&shapes[shape_idx], VectorShape::SvgShape { .. }) {
|
||||
let layer_id = doc.engine.active_layer_id();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(8.0);
|
||||
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
|
||||
event_bus.push(AppEvent::OpenSvgEditor {
|
||||
layer_id,
|
||||
shape_idx,
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
let shapes = match doc.engine.active_vector_shapes() {
|
||||
|
||||
@@ -58,6 +58,7 @@ fn tool_icon_source(tool: Tool) -> Option<egui::ImageSource<'static>> {
|
||||
Tool::VectorCrescent => Some(icon!("vector_crescent.svg")),
|
||||
Tool::VectorBolt => Some(icon!("vector_bolt.svg")),
|
||||
Tool::VectorArrow4 => Some(icon!("vector_arrow4.svg")),
|
||||
Tool::CustomShape(_) => None, // loaded dynamically via shape catalog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,9 +246,12 @@ pub fn show_tool_panel_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
|
||||
// Render active popup (if any) on top of everything
|
||||
// (custom shape popup is rendered inside render_custom_shape_slot)
|
||||
if let Some(slot_idx) = app.state.active_popup_slot {
|
||||
if let Some(anchor) = app.state.popup_anchor_pos {
|
||||
show_slot_popup(app, ui.ctx(), slot_idx, anchor);
|
||||
if slot_idx < TOOL_SLOTS.len() {
|
||||
if let Some(anchor) = app.state.popup_anchor_pos {
|
||||
show_slot_popup(app, ui.ctx(), slot_idx, anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,6 +453,9 @@ fn render_tool_slots_single_column(app: &mut HcieApp, ui: &mut egui::Ui) {
|
||||
|
||||
let _ = resp.on_hover_text(last_used_tool.label());
|
||||
}
|
||||
|
||||
// ── Custom shape slot ──
|
||||
render_custom_shape_slot(app, ui);
|
||||
}
|
||||
|
||||
fn render_tool_slots_two_column(app: &mut HcieApp, ui: &mut egui::Ui) {
|
||||
@@ -498,6 +505,26 @@ fn render_tool_slots_two_column(app: &mut HcieApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.add_space(row_height);
|
||||
}
|
||||
|
||||
// ── Custom shape slot (full width in two-column mode) ──
|
||||
let engine = app.documents.get(app.active_doc).map(|d| &d.engine);
|
||||
let catalog = match engine {
|
||||
Some(e) => &e.shape_catalog,
|
||||
None => return,
|
||||
};
|
||||
if !catalog.is_empty() {
|
||||
let top = ui.min_rect().bottom();
|
||||
// Use one column width for the slot
|
||||
let x = ui.min_rect().left();
|
||||
let slot_rect = egui::Rect::from_min_size(
|
||||
egui::pos2(x, top),
|
||||
egui::vec2(SLOT_W, TOOL_BTN_SIZE),
|
||||
);
|
||||
let _ = ui.allocate_ui_at_rect(slot_rect, |ui| {
|
||||
ui.set_width(SLOT_W);
|
||||
render_custom_shape_slot(app, ui);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_slot_response(
|
||||
@@ -570,3 +597,205 @@ fn handle_slot_response(
|
||||
|
||||
let _ = resp.on_hover_text(last_used_tool.label());
|
||||
}
|
||||
|
||||
/// Render a single slot for custom SVG shapes with a popup listing all entries.
|
||||
fn render_custom_shape_slot(app: &mut HcieApp, ui: &mut egui::Ui) {
|
||||
let engine = app
|
||||
.documents
|
||||
.get(app.active_doc)
|
||||
.map(|d| &d.engine);
|
||||
let catalog = match engine {
|
||||
Some(e) => &e.shape_catalog,
|
||||
None => return,
|
||||
};
|
||||
if catalog.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let theme = app.state.settings.theme;
|
||||
let colors = ThemeColors::get(theme);
|
||||
|
||||
// Determine the currently selected custom shape index
|
||||
let current_idx = match app.state.active_tool {
|
||||
Tool::CustomShape(i) => Some(i),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Button label from the catalog
|
||||
let _entry = current_idx.and_then(|i| catalog.get(i));
|
||||
let _label = _entry.map(|e| e.label.as_str()).unwrap_or("Custom Shape");
|
||||
let icon_str = _entry.map(|_| "○").unwrap_or("○");
|
||||
|
||||
let slot_idx = usize::MAX; // custom slot sentinel
|
||||
let is_active = current_idx.is_some();
|
||||
let has_submenu = catalog.len() > 1;
|
||||
|
||||
let icon = tool_icon_source(app.state.active_tool);
|
||||
let fallback = if icon.is_none() {
|
||||
Some(icon_str.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let layout_id = format!("toolbox.custom_shape_slot");
|
||||
let resp = ui.add(
|
||||
ToolSlotButton::new(icon, fallback, is_active, has_submenu, theme)
|
||||
.layout_id(layout_id.clone()),
|
||||
);
|
||||
let resp = crate::app::shell::gui_layout::track(&layout_id, ui, &resp);
|
||||
|
||||
let resp_rect = resp.rect;
|
||||
let resp_clicked = resp.clicked();
|
||||
let resp_secondary = resp.secondary_clicked();
|
||||
let pointer_down_on_this = resp.is_pointer_button_down_on();
|
||||
|
||||
if has_submenu {
|
||||
let pressing_this = app
|
||||
.state
|
||||
.slot_press_start
|
||||
.as_ref()
|
||||
.map(|(idx, _)| *idx == slot_idx)
|
||||
.unwrap_or(false);
|
||||
|
||||
if pointer_down_on_this && !pressing_this && app.state.active_popup_slot.is_none() {
|
||||
app.state.slot_press_start = Some((slot_idx, Instant::now()));
|
||||
}
|
||||
|
||||
if !pointer_down_on_this && pressing_this {
|
||||
app.state.slot_press_start = None;
|
||||
}
|
||||
|
||||
if let Some((idx, start)) = app.state.slot_press_start {
|
||||
if idx == slot_idx
|
||||
&& start.elapsed().as_millis() >= LONG_PRESS_MS
|
||||
&& app.state.active_popup_slot.is_none()
|
||||
{
|
||||
let anchor = resp_rect.right_center();
|
||||
app.state.active_popup_slot = Some(99); // custom slot popup marker
|
||||
app.state.popup_anchor_pos = Some(anchor);
|
||||
app.state.slot_press_start = None;
|
||||
app.state.popup_opened_via_drag = true;
|
||||
}
|
||||
}
|
||||
|
||||
if resp_clicked && app.state.active_popup_slot.is_none() {
|
||||
// Activate currently selected or first custom shape
|
||||
let idx = current_idx.unwrap_or(0);
|
||||
app.state.active_tool = Tool::CustomShape(idx);
|
||||
app.event_bus.push(AppEvent::ToolChanged(app.state.active_tool));
|
||||
}
|
||||
|
||||
if resp_secondary && app.state.active_popup_slot.is_none() {
|
||||
let anchor = resp_rect.right_center();
|
||||
app.state.active_popup_slot = Some(99);
|
||||
app.state.popup_anchor_pos = Some(anchor);
|
||||
app.state.popup_opened_via_drag = false;
|
||||
}
|
||||
} else {
|
||||
if resp_clicked {
|
||||
app.state.active_tool = Tool::CustomShape(0);
|
||||
app.event_bus.push(AppEvent::ToolChanged(app.state.active_tool));
|
||||
}
|
||||
}
|
||||
|
||||
let _ = resp.on_hover_text(_label);
|
||||
|
||||
// ── Render custom shapes popup if active ──
|
||||
if app.state.active_popup_slot == Some(99) {
|
||||
if let Some(anchor) = app.state.popup_anchor_pos {
|
||||
let popup_count = catalog.len() as f32;
|
||||
let popup_h = (popup_count * POPUP_ROW_H + 8.0).min(400.0);
|
||||
let screen = ui.ctx().content_rect();
|
||||
let popup_pos = egui::pos2(
|
||||
(anchor.x + 4.0)
|
||||
.min(screen.max.x - POPUP_W - 4.0)
|
||||
.max(screen.min.x + 4.0),
|
||||
(anchor.y - popup_h / 2.0)
|
||||
.min(screen.max.y - popup_h - 4.0)
|
||||
.max(screen.min.y + 4.0),
|
||||
);
|
||||
|
||||
let popup_id = egui::Id::new("custom_shape_popup");
|
||||
let popup_response = egui::Area::new(popup_id)
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(popup_pos)
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(colors.bg_elevated)
|
||||
.stroke(egui::Stroke::new(1.0, colors.border_high))
|
||||
.corner_radius(egui::CornerRadius::same(6))
|
||||
.inner_margin(egui::Margin::same(4))
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_width(POPUP_W);
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(popup_h)
|
||||
.scroll_bar_visibility(
|
||||
egui::scroll_area::ScrollBarVisibility::AlwaysHidden,
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0);
|
||||
let mut clicked_idx: Option<u32> = None;
|
||||
for entry in catalog.all() {
|
||||
let active = current_idx == Some(entry.index);
|
||||
let r = ui.allocate_response(
|
||||
egui::vec2(POPUP_W - 8.0, POPUP_ROW_H),
|
||||
egui::Sense::click(),
|
||||
);
|
||||
let rect = r.rect;
|
||||
if ui.is_rect_visible(rect) {
|
||||
let fill = if active {
|
||||
colors.accent.gamma_multiply(0.25)
|
||||
} else if r.hovered() {
|
||||
colors.bg_hover
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
if fill != egui::Color32::TRANSPARENT {
|
||||
ui.painter().rect_filled(
|
||||
rect,
|
||||
egui::CornerRadius::same(4),
|
||||
fill,
|
||||
);
|
||||
}
|
||||
let tint = if active {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.text_primary
|
||||
};
|
||||
ui.painter().text(
|
||||
egui::pos2(rect.left() + 6.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&entry.label,
|
||||
egui::FontId::proportional(12.0),
|
||||
tint,
|
||||
);
|
||||
}
|
||||
if r.clicked() {
|
||||
clicked_idx = Some(entry.index);
|
||||
}
|
||||
}
|
||||
if let Some(idx) = clicked_idx {
|
||||
app.state.active_tool = Tool::CustomShape(idx);
|
||||
app.event_bus.push(AppEvent::ToolChanged(
|
||||
app.state.active_tool,
|
||||
));
|
||||
app.state.popup_close_request = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.response;
|
||||
|
||||
let popup_rect = popup_response.rect;
|
||||
let pointer_pressed = ui.ctx().input(|i| i.pointer.any_pressed());
|
||||
let pointer_in_popup = ui.ctx().input(|i| {
|
||||
i.pointer
|
||||
.hover_pos()
|
||||
.map_or(false, |p| popup_rect.contains(p))
|
||||
});
|
||||
if pointer_pressed && !pointer_in_popup && !app.state.popup_close_request {
|
||||
app.state.popup_close_request = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,8 +1301,14 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
x2 as f32,
|
||||
y2 as f32,
|
||||
&self.state,
|
||||
&self.doc.engine.shape_catalog,
|
||||
);
|
||||
self.doc.engine.add_vector_shape(shape);
|
||||
// Auto-select the newly created shape
|
||||
if let Some(shapes) = self.doc.engine.active_vector_shapes() {
|
||||
self.state.selected_vector_shape = Some(shapes.len() - 1);
|
||||
self.state.vector_shapes_snapshot = Some(shapes.clone());
|
||||
}
|
||||
self.event_bus.push(AppEvent::RenderRequested);
|
||||
self.event_bus.push(AppEvent::DrawingFinished);
|
||||
}
|
||||
@@ -2166,6 +2172,7 @@ fn create_shape_from_drag(
|
||||
x2: f32,
|
||||
y2: f32,
|
||||
state: &ToolState,
|
||||
catalog: &hcie_engine_api::ShapeCatalog,
|
||||
) -> hcie_engine_api::VectorShape {
|
||||
use hcie_engine_api::VectorShape;
|
||||
let color = state.primary_color;
|
||||
@@ -2334,6 +2341,26 @@ fn create_shape_from_drag(
|
||||
_ => unreachable!(),
|
||||
}
|
||||
},
|
||||
Tool::CustomShape(idx) => {
|
||||
if let Some(entry) = catalog.get(idx) {
|
||||
VectorShape::SvgShape {
|
||||
x1, y1, x2, y2,
|
||||
kind: entry.kind.clone(),
|
||||
svg: entry.svg.clone(),
|
||||
stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
}
|
||||
} else {
|
||||
// Fallback: empty SVG rectangle
|
||||
VectorShape::SvgShape {
|
||||
x1, y1, x2, y2,
|
||||
kind: "custom".to_string(),
|
||||
svg: r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path d="M 0,0 L 1,0 L 1,1 L 0,1 Z"/></svg>"#.to_string(),
|
||||
stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => VectorShape::Line {
|
||||
x1,
|
||||
y1,
|
||||
|
||||
@@ -67,6 +67,10 @@ pub enum AppEvent {
|
||||
|
||||
// Vector
|
||||
VectorShapeDeleted(u64, usize),
|
||||
OpenSvgEditor {
|
||||
layer_id: u64,
|
||||
shape_idx: usize,
|
||||
},
|
||||
|
||||
// Clipboard
|
||||
ClipboardCopy,
|
||||
|
||||
Reference in New Issue
Block a user