Files
vectorcraft/crates/tools/src/addons/contour.rs
T
KindmarkandClaude Sonnet 5.5 db1ef1bfed Contour: make it a built-in add-on and translate its labels
Review of #5694 asked for two changes.

Add-on, not a core tool, as Enclosing Lasso is:
- The command is addon.contour.set, in an add-on module of its own
  (crates/engine/src/cmd/addons/contour.rs) with its tests beside it.
- The tool is listed under Object > Plug-ins > Contour and under its own
  "Contour" heading in All Tools. It has no Basic or Advanced toolbar
  slot and no default shortcut. The Contour bar is unchanged.
- The add-on drawer test lists Contour with the other add-on tools.
- Docs (mcp.md, the parity documents, ROADMAP) say add-on and use the
  new command name; mcp.md gains a Contour add-on section.

Translations: rows for "Contour", "Contour Tool", "Sharp", "Bake" and
"Turn the Contour into plain paths" in all 26 catalogs ("Round" and
"Square" already had them). Drafted from each catalog's own vocabulary
(for Bake, the verb it uses for Expand Appearance; a word other than the
catalog's Stroke or Path word for the tool's name where they collide);
they are not native-speaker reviewed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 12:57:51 +07:00

255 lines
9.2 KiB
Rust

//! The Contour tool: drag away from a selected shape's edge to grow a live copy of it, or toward the
//! inside to shrink it (`addon.contour.set`). The copy is the original's outline offset by the distance
//! the pointer has moved from the edge, previewed while the button is down and kept as one undo step
//! on release. Nothing is baked: the copy carries a live Offset Path effect, so it keeps being
//! editable (a second drag on a selected Contour changes its distance instead of nesting another),
//! and Expand Appearance bakes it. While a Contour is selected the tool draws the outline the
//! shape had before as a dashed guide.
//!
//! The distance is how far the pointer has moved from the shape's edge since the press, so a press
//! anywhere changes nothing until the pointer moves, and a Contour that already stands at some
//! distance continues from there. Shift rounds the distance to whole points.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{BezPath, FillRule, Point, hit};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
pub const ID: &str = "contour";
/// The live effect a Contour is made of (the one `addon.contour.set` applies).
const EFFECT: &str = "path.offsetPath";
/// The largest distance, in points, `addon.contour.set` accepts.
const MAX_OFFSET: f64 = 1e5;
/// The guide showing the shape a Contour was made from.
const GUIDE: [u8; 3] = [0x1e, 0x88, 0xe5];
/// A selected shape's outline, in the document.
struct Base {
path: BezPath,
rule: FillRule,
}
fn base_of(n: &Node) -> Option<Base> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some(Base { path: path.to_bezpath(), rule: *rule }),
NodeKind::Compound { children, rule } => {
let mut path = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
path.extend(p.to_bezpath().elements().iter().copied());
}
}
Some(Base { path, rule: *rule })
}
_ => None,
}
}
/// The Offset Path distance of a Contour (a shape that carries the effect).
fn contour_offset(n: &Node) -> Option<f64> {
let e = n.appearance.effects.iter().rev().find(|e| e.id == EFFECT)?;
Some(e.params.get("offset").and_then(Value::as_f64).unwrap_or(0.0))
}
/// The corner style and miter limit of a Contour's Offset Path effect, as `addon.contour.set` takes
/// them.
fn contour_style(n: &Node) -> Option<Value> {
let e = n.appearance.effects.iter().rev().find(|e| e.id == EFFECT)?;
let corners = match e.params.get("joins").and_then(Value::as_str) {
Some("round") => "round",
Some("bevel") => "square",
_ => "sharp",
};
let mut style = json!({ "corners": corners });
if let Some(m) = e.params.get("miterLimit").and_then(Value::as_f64) {
style["miterLimit"] = json!(m);
}
Some(style)
}
/// How far `p` is from the nearest of `bases`' edges: positive outside, negative inside.
fn signed_distance(bases: &[Base], p: Point) -> Option<f64> {
let mut nearest = f64::INFINITY;
let mut inside = false;
for b in bases {
nearest = nearest.min(hit::distance_to_outline(&b.path, p));
inside |= hit::fill_contains(&b.path, b.rule, p);
}
nearest.is_finite().then_some(if inside { -nearest } else { nearest })
}
/// A press in progress.
struct Drag {
bases: Vec<Base>,
/// Where the pointer stood against the edge at the press.
pressed_at: f64,
/// The Contour's distance at the press (0 for a shape that is not one yet).
start: f64,
/// The corner style and miter limit every preview carries: the pressed Contour's own, else the
/// tool's options.
style: Value,
/// The distance last previewed.
last: Option<f64>,
/// Where the pointer is, for the distance label.
pos: Point,
}
#[derive(Default)]
pub struct ContourTool {
drag: Option<Drag>,
corners: Corners,
miter_limit: Option<f64>,
}
/// How a Contour treats the corners of the shape.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum Corners {
/// Pointed (mitered) corners.
#[default]
Sharp,
Round,
/// Corners cut off flat.
Square,
}
impl Corners {
fn id(self) -> &'static str {
match self {
Corners::Sharp => "sharp",
Corners::Round => "round",
Corners::Square => "square",
}
}
fn parse(s: &str) -> Option<Self> {
[Corners::Sharp, Corners::Round, Corners::Square].into_iter().find(|c| c.id().eq_ignore_ascii_case(s))
}
}
impl ContourTool {
/// The corner style and miter limit of a new Contour: the tool's options.
fn style(&self) -> Value {
let mut p = json!({ "corners": self.corners.id() });
if let Some(m) = self.miter_limit {
p["miterLimit"] = json!(m);
}
p
}
}
/// The selected shapes a Contour can be made of.
fn selected_shapes<'a>(cx: &'a ToolContext) -> Vec<&'a Node> {
cx.selection
.objects
.iter()
.filter_map(|id| cx.doc.node(*id))
.filter(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))
.collect()
}
fn round_distance(v: f64, mods: Mods) -> f64 {
let v = if mods.shift { v.round() } else { (v * 100.0).round() / 100.0 };
v.clamp(-MAX_OFFSET, MAX_OFFSET)
}
impl Tool for ContourTool {
fn id(&self) -> &'static str {
ID
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
let shapes = selected_shapes(cx);
let start = shapes.first().and_then(|n| contour_offset(n)).unwrap_or(0.0);
// A Contour keeps its own corners; a new one takes the tool's.
let style = shapes.first().and_then(|n| contour_style(n)).unwrap_or_else(|| self.style());
let bases: Vec<Base> = shapes.into_iter().filter_map(base_of).collect();
let Some(pressed_at) = signed_distance(&bases, ev.pos) else { return vec![] };
self.drag = Some(Drag { bases, pressed_at, start, style, last: None, pos: ev.pos });
vec![Action::Begin("Contour".into())]
}
PointerKind::Drag => {
let Some(drag) = self.drag.as_mut() else { return vec![] };
drag.pos = ev.pos;
let Some(now) = signed_distance(&drag.bases, ev.pos) else { return vec![] };
let offset = round_distance(drag.start + now - drag.pressed_at, ev.mods);
// Nothing is previewed until the distance has left where it started: a drag along
// the edge (or one Shift rounds back) would otherwise leave an unchanged copy.
if !offset.is_finite() || drag.last == Some(offset) || (drag.last.is_none() && offset == drag.start) {
return vec![];
}
drag.last = Some(offset);
let mut params = drag.style.clone();
params["offset"] = json!(offset);
vec![Action::Preview("addon.contour.set".into(), params)]
}
PointerKind::Up => match self.drag.take() {
Some(d) if d.last.is_some() => vec![Action::Commit],
Some(_) => vec![Action::Cancel],
None => vec![],
},
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
match key {
ToolKey::Escape if self.drag.take().is_some() => vec![Action::Cancel],
_ => vec![],
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
// The outline each selected Contour was made from.
for n in selected_shapes(cx) {
if contour_offset(n).is_none() {
continue;
}
if let Some(b) = base_of(n) {
// A white halo under the dashes, so the guide shows over any fill.
out.push(Overlay::Path { path: b.path.clone(), color: [255, 255, 255], width: 3.0, dashed: false });
out.push(Overlay::Path { path: b.path, color: GUIDE, width: 1.0, dashed: true });
}
}
if let Some(d) = &self.drag
&& let Some(offset) = d.last
{
out.push(Overlay::Measure { p: d.pos, text: format!("{offset:+.2} pt") });
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
json!({"corners": self.corners.id(), "miterLimit": self.miter_limit.unwrap_or(4.0)})
}
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"corners" => {
if let Some(c) = value.as_str().and_then(Corners::parse) {
self.corners = c;
}
}
"miterLimit" => {
if let Some(m) = value.as_f64().filter(|m| m.is_finite()) {
self.miter_limit = Some(m.clamp(1.0, 500.0));
}
}
_ => {}
}
}
fn busy(&self) -> bool {
self.drag.is_some()
}
}