MiMo 2.5 feat: enhance vector shape handling with angle snapping and color state management
This commit is contained in:
@@ -0,0 +1,120 @@
|
|||||||
|
# Plan: Fix Vector Tool Angle Persistence and Color State Leaking
|
||||||
|
|
||||||
|
## Problem Analysis
|
||||||
|
|
||||||
|
Both issues stem from `sync_tool_from_shape()` in `hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs`.
|
||||||
|
|
||||||
|
### Issue 1: Angle Persistence
|
||||||
|
|
||||||
|
**Root cause:** Two mechanisms conspire to leak angle state:
|
||||||
|
|
||||||
|
1. `sync_tool_from_shape()` (line 150) copies the selected shape's angle into `tool_settings.vector_angle`:
|
||||||
|
```rust
|
||||||
|
ts.vector_angle = shape.angle().to_degrees();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Canvas drag-rotation (line 6397 in `app.rs`) also writes the rotated angle back:
|
||||||
|
```rust
|
||||||
|
self.settings.tool_settings.vector_angle = angle.to_degrees();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. New SVG shape creation (line 5768 in `app.rs`) reads from the same field:
|
||||||
|
```rust
|
||||||
|
let svg_angle = self.settings.tool_settings.vector_angle.to_radians();
|
||||||
|
```
|
||||||
|
Then applies it to every new SVG shape at line 6203-6204:
|
||||||
|
```rust
|
||||||
|
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
|
||||||
|
shape.set_angle(svg_angle);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow that reproduces the bug:**
|
||||||
|
1. Draw a shape (angle=0, correct)
|
||||||
|
2. Select the shape → `sync_tool_from_shape` copies its angle to `tool_settings.vector_angle`
|
||||||
|
3. Rotate via drag → `tool_settings.vector_angle` updated to rotated value (e.g., 45°)
|
||||||
|
4. Draw a NEW shape → `svg_angle = 45°` → new shape created with 45° rotation
|
||||||
|
|
||||||
|
**Desired behavior:** Every new drawing starts at angle 0, while the properties panel angle slider continues to work for editing selected shapes.
|
||||||
|
|
||||||
|
### Issue 2: Color State Leaking
|
||||||
|
|
||||||
|
**Root cause:** `sync_tool_from_shape()` (lines 182-186) copies the selected shape's colors into the app's active palette:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
app.fg_color = shape.color();
|
||||||
|
if let Some(fc) = shape.fill_color() {
|
||||||
|
app.bg_color = fc;
|
||||||
|
}
|
||||||
|
app.colors_dirty = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is called from `VectorShapeSelect` handler (line 6527 in `app.rs`).
|
||||||
|
|
||||||
|
**Desired behavior:** Selecting a shape should NOT change the active color palette. The palette should only change when the user explicitly picks a new color.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### File 1: `hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs`
|
||||||
|
|
||||||
|
**Change A — Remove angle sync from `sync_tool_from_shape` (line 150):**
|
||||||
|
|
||||||
|
Delete the line:
|
||||||
|
```rust
|
||||||
|
ts.vector_angle = shape.angle().to_degrees();
|
||||||
|
```
|
||||||
|
|
||||||
|
The Properties panel (`properties.rs`) already receives `vector_angle` from `tool_settings` and passes it to the slider. When the user changes the slider, `VectorAngleChanged` writes directly to `tool_settings.vector_angle` and calls `sync_shape_from_tool`. The selected shape's own angle will no longer contaminate the "default for new shapes" value.
|
||||||
|
|
||||||
|
**Change B — Remove color sync from `sync_tool_from_shape` (lines 182-186):**
|
||||||
|
|
||||||
|
Delete these lines:
|
||||||
|
```rust
|
||||||
|
// Colors
|
||||||
|
app.fg_color = shape.color();
|
||||||
|
if let Some(fc) = shape.fill_color() {
|
||||||
|
app.bg_color = fc;
|
||||||
|
}
|
||||||
|
app.colors_dirty = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
The Geometry and Properties panels already display the selected shape's stroke/fill colors directly from the engine (`shapes[idx].color()`, `shapes[idx].fill_color()`), so the UI remains correct. Color changes in those panels go directly to the engine via `set_vector_shape_color` / `set_vector_shape_fill_color`, independent of `fg_color`/`bg_color`.
|
||||||
|
|
||||||
|
### File 2: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs`
|
||||||
|
|
||||||
|
**Change C — Reset angle after new shape creation (after line 6206):**
|
||||||
|
|
||||||
|
After `engine.add_vector_shape(shape)` at line 6206, add:
|
||||||
|
```rust
|
||||||
|
self.settings.tool_settings.vector_angle = 0.0;
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides a safety net: even if something else writes to `vector_angle` between the last `sync_tool_from_shape` and the next draw, the angle resets after each new shape is committed. The reset also applies for non-SVG shapes (Rect, Circle, Line), ensuring complete consistency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs` | Remove angle copy (line 150) and color copy (lines 182-186) from `sync_tool_from_shape` |
|
||||||
|
| `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` | Reset `vector_angle` to 0.0 after `add_vector_shape` in `VectorDrawEnd` |
|
||||||
|
|
||||||
|
## What Is Preserved
|
||||||
|
|
||||||
|
- **Properties panel angle slider:** Still works. It reads from `tool_settings.vector_angle` and writes via `VectorAngleChanged`. The slider just won't auto-populate from the selected shape anymore — it retains its value until explicitly changed.
|
||||||
|
- **Geometry panel stroke/fill colors:** Still read directly from the engine shape, unaffected by this change.
|
||||||
|
- **Other tool properties (stroke, opacity, fill, radius, points, sides):** Unaffected — `sync_tool_from_shape` still syncs all non-angle, non-color properties.
|
||||||
|
- **Color palette when picking colors:** Still works. `sync_shape_from_tool` is still called when the user changes colors while a shape is selected, pushing the new palette color to the shape.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
1. Build: `cargo check -p hcie-iced-gui`
|
||||||
|
2. Manual test sequence:
|
||||||
|
- Draw a Rect → select → rotate to 30° → deselect → draw a new Rect → verify new Rect has angle 0°
|
||||||
|
- Draw a Star → select → change angle slider to 45° → deselect → draw a new Star → verify new Star has angle 0°
|
||||||
|
- Select a shape → verify active palette color is NOT changed
|
||||||
|
- Change palette color while shape is selected → verify shape color updates
|
||||||
|
- Use Properties panel angle slider on selected shape → verify the shape's angle changes correctly
|
||||||
@@ -1019,6 +1019,11 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
|||||||
.engine
|
.engine
|
||||||
.vector_shape_angle(layer_id, shape_idx);
|
.vector_shape_angle(layer_id, shape_idx);
|
||||||
angle += da;
|
angle += da;
|
||||||
|
let shift = ui.input(|i| i.modifiers.shift);
|
||||||
|
if shift {
|
||||||
|
let step = std::f32::consts::PI / 12.0;
|
||||||
|
angle = (angle / step).round() * step;
|
||||||
|
}
|
||||||
self.doc
|
self.doc
|
||||||
.engine
|
.engine
|
||||||
.set_vector_shape_angle(layer_id, shape_idx, angle);
|
.set_vector_shape_angle(layer_id, shape_idx, angle);
|
||||||
@@ -2230,10 +2235,15 @@ fn create_shape_from_drag(
|
|||||||
let mut p = std::collections::HashMap::new();
|
let mut p = std::collections::HashMap::new();
|
||||||
p.insert("thick".to_string(), 0.0);
|
p.insert("thick".to_string(), 0.0);
|
||||||
match hcie_engine_api::create_vector_shape("arrow", x1, y1, x2, y2, &p) {
|
match hcie_engine_api::create_vector_shape("arrow", x1, y1, x2, y2, &p) {
|
||||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
VectorShape::SvgShape { kind, svg, .. } => {
|
||||||
|
// Derive the arrow's rotation from the drag direction so
|
||||||
|
// it points along the line drawn by the user.
|
||||||
|
let arrow_angle = (y2 - y1).atan2(x2 - x1);
|
||||||
|
VectorShape::SvgShape {
|
||||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||||
angle: 0.0, opacity, hardness,
|
angle: arrow_angle, opacity, hardness,
|
||||||
},
|
}
|
||||||
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6200,11 +6200,26 @@ impl HcieIcedApp {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(mut shape) = shape {
|
if let Some(mut shape) = shape {
|
||||||
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
|
// For arrow shapes, derive the angle from the drag
|
||||||
|
// direction so the arrow points along the drawn line.
|
||||||
|
if matches!(
|
||||||
|
self.tool_state.active_tool,
|
||||||
|
hcie_engine_api::Tool::VectorArrow
|
||||||
|
) {
|
||||||
|
let drag_angle = (y1 - y0).atan2(x1 - x0);
|
||||||
|
let drag_angle = if self.modifiers.shift() {
|
||||||
|
crate::vector_edit::snap_to_15deg(drag_angle)
|
||||||
|
} else {
|
||||||
|
drag_angle
|
||||||
|
};
|
||||||
|
shape.set_angle(drag_angle);
|
||||||
|
} else if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
|
||||||
shape.set_angle(svg_angle);
|
shape.set_angle(svg_angle);
|
||||||
}
|
}
|
||||||
engine.add_vector_shape(shape);
|
engine.add_vector_shape(shape);
|
||||||
self.documents[self.active_doc].modified = true;
|
self.documents[self.active_doc].modified = true;
|
||||||
|
// Reset angle so every new drawing starts at 0°.
|
||||||
|
self.settings.tool_settings.vector_angle = 0.0;
|
||||||
}
|
}
|
||||||
self.refresh_composite_if_needed();
|
self.refresh_composite_if_needed();
|
||||||
}
|
}
|
||||||
@@ -6380,6 +6395,7 @@ impl HcieIcedApp {
|
|||||||
(x, y),
|
(x, y),
|
||||||
self.modifiers.shift(),
|
self.modifiers.shift(),
|
||||||
self.modifiers.alt(),
|
self.modifiers.alt(),
|
||||||
|
self.modifiers.shift(),
|
||||||
);
|
);
|
||||||
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
||||||
let angle = shape.angle();
|
let angle = shape.angle();
|
||||||
|
|||||||
@@ -220,6 +220,13 @@ pub fn panel_body<'a>(
|
|||||||
let layer_height = layer_info.map(|l| l.height).unwrap_or(0);
|
let layer_height = layer_info.map(|l| l.height).unwrap_or(0);
|
||||||
let vector_shapes = doc.engine.active_vector_shapes().unwrap_or_default();
|
let vector_shapes = doc.engine.active_vector_shapes().unwrap_or_default();
|
||||||
let ts2 = app.settings.tool_settings.clone();
|
let ts2 = app.settings.tool_settings.clone();
|
||||||
|
// Read the selected shape's angle directly from the engine so
|
||||||
|
// the slider updates instantly when the selection changes.
|
||||||
|
let effective_vector_angle = doc
|
||||||
|
.selected_vector_shape
|
||||||
|
.and_then(|idx| vector_shapes.get(idx))
|
||||||
|
.map(|s| s.angle().to_degrees())
|
||||||
|
.unwrap_or(ts2.vector_angle);
|
||||||
crate::panels::properties::view(
|
crate::panels::properties::view(
|
||||||
&app.tool_state.active_tool,
|
&app.tool_state.active_tool,
|
||||||
app.tool_state.brush_size,
|
app.tool_state.brush_size,
|
||||||
@@ -243,7 +250,7 @@ pub fn panel_body<'a>(
|
|||||||
ts2.vector_radius,
|
ts2.vector_radius,
|
||||||
ts2.vector_points,
|
ts2.vector_points,
|
||||||
ts2.vector_sides,
|
ts2.vector_sides,
|
||||||
ts2.vector_angle,
|
effective_vector_angle,
|
||||||
active_id,
|
active_id,
|
||||||
app.fg_color,
|
app.fg_color,
|
||||||
app.bg_color,
|
app.bg_color,
|
||||||
|
|||||||
@@ -142,12 +142,16 @@ pub fn sync_tool_from_shape(app: &mut HcieIcedApp) {
|
|||||||
if let Some(shape) = shapes.get(shape_idx) {
|
if let Some(shape) = shapes.get(shape_idx) {
|
||||||
let ts = &mut app.settings.tool_settings;
|
let ts = &mut app.settings.tool_settings;
|
||||||
|
|
||||||
// Common properties
|
// Common properties — note: angle is intentionally NOT synced here
|
||||||
|
// so that the "default angle for new shapes" stays independent from
|
||||||
|
// the selected shape's rotation. The angle slider in the Properties
|
||||||
|
// panel writes directly to `tool_settings.vector_angle` via
|
||||||
|
// VectorAngleChanged and is pushed to the shape via
|
||||||
|
// sync_shape_from_tool.
|
||||||
ts.vector_stroke_width = shape.stroke();
|
ts.vector_stroke_width = shape.stroke();
|
||||||
ts.vector_opacity = shape.shape_opacity();
|
ts.vector_opacity = shape.shape_opacity();
|
||||||
app.tool_state.brush_hardness = shape.hardness();
|
app.tool_state.brush_hardness = shape.hardness();
|
||||||
ts.vector_fill = shape.is_filled();
|
ts.vector_fill = shape.is_filled();
|
||||||
ts.vector_angle = shape.angle().to_degrees();
|
|
||||||
|
|
||||||
// Shape-specific properties
|
// Shape-specific properties
|
||||||
match shape {
|
match shape {
|
||||||
@@ -178,12 +182,10 @@ pub fn sync_tool_from_shape(app: &mut HcieIcedApp) {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Colors
|
// Colors are intentionally NOT synced from the shape into the
|
||||||
app.fg_color = shape.color();
|
// active palette. The geometry / properties panels read the
|
||||||
if let Some(fc) = shape.fill_color() {
|
// shape's colors directly from the engine for display, and the
|
||||||
app.bg_color = fc;
|
// user picks new colors via the color palette or panel pickers.
|
||||||
}
|
|
||||||
app.colors_dirty = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,15 +114,18 @@ pub fn hit_test_handle(
|
|||||||
|
|
||||||
/// Derives a safe shape for the current pointer position in a drag.
|
/// Derives a safe shape for the current pointer position in a drag.
|
||||||
///
|
///
|
||||||
/// Arguments: `session` is the immutable drag basis, `pointer` is canvas-space, `lock_aspect`
|
/// Arguments: `session` is the immutable drag basis, `pointer` is canvas-space,
|
||||||
/// corresponds to Shift, and `from_center` corresponds to Alt. Returns: A finite shape with
|
/// `lock_aspect` corresponds to Shift (aspect-lock on resize), `from_center`
|
||||||
/// dimensions at least `MIN_VECTOR_SIZE`; invalid pointer input returns the original shape.
|
/// corresponds to Alt, and `snap_angle` corresponds to Shift on rotation.
|
||||||
|
/// Returns: A finite shape with dimensions at least `MIN_VECTOR_SIZE`; invalid
|
||||||
|
/// pointer input returns the original shape.
|
||||||
/// Side Effects: None.
|
/// Side Effects: None.
|
||||||
pub fn transform_shape(
|
pub fn transform_shape(
|
||||||
session: &VectorDragSession,
|
session: &VectorDragSession,
|
||||||
pointer: (f32, f32),
|
pointer: (f32, f32),
|
||||||
lock_aspect: bool,
|
lock_aspect: bool,
|
||||||
from_center: bool,
|
from_center: bool,
|
||||||
|
snap_angle: bool,
|
||||||
) -> VectorShape {
|
) -> VectorShape {
|
||||||
use VectorEditHandle as H;
|
use VectorEditHandle as H;
|
||||||
|
|
||||||
@@ -152,8 +155,13 @@ pub fn transform_shape(
|
|||||||
(session.pointer_start.1 - center.1).atan2(session.pointer_start.0 - center.0);
|
(session.pointer_start.1 - center.1).atan2(session.pointer_start.0 - center.0);
|
||||||
let current_angle = (pointer.1 - center.1).atan2(pointer.0 - center.0);
|
let current_angle = (pointer.1 - center.1).atan2(pointer.0 - center.0);
|
||||||
let angle = session.original.angle() + normalize_angle(current_angle - start_angle);
|
let angle = session.original.angle() + normalize_angle(current_angle - start_angle);
|
||||||
|
let angle = if snap_angle {
|
||||||
|
snap_to_15deg(angle)
|
||||||
|
} else {
|
||||||
|
normalize_angle(angle)
|
||||||
|
};
|
||||||
if angle.is_finite() {
|
if angle.is_finite() {
|
||||||
result.set_angle(normalize_angle(angle));
|
result.set_angle(angle);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -302,6 +310,16 @@ pub fn normalize_angle(angle: f32) -> f32 {
|
|||||||
(angle + std::f32::consts::PI).rem_euclid(two_pi) - std::f32::consts::PI
|
(angle + std::f32::consts::PI).rem_euclid(two_pi) - std::f32::consts::PI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snaps an angle (radians) to the nearest 15° increment.
|
||||||
|
///
|
||||||
|
/// Used for Shift-constrained drawing and rotation. 15° = PI/12.
|
||||||
|
/// Returns the nearest snapped angle within [-PI, PI].
|
||||||
|
pub fn snap_to_15deg(angle: f32) -> f32 {
|
||||||
|
let step = std::f32::consts::PI / 12.0; // 15°
|
||||||
|
let snapped = (angle / step).round() * step;
|
||||||
|
normalize_angle(snapped)
|
||||||
|
}
|
||||||
|
|
||||||
/// Inverse-rotates a point around a center. Arguments and return are canvas-space coordinates.
|
/// Inverse-rotates a point around a center. Arguments and return are canvas-space coordinates.
|
||||||
/// Side Effects: None.
|
/// Side Effects: None.
|
||||||
fn inverse_rotate(point: (f32, f32), center: (f32, f32), angle: f32) -> (f32, f32) {
|
fn inverse_rotate(point: (f32, f32), center: (f32, f32), angle: f32) -> (f32, f32) {
|
||||||
@@ -467,10 +485,10 @@ mod tests {
|
|||||||
pointer_start: (5.0, 5.0),
|
pointer_start: (5.0, 5.0),
|
||||||
original: rect(),
|
original: rect(),
|
||||||
};
|
};
|
||||||
let moved = transform_shape(&session, (15.0, 25.0), false, false);
|
let moved = transform_shape(&session, (15.0, 25.0), false, false, false);
|
||||||
assert_eq!(moved.normalized_bounds(), (20.0, 40.0, 120.0, 90.0));
|
assert_eq!(moved.normalized_bounds(), (20.0, 40.0, 120.0, 90.0));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
transform_shape(&session, (f32::NAN, 0.0), false, false),
|
transform_shape(&session, (f32::NAN, 0.0), false, false, false),
|
||||||
rect()
|
rect()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
cargo run -p hcie-wx-app --bin hcie-wx-app
|
cargo run -p hcie-wx-app --bin hcie-wx-app
|
||||||
cargo run --bin hcie-gui
|
cargo run --bin hcie-gui
|
||||||
cargo run --example gui
|
cargo run --example gui
|
||||||
|
cargo run -p
|
||||||
|
|
||||||
##TAURI
|
##TAURI
|
||||||
npm komutunu proje kökünden değil, hcie-tauri-app/ altından çalıştırmalısın:
|
npm komutunu proje kökünden değil, hcie-tauri-app/ altından çalıştırmalısın:
|
||||||
|
|||||||
Reference in New Issue
Block a user