mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
M2.8/M6.2/M7.2: smart guides + snapping, Type tool, Pathfinder/path/type commands, web build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8cd8f8f8a7
commit
874c0e6ca2
@@ -6,7 +6,9 @@ mod layer;
|
||||
mod object;
|
||||
mod paint;
|
||||
mod path;
|
||||
mod pathops;
|
||||
mod select;
|
||||
mod typecmd;
|
||||
|
||||
use drawcraft_color::Color;
|
||||
use drawcraft_doc::NodeId;
|
||||
@@ -111,6 +113,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(select::specs());
|
||||
v.extend(paint::specs());
|
||||
v.extend(layer::specs());
|
||||
v.extend(pathops::specs());
|
||||
v.extend(typecmd::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,27 +16,97 @@ use super::edit::selected_roots;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
const PF: &str = "{} operate on the selected objects (back → front) → {ids}";
|
||||
vec![
|
||||
cmd!("object.pathfinder.unite", "Unite", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Unite)),
|
||||
cmd!("object.pathfinder.minusFront", "Minus Front", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(
|
||||
s,
|
||||
PathfinderOp::MinusFront
|
||||
)),
|
||||
cmd!("object.pathfinder.intersect", "Intersect", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(
|
||||
s,
|
||||
PathfinderOp::Intersect
|
||||
)),
|
||||
cmd!("object.pathfinder.exclude", "Exclude", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Exclude)),
|
||||
cmd!("object.pathfinder.divide", "Divide", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Divide)),
|
||||
cmd!("object.pathfinder.trim", "Trim", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Trim)),
|
||||
cmd!("object.pathfinder.merge", "Merge", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Merge)),
|
||||
cmd!("object.pathfinder.crop", "Crop", ["Window", "Pathfinder"], None, PF, has_multi, |s, _| run_pf(s, PathfinderOp::Crop)),
|
||||
cmd!("object.pathfinder.outline", "Outline", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Outline)),
|
||||
cmd!("object.pathfinder.minusBack", "Minus Back", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(
|
||||
s,
|
||||
PathfinderOp::MinusBack
|
||||
)),
|
||||
cmd!(
|
||||
"object.pathfinder.unite",
|
||||
"Unite",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_selection,
|
||||
|s, _| run_pf(s, PathfinderOp::Unite)
|
||||
),
|
||||
cmd!(
|
||||
"object.pathfinder.minusFront",
|
||||
"Minus Front",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_selection,
|
||||
|s, _| run_pf(s, PathfinderOp::MinusFront)
|
||||
),
|
||||
cmd!(
|
||||
"object.pathfinder.intersect",
|
||||
"Intersect",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_selection,
|
||||
|s, _| run_pf(s, PathfinderOp::Intersect)
|
||||
),
|
||||
cmd!(
|
||||
"object.pathfinder.exclude",
|
||||
"Exclude",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_selection,
|
||||
|s, _| run_pf(s, PathfinderOp::Exclude)
|
||||
),
|
||||
cmd!(
|
||||
"object.pathfinder.divide",
|
||||
"Divide",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_selection,
|
||||
|s, _| run_pf(s, PathfinderOp::Divide)
|
||||
),
|
||||
cmd!(
|
||||
"object.pathfinder.trim",
|
||||
"Trim",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_selection,
|
||||
|s, _| run_pf(s, PathfinderOp::Trim)
|
||||
),
|
||||
cmd!(
|
||||
"object.pathfinder.merge",
|
||||
"Merge",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_selection,
|
||||
|s, _| run_pf(s, PathfinderOp::Merge)
|
||||
),
|
||||
cmd!(
|
||||
"object.pathfinder.crop",
|
||||
"Crop",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_multi,
|
||||
|s, _| run_pf(s, PathfinderOp::Crop)
|
||||
),
|
||||
cmd!(
|
||||
"object.pathfinder.outline",
|
||||
"Outline",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_selection,
|
||||
|s, _| run_pf(s, PathfinderOp::Outline)
|
||||
),
|
||||
cmd!(
|
||||
"object.pathfinder.minusBack",
|
||||
"Minus Back",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{} operate on the selected objects (back → front) → {ids}",
|
||||
has_selection,
|
||||
|s, _| run_pf(s, PathfinderOp::MinusBack)
|
||||
),
|
||||
cmd!(
|
||||
"object.path.outlineStroke",
|
||||
"Outline Stroke",
|
||||
@@ -239,7 +309,8 @@ fn pf_label(op: PathfinderOp) -> &'static str {
|
||||
fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let Some(&top) = roots.last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let shape_mode = matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
|
||||
let shape_mode =
|
||||
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
|
||||
let label = pf_label(op);
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
// Build the stack (back → front) with a style per entry.
|
||||
@@ -432,7 +503,8 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let mut stroke_node = shape_node(d, outline, Some(&l));
|
||||
stroke_node.appearance = Appearance { items: vec![AppearanceItem::Fill(drawcraft_doc::appearance::FillLayer::new(st.paint.clone()))], effects: vec![] };
|
||||
stroke_node.appearance =
|
||||
Appearance { items: vec![AppearanceItem::Fill(drawcraft_doc::appearance::FillLayer::new(st.paint.clone()))], effects: vec![] };
|
||||
let fill = l.appearance.fill().filter(|f| f.visible && !f.paint.is_none()).cloned();
|
||||
let new = if let Some(f) = fill {
|
||||
let mut fill_node = l.clone();
|
||||
@@ -598,9 +670,7 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if *guide || *clipping || (parent_clip && first) {
|
||||
return;
|
||||
}
|
||||
if stray && path.anchor_count() <= 1 {
|
||||
v.push(n.id);
|
||||
} else if unpainted && !parent_compound && !is_painted(&n.appearance) {
|
||||
if (stray && path.anchor_count() <= 1) || (unpainted && !parent_compound && !is_painted(&n.appearance)) {
|
||||
v.push(n.id);
|
||||
}
|
||||
}
|
||||
@@ -707,4 +777,3 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Type menu: Create Outlines, and the Character/Paragraph panel setters (`text.setText`,
|
||||
//! `text.setStyle`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use drawcraft_geom::PathData;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{num_param, shape_node, text_style_appearance};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"type.createOutlines",
|
||||
"Create Outlines",
|
||||
["Type"],
|
||||
Some("Cmd+Shift+O"),
|
||||
"{} convert the selected text to groups of compound paths (one per glyph) → {ids}",
|
||||
has_selection,
|
||||
create_outlines
|
||||
),
|
||||
cmd!(
|
||||
"text.setText",
|
||||
"Set Text",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids?, text} replace the contents of text objects (keeps the first run's style)",
|
||||
has_doc,
|
||||
set_text
|
||||
),
|
||||
cmd!(
|
||||
"text.setStyle",
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Recompute the layout bounds cache after a text edit.
|
||||
pub(crate) fn refresh_bounds(t: &mut TextObject) {
|
||||
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
}
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
if let Some(n) = d.node(*id) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Create Outlines", |d, sel| {
|
||||
let texts = text_ids(d, &roots);
|
||||
if texts.is_empty() {
|
||||
return Err(EngineError::Other("Create Outlines: select text objects".into()));
|
||||
}
|
||||
let mut new_sel: Vec<NodeId> = roots.iter().copied().filter(|r| !texts.contains(r)).collect();
|
||||
let mut out = vec![];
|
||||
for tid in texts {
|
||||
let Some(n) = d.node(tid).cloned() else { continue };
|
||||
let NodeKind::Text(t) = &n.kind else { continue };
|
||||
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
|
||||
let mut children = vec![];
|
||||
for g in &lay.glyphs {
|
||||
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
|
||||
let mut node = shape_node(d, path, None);
|
||||
node.appearance = text_style_appearance(&st.fill, &st.stroke, st.stroke_width);
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;
|
||||
d.remove(tid)?;
|
||||
if children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.opacity = n.opacity;
|
||||
g.blend = n.blend;
|
||||
g.name = n.name.clone();
|
||||
d.insert(par, idx, g)?;
|
||||
out.push(gid);
|
||||
if roots.contains(&tid) {
|
||||
new_sel.push(gid);
|
||||
}
|
||||
}
|
||||
sel.set(new_sel);
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = targets(s, p)?;
|
||||
let t = text_ids(&s.doc()?.doc, &ids);
|
||||
if t.is_empty() {
|
||||
return Err(bad(cmd, "no text objects selected"));
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string();
|
||||
let ids = text_targets(s, p, "text.setText")?;
|
||||
s.edit("Typing", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let style = t.first_style();
|
||||
t.runs = vec![drawcraft_doc::TextRun { text: text.clone(), style }];
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn justify_param(v: &str) -> Option<Justify> {
|
||||
Some(match v.to_ascii_lowercase().as_str() {
|
||||
"left" => Justify::Left,
|
||||
"center" => Justify::Center,
|
||||
"right" => Justify::Right,
|
||||
"justifyleft" => Justify::JustifyLeft,
|
||||
"justifycenter" => Justify::JustifyCenter,
|
||||
"justifyright" => Justify::JustifyRight,
|
||||
"justifyall" | "justify" => Justify::JustifyAll,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setStyle";
|
||||
let font = str_param(p, "font").map(str::to_string);
|
||||
let style = str_param(p, "style").map(str::to_string);
|
||||
let size = num_param(p, "size");
|
||||
if size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
let leading = match p.get("leading") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
|
||||
Some(_) => Some(Some(num_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))),
|
||||
};
|
||||
let tracking = num_param(p, "tracking");
|
||||
let justify = match str_param(p, "justify") {
|
||||
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be left|center|right|justifyAll"))?),
|
||||
None => None,
|
||||
};
|
||||
let fill = match p.get("fill") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(drawcraft_color::Paint::None),
|
||||
Some(v) => Some(drawcraft_color::Paint::solid(color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?)),
|
||||
};
|
||||
if font.is_none() && style.is_none() && size.is_none() && leading.is_none() && tracking.is_none() && justify.is_none() && fill.is_none() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let ids = text_targets(s, p, C)?;
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
for r in &mut t.runs {
|
||||
let st = &mut r.style;
|
||||
if let Some(f) = &font {
|
||||
st.font_family = f.clone();
|
||||
}
|
||||
if let Some(f) = &style {
|
||||
st.font_style = f.clone();
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
}
|
||||
if let Some(l) = leading {
|
||||
st.leading = l;
|
||||
}
|
||||
if let Some(v) = tracking {
|
||||
st.tracking = v.clamp(-1000.0, 10000.0);
|
||||
}
|
||||
if let Some(f) = &fill {
|
||||
st.fill = f.clone();
|
||||
}
|
||||
}
|
||||
if let Some(j) = justify {
|
||||
t.para.justify = j;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
Reference in New Issue
Block a user