mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
Vector edit mode can now edit several vector layers at once, and the rest of the vector edit tail
from the public documentation landed. A behaviour suite came first
(`crates/engine/src/vector_multi_behaviour_tests.rs`, 5 behaviour tests with about 30 table rows and
steps, plus a junk-parameter sweep of every `vector.*` command run in multi-edit mode).
- **Multi-edit.** Enter, a double-click or `vector.edit.begin {ids}` with several shapes selected
puts them all in edit mode, converting them in one undo step. The clicked or first one is the
active layer. Pressing another layer's point, handle or segment makes that layer active. Shift-click,
marquee and lasso select points in any layer. Dragging a selected point, the arrows, Delete,
delete and heal, mirroring, smoothing, corner radius and the new cap and join commands reach every
layer's selected points in one undo step. The pen, bend, paint bucket, cut, eraser, join and
position work in the active layer. A click away from every edited layer leaves edit mode.
`vector.edit.state` now reports `layers`, `others` and `handles`.
- **Several handles.** Shift-click Bezier handles to select more than one. Dragging one moves the
others by the same amount. Also available as `vector.edit.select {handles}` and
`vector.edit.moveHandles`. Selected handles are drawn filled.
- **Rounding next to curves.** A sharp point with a corner radius rounds even when one of its
segments is curved. The curve is cut where the arc meets it and keeps the rest of its shape, and
the arc meets each side smoothly (`VectorNetwork::rounded`).
- **A cap and a join per point.** `Vertex` gets optional `cap` and `join` values. When unset, the
layer's apply. The commands are `vector.edit.strokeCap` and `vector.edit.strokeJoin` (`null`
clears). The Design panel's Point section gets End point and Join dropdowns, each with a "Same as
layer" option. The stroke is drawn run by run and each point's own join is drawn over the break
(`figcraft_pathops::stroke_outline_of`, new `points.rs`). This applies to the canvas, PNG and PDF,
and to SVG as outlines. Outline stroke follows it, render bounds count arrowheads from points, and
the stroke cache key includes the points' styles.
- **Pen outside edit mode.** Clicking one of the path's own points (other than the first or last)
connects there and ends the path, as a vector network. With an open vector selected, clicking one
of its end points continues that path in edit mode.
- MCP: headless `key` Enter on shapes now enters vector edit mode, as in the desktop app. The new
commands have Spanish labels, and the new UI string has en and es entries.
- Docs: target-app-parity (area 4: 98%/72% to 99%/73%, 2-4 h to 1-3 h; area 20: 80%/55% to
88%/60%, 2-5 h to 1-3 h), ROADMAP, gaps (gap 9 is down to tablet pressure on macOS and Linux; the
gap 13 note is updated), ui-parity and mcp.md. Totals are recomputed by the doc's method, because
rows added since the last recompute had left them stale. Feature breadth is now ~83% and ready for
real work ~52%. Mainstream stays at ~49% (49.7%, rounded down). Full parity goes from
~306-595 h to ~294-572 h.
186 lines
8.5 KiB
Rust
186 lines
8.5 KiB
Rust
//! Design panel in vector edit mode: the selected points' position and dimensions (their
|
|
//! transform box), corner radius, handle mirroring and the stroke's cap and join at them, or the
|
|
//! selected width point with the Variable width tool (in place of the layer's position and
|
|
//! dimensions).
|
|
|
|
use egui::{Id, Ui};
|
|
use figcraft_doc::{Document, HandleMirroring, StrokeCap, StrokeJoin};
|
|
use figcraft_engine::vectoredit::{self, VectorEdit};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::FigApp;
|
|
use crate::i18n::tr;
|
|
use crate::icons::Icon;
|
|
use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row};
|
|
use crate::widgets::{self, FieldEdit, Lead, PAD};
|
|
|
|
/// Run a point command from a field: typed values are one step, scrubs one step when released.
|
|
fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, params: impl Fn(f64) -> Value) {
|
|
match edit {
|
|
Some(FieldEdit::Set(v)) => {
|
|
app.run(cmd, params(v));
|
|
}
|
|
Some(FieldEdit::Scrub(v, done)) => {
|
|
let _ = app.session.begin_interaction("Edit points");
|
|
let _ = app.session.execute(cmd, ¶ms(v));
|
|
if done {
|
|
let _ = app.session.commit_interaction();
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
/// The Variable width tool's selected width point: where it is along the path and its width.
|
|
fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
|
widgets::section_header(ui, tr("Width point"), &[], false);
|
|
let stroke = doc.node(ve.id).map(|n| &n.stroke);
|
|
let sel = ve.width_point.and_then(|i| Some((i, *stroke?.profile()?.points.get(i)?)));
|
|
let Some((i, p)) = sel else {
|
|
let t = crate::theme::Tokens::get(ui.ctx());
|
|
ui.horizontal(|ui| {
|
|
ui.add_space(PAD);
|
|
widgets::label(ui, tr("Drag the stroke to add a width point"), t.text_secondary);
|
|
});
|
|
ui.add_space(8.0);
|
|
return;
|
|
};
|
|
let weight = stroke.map_or(1.0, |s| s.weight);
|
|
let col = widgets::col_width(ui.available_width());
|
|
let round = |v: f64| (v * 100.0).round() / 100.0;
|
|
widgets::sublabels(ui, tr("Position"), Some(tr("Width")));
|
|
row(ui, 32.0, |ui| {
|
|
let e = widgets::number_field(ui, Id::new("wpt_t"), Lead::None, Some(round(p.t * 100.0)), "%", col, 1.0);
|
|
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "t": v / 100.0}));
|
|
let e = widgets::number_field(ui, Id::new("wpt_w"), Lead::Icon(Icon::StrokeIcon, tr("Width")), Some(round(p.scale * weight)), "", col, 1.0);
|
|
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "width": v.max(0.0)}));
|
|
});
|
|
// Each side's reach from the centre line (Alt-drag a handle on the canvas for one side).
|
|
let (l, r) = p.sides();
|
|
widgets::sublabels(ui, tr("Left side"), Some(tr("Right side")));
|
|
row(ui, 32.0, |ui| {
|
|
for (side, scale, name) in [("left", l, tr("Left side width")), ("right", r, tr("Right side width"))] {
|
|
let e = widgets::number_field(
|
|
ui,
|
|
Id::new(("wpt_side", side)),
|
|
Lead::Icon(Icon::StrokeIcon, name),
|
|
Some(round(scale * weight / 2.0)),
|
|
"",
|
|
col,
|
|
1.0,
|
|
);
|
|
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "side": side, "width": v.max(0.0)}));
|
|
}
|
|
});
|
|
ui.add_space(8.0);
|
|
}
|
|
|
|
pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
|
if ve.tool == vectoredit::VTool::Width {
|
|
width_point(app, ui, doc, ve);
|
|
return;
|
|
}
|
|
let Some(net) = vectoredit::network(doc, ve.id) else { return };
|
|
let pts = vectoredit::selected_points(net, ve);
|
|
widgets::section_header(ui, if pts.len() > 1 { tr("Points") } else { tr("Point") }, &[], false);
|
|
if pts.is_empty() {
|
|
let t = crate::theme::Tokens::get(ui.ctx());
|
|
ui.horizontal(|ui| {
|
|
ui.add_space(PAD);
|
|
widgets::label(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary);
|
|
});
|
|
ui.add_space(8.0);
|
|
return;
|
|
}
|
|
let col = widgets::col_width(ui.available_width());
|
|
let pos = vectoredit::selection_position(doc, ve);
|
|
let round = |v: f64| (v * 100.0).round() / 100.0;
|
|
widgets::sublabels(ui, tr("Position"), None);
|
|
row(ui, 32.0, |ui| {
|
|
let e = widgets::number_field(ui, Id::new("vpt_x"), Lead::Text("X"), pos.map(|p| round(p.x)), "", col, 1.0);
|
|
point_field(app, "vector.edit.position", e, |v| json!({"x": v}));
|
|
let e = widgets::number_field(ui, Id::new("vpt_y"), Lead::Text("Y"), pos.map(|p| round(p.y)), "", col, 1.0);
|
|
point_field(app, "vector.edit.position", e, |v| json!({"y": v}));
|
|
});
|
|
if let Some(b) = vectoredit::transform::point_box(doc, ve).filter(|b| b.points.len() > 1) {
|
|
widgets::sublabels(ui, tr("Dimensions"), None);
|
|
row(ui, 32.0, |ui| {
|
|
let e = widgets::number_field(ui, Id::new("vpt_w"), Lead::Text("W"), Some(round(b.width)), "", col, 1.0);
|
|
point_field(app, "vector.edit.resize", e, |v| json!({"width": v.max(0.0)}));
|
|
let e = widgets::number_field(ui, Id::new("vpt_h"), Lead::Text("H"), Some(round(b.height)), "", col, 1.0);
|
|
point_field(app, "vector.edit.resize", e, |v| json!({"height": v.max(0.0)}));
|
|
});
|
|
}
|
|
let vertex = |v: &usize| net.vertices.get(*v).copied();
|
|
let radius = same(pts.iter().map(|v| vertex(v).map(|x| x.radius))).flatten();
|
|
let mirror = same(pts.iter().map(|v| net.mirroring(*v)));
|
|
// A point's own cap and join (`Some(None)`: the layer's; `None`: mixed).
|
|
let cap = same(pts.iter().map(|v| vertex(v).map(|x| x.cap))).flatten();
|
|
let join = same(pts.iter().map(|v| vertex(v).map(|x| x.join))).flatten();
|
|
widgets::sublabels(ui, tr("Corner radius"), None);
|
|
row(ui, 32.0, |ui| {
|
|
let e =
|
|
widgets::number_field(ui, Id::new("vpt_radius"), Lead::Icon(Icon::CornerRadius, tr("Corner radius")), radius.map(round), "", col, 1.0);
|
|
point_field(app, "vector.edit.cornerRadius", e, |v| json!({"radius": v.max(0.0)}));
|
|
});
|
|
widgets::sublabels(ui, tr("Mirroring"), None);
|
|
row(ui, 32.0, |ui| {
|
|
let label = |m: HandleMirroring| match m {
|
|
HandleMirroring::None => tr("No mirroring"),
|
|
HandleMirroring::Angle => tr("Mirror angle"),
|
|
HandleMirroring::AngleAndLength => tr("Mirror angle and length"),
|
|
};
|
|
let resp = widgets::dropdown(ui, mirror.map(label).unwrap_or(tr("Mixed")), col * 2.0 + 8.0, None);
|
|
menu_popup(&resp).show(|ui| {
|
|
for m in [HandleMirroring::None, HandleMirroring::Angle, HandleMirroring::AngleAndLength] {
|
|
if menu_item(ui, label(m), "") {
|
|
app.run("vector.edit.mirroring", json!({"mode": m.id()}));
|
|
ui.close();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
// The stroke where it ends and turns at these points.
|
|
let own = |set: Option<Option<&'static str>>| match set {
|
|
None => tr("Mixed"),
|
|
Some(None) => tr("Same as layer"),
|
|
Some(Some(l)) => l,
|
|
};
|
|
widgets::sublabels(ui, tr("End point"), Some(tr("Join|stroke")));
|
|
row(ui, 32.0, |ui| {
|
|
let resp = widgets::dropdown(ui, own(cap.map(|c| c.map(|c| tr(c.label())))), col, None);
|
|
menu_popup(&resp).show(|ui| {
|
|
if menu_item(ui, tr("Same as layer"), "") {
|
|
app.run("vector.edit.strokeCap", json!({"cap": null}));
|
|
ui.close();
|
|
}
|
|
for c in StrokeCap::ALL {
|
|
if menu_item_with(ui, tr(c.label()), "", CAP_ART_W, |p, r, col| cap_art(p, r, col, c, false)) {
|
|
app.run("vector.edit.strokeCap", json!({"cap": c}));
|
|
ui.close();
|
|
}
|
|
}
|
|
});
|
|
let resp = widgets::dropdown(ui, own(join.map(|j| j.map(join_label))), col, None);
|
|
menu_popup(&resp).show(|ui| {
|
|
if menu_item(ui, tr("Same as layer"), "") {
|
|
app.run("vector.edit.strokeJoin", json!({"join": null}));
|
|
ui.close();
|
|
}
|
|
for j in StrokeJoin::ALL {
|
|
if menu_item(ui, join_label(j), "") {
|
|
app.run("vector.edit.strokeJoin", json!({"join": j}));
|
|
ui.close();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
ui.add_space(8.0);
|
|
}
|
|
|
|
/// The value every item shares, if they all share one.
|
|
fn same<T: PartialEq>(mut it: impl Iterator<Item = T>) -> Option<T> {
|
|
let first = it.next()?;
|
|
it.all(|x| x == first).then_some(first)
|
|
}
|