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:
Brandon Thomas
2026-09-30 06:28:59 -04:00
co-authored by Claude Opus 5.5
parent 8cd8f8f8a7
commit 874c0e6ca2
19 changed files with 1325 additions and 67 deletions
+4
View File
@@ -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
})
}
+95 -26
View File
@@ -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))
}
+209
View File
@@ -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<_>>() }))
}