Distortion tools (Width, Liquify, Puppet Warp, Perspective Grid), Patterns + Repeat, colour management (CMS, proofing, separations), Preferences/Shortcuts/Workspaces; original brand mark; fix filter panic in thumbnails; README demo scenes

- New original DrawCraft mark (gradient + pen nib) replaces the two-letter badge
- render: thumbnails and pattern tiles use a single-threaded context (vello's default is
  multithreaded and panics on filters); per-item raster effects detected
- Type tool state no longer leaks across documents
- ui.set {status} for clean screenshots
- README: new hero, feature shots, Made in DrawCraft gallery; examples/neon-drive, ribbons,
  feature-sheet; ASSETS.md updated; heading renamed to Crafting Apps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 22:44:39 -04:00
co-authored by Claude Opus 5.5
parent 05f5bed1ad
commit acb2566d89
46 changed files with 24072 additions and 88 deletions
+5 -2
View File
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
[],
None,
"{ids?, colors?: [color]} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
always,
has_doc,
gamut_check
),
cmd!(
@@ -410,7 +410,10 @@ fn plates_json(s: &Session) -> Value {
fn separations(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.separationsPreview";
let mut v = proof::view();
let all: Vec<String> = s.active().map(|d| proof::plates(&d.doc).into_iter().map(|p| p.name).collect()).unwrap_or_else(|| cms::PROCESS_PLATES.iter().map(|s| s.to_string()).collect());
let all: Vec<String> = s
.active()
.map(|d| proof::plates(&d.doc).into_iter().map(|p| p.name).collect())
.unwrap_or_else(|| cms::PROCESS_PLATES.iter().map(|s| s.to_string()).collect());
let check = |n: &str| if all.iter().any(|a| a == n) { Ok(n.to_string()) } else { Err(bad(C, format!("unknown plate `{n}`"))) };
if let Some(on) = p.get("on").and_then(Value::as_bool) {
v.separations = if on { Some(v.separations.take().unwrap_or_else(|| all.clone())) } else { None };
+8 -2
View File
@@ -251,7 +251,9 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap();
let mut all = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { guide: false, .. }) && n.geometric_bounds().is_some_and(|b| b.inflate(1e-6, 1e-6).intersect(sweep).area() > 0.0) {
if matches!(n.kind, NodeKind::Path { guide: false, .. })
&& n.geometric_bounds().is_some_and(|b| b.inflate(1e-6, 1e-6).intersect(sweep).area() > 0.0)
{
all.push(n.id);
}
});
@@ -345,7 +347,11 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
}
fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
let kind = p.get("kind").and_then(Value::as_u64).filter(|k| (1..=3).contains(k)).ok_or_else(|| bad("perspective.grid.preset", "kind must be 1, 2 or 3"))? as u8;
let kind = p
.get("kind")
.and_then(Value::as_u64)
.filter(|k| (1..=3).contains(k))
.ok_or_else(|| bad("perspective.grid.preset", "kind must be 1, 2 or 3"))? as u8;
let old = grid_of(&s.doc()?.doc);
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
let g = PerspectiveGrid { attached: old.attached, ..PerspectiveGrid::preset(kind, ab) };
+1 -1
View File
@@ -16,10 +16,10 @@ mod live;
pub(crate) mod menucmds;
mod object;
mod paint;
mod patterncmds;
mod panelcmds;
mod path;
mod pathops;
mod patterncmds;
pub mod prefscmds;
mod recolor;
mod select;
+4 -4
View File
@@ -660,10 +660,10 @@ mod tests;
#[cfg(test)]
mod tests_brushsym;
#[cfg(test)]
mod tests_colormgmt;
#[cfg(test)]
mod tests_build;
#[cfg(test)]
mod tests_colormgmt;
#[cfg(test)]
mod tests_distort;
#[cfg(test)]
mod tests_draw2;
@@ -676,12 +676,12 @@ mod tests_menucmds;
#[cfg(test)]
mod tests_panelcmds;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_pathops;
#[cfg(test)]
mod tests_pattern;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_textedit;
#[cfg(test)]
mod tests_xform;
+28 -8
View File
@@ -202,11 +202,20 @@ fn separation_plates_render_ink_coverage() {
assert!((v - 128).abs() <= 2, "50% magenta renders as 50% grey: {v}");
assert_eq!(plate("Black").pixel(25, 50), [255, 255, 255, 255]);
// Composite of C+M only shows the magenta object in magenta ink.
let cm = render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Magenta".into()]), ..Default::default() }), ..Default::default() });
let cm = render(
&doc,
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Magenta".into()]), ..Default::default() }), ..Default::default() },
);
assert_eq!(cm.pixel(25, 50)[0], 255);
let all = render(
&doc,
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into(), "Magenta".into(), "Yellow".into(), "Black".into()]), ..Default::default() }), ..Default::default() },
RenderOptions {
proof: Some(ProofSetup {
separations: Some(vec!["Cyan".into(), "Magenta".into(), "Yellow".into(), "Black".into()]),
..Default::default()
}),
..Default::default()
},
);
let p = all.pixel(25, 50);
assert!(p[0] < 40 && p[2] > 200, "composite cyan: {p:?}");
@@ -217,9 +226,17 @@ fn rgb_art_separates_with_gcr() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!("#000000"));
let doc = s.doc().unwrap().doc.clone();
let k = render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Black".into()]), ..Default::default() }), ..Default::default() }).pixel(50, 50);
let k = render(
&doc,
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Black".into()]), ..Default::default() }), ..Default::default() },
)
.pixel(50, 50);
assert!(k[0] < 20, "RGB black is mostly K: {k:?}");
let c = render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into()]), ..Default::default() }), ..Default::default() }).pixel(50, 50);
let c = render(
&doc,
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into()]), ..Default::default() }), ..Default::default() },
)
.pixel(50, 50);
assert!(c[0] > 40 && c[0] < 200, "rich black has some cyan: {c:?}");
}
@@ -236,7 +253,10 @@ fn spot_colours_get_their_own_plate() {
let names: Vec<&str> = plates["plates"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Brand Orange"]);
let doc = s.doc().unwrap().doc.clone();
let plate = |n: &str| render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec![n.into()]), ..Default::default() }), ..Default::default() }).pixel(50, 50);
let plate = |n: &str| {
render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec![n.into()]), ..Default::default() }), ..Default::default() })
.pixel(50, 50)
};
assert_eq!(plate("Brand Orange"), [0, 0, 0, 255]);
assert_eq!(plate("Magenta"), [255, 255, 255, 255], "spot ink isn't on the process plates");
// Panel commands: only / toggle.
@@ -285,9 +305,9 @@ fn view_toggles_redraw_without_dirtying() {
assert_eq!(drawcraft_render::proof::active_proof().unwrap().target, ProofTarget::Deuteranopia);
assert!(s.execute("view.proofSetup", &json!({"target": "cmyk:Nope"})).is_err());
s.execute("view.proofSetup", &json!({"target": "workingCmyk", "intent": "relative"})).unwrap();
s.execute("view.proofColors", &json!({})).unwrap();
assert!(drawcraft_render::proof::active_proof().is_none());
let r = s.execute("view.overprintPreview", &json!({})).unwrap();
let r = s.execute("view.proofColors", &json!({"on": false})).unwrap();
assert_eq!(r["proofColors"], false);
let r = s.execute("view.overprintPreview", &json!({"on": true})).unwrap();
assert_eq!(r["overprintPreview"], true);
s.execute("view.overprintPreview", &json!({"on": false})).unwrap();
}
+2 -5
View File
@@ -184,11 +184,8 @@ fn puppet_warp_satisfies_pins_and_keeps_area() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
let n = undo_len(&s);
s.execute(
"object.puppetWarp",
&json!({"id": a.0, "pins": [[120, 150], [250, 150], [380, 150]], "moved": [[120, 150], [250, 150], [370, 90]]}),
)
.unwrap();
s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[120, 150], [250, 150], [380, 150]], "moved": [[120, 150], [250, 150], [370, 90]]}))
.unwrap();
let p = path(&s, a);
let area0 = 300.0 * 100.0;
let area1 = p.to_bezpath().area().abs();
+1 -3
View File
@@ -115,9 +115,7 @@ fn list_describes_every_pref() {
#[test]
fn prefs_serde_round_trip_and_tolerates_missing_fields() {
let mut p = Prefs::default();
p.ui_scaling = 1.25;
p.units_general = "millimeters".into();
let p = Prefs { ui_scaling: 1.25, units_general: "millimeters".into(), ..Default::default() };
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
assert_eq!(back, p);
let partial: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap();