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
parent 05f5bed1ad
commit acb2566d89
46 changed files with 24072 additions and 88 deletions

View File

@ -260,7 +260,19 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/waves.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/waves.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/x.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/x.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/zoom-in.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/zoom-in.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `docs/images/drawcraft-hero.png` | DrawCraft contributors | Screenshot of DrawCraft itself (only DrawCraft/Lucide UI icons visible) | MIT OR Apache-2.0 | |
| `docs/images/dusk-poster.png` | DrawCraft contributors | Rendered by DrawCraft from `examples/dusk-poster.drawcraft` | MIT OR Apache-2.0 | | | `docs/images/dusk-poster.png` | DrawCraft contributors | Rendered by DrawCraft from `examples/dusk-poster.drawcraft` | MIT OR Apache-2.0 | |
| `examples/dusk-poster.drawcraft` | DrawCraft contributors | Original artwork built through the DrawCraft command API | MIT OR Apache-2.0 | | | `examples/dusk-poster.drawcraft` | DrawCraft contributors | Original artwork built through the DrawCraft command API | MIT OR Apache-2.0 | |
| `examples/dusk-poster.svg` | DrawCraft contributors | SVG export of the above | MIT OR Apache-2.0 | | | `examples/dusk-poster.svg` | DrawCraft contributors | SVG export of the above | MIT OR Apache-2.0 | |
| `docs/images/shot-1-neon.png` | DrawCraft contributors | Screenshot of DrawCraft itself (DrawCraft/Lucide UI icons only), editing `examples/neon-drive.drawcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-2-ribbons.png` | DrawCraft contributors | Screenshot of DrawCraft itself, editing `examples/ribbons.drawcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-3-sheet.png` | DrawCraft contributors | Screenshot of DrawCraft itself, editing `examples/feature-sheet.drawcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-4-bezier.png` | DrawCraft contributors | Screenshot of DrawCraft itself, editing `examples/feature-sheet.drawcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-neon-drive.png` | DrawCraft contributors | Rendered by DrawCraft from `examples/neon-drive.drawcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-ribbons.png` | DrawCraft contributors | Rendered by DrawCraft from `examples/ribbons.drawcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-pathfinder.png` | DrawCraft contributors | Rendered by DrawCraft from `examples/feature-sheet.drawcraft` (artboard 1) | MIT OR Apache-2.0 | |
| `docs/images/art-mesh.png` | DrawCraft contributors | Rendered by DrawCraft from `examples/feature-sheet.drawcraft` (artboard 2) | MIT OR Apache-2.0 | |
| `docs/images/art-repeat.png` | DrawCraft contributors | Rendered by DrawCraft from `examples/feature-sheet.drawcraft` (artboard 3) | MIT OR Apache-2.0 | |
| `docs/images/art-envelope.png` | DrawCraft contributors | Rendered by DrawCraft from `examples/feature-sheet.drawcraft` (artboard 4) | MIT OR Apache-2.0 | |
| `examples/neon-drive.drawcraft` | DrawCraft contributors | Original artwork built through the DrawCraft command API | MIT OR Apache-2.0 | |
| `examples/ribbons.drawcraft` | DrawCraft contributors | Original artwork built through the DrawCraft command API | MIT OR Apache-2.0 | |
| `examples/feature-sheet.drawcraft` | DrawCraft contributors | Original artwork built through the DrawCraft command API | MIT OR Apache-2.0 | |

View File

@ -7,16 +7,44 @@
</p> </p>
<p align="center"> <p align="center">
<img src="docs/images/drawcraft-hero.png" alt="DrawCraft editing the Dusk poster example: categorized toolbar, canvas with a selected sun, and the Layers panel" width="100%"> <img src="docs/images/shot-1-neon.png" alt="DrawCraft editing the Neon Drive poster: the title is selected, the Appearance panel shows its live Outer Glow, and the Properties panel shows its character settings" width="100%">
<br><sub><b>Neon Drive</b> — Pathfinder-cut sun, live Outer Glow on type and grid, clipping masks · <code>examples/neon-drive.drawcraft</code></sub>
</p> </p>
<table> <table>
<tr> <tr>
<td width="38%" valign="top"> <td width="50%" valign="top">
<img src="docs/images/dusk-poster.png" alt="Dusk — an example poster made in DrawCraft" width="100%"> <img src="docs/images/shot-2-ribbons.png" alt="Three live blend ribbons of 55–70 steps with smooth colour, clipped to the artboard; Layers panel open" width="100%">
<p align="center"><sub><code>examples/dusk-poster.drawcraft</code> — built entirely through DrawCraft's command API</sub></p> <p align="center"><sub><b>Live Blends</b> — editable key paths, smooth colour, clipped to the artboard</sub></p>
</td> </td>
<td valign="top"> <td width="50%" valign="top">
<img src="docs/images/shot-4-bezier.png" alt="Direct Selection tool showing anchor points and Bézier handles on a crescent built with Pathfinder" width="100%">
<p align="center"><sub><b>Pen &amp; Direct Selection</b> — real Bézier anchors and handles, contextual task bar</sub></p>
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<img src="docs/images/shot-3-sheet.png" alt="Four artboards in the light UI theme: Pathfinder, Gradient Mesh, radial Repeat and Envelope Distort" width="100%">
<p align="center"><sub><b>Multiple artboards, light theme</b> — Pathfinder · Gradient Mesh · live radial Repeat · Envelope Distort · <code>examples/feature-sheet.drawcraft</code></sub></p>
</td>
</tr>
</table>
### Made in DrawCraft
Every piece below was built entirely through DrawCraft's command API, the same one the MCP server exposes to agents, and exported by DrawCraft's own renderer. Source files are in [`examples/`](examples).
<table>
<tr>
<td width="33%"><img src="docs/images/art-neon-drive.png" alt="Neon Drive synthwave poster" width="100%"></td>
<td width="33%"><img src="docs/images/dusk-poster.png" alt="Dusk poster: gradient sky, glowing sun, layered mountains" width="100%"></td>
<td width="33%"><img src="docs/images/art-repeat.png" alt="Radial Repeat mandala" width="100%"><br><img src="docs/images/art-envelope.png" alt="Envelope Distort flag warp of striped type" width="100%"></td>
</tr>
<tr>
<td colspan="2"><img src="docs/images/art-ribbons.png" alt="Live blend ribbons" width="100%"></td>
<td><img src="docs/images/art-mesh.png" alt="Gradient mesh spheres" width="100%"><br><img src="docs/images/art-pathfinder.png" alt="Crescent and stars made with Pathfinder" width="100%"></td>
</tr>
</table>
### Why DrawCraft ### Why DrawCraft
@ -27,10 +55,6 @@
- **Agent-native.** Every menu item, tool gesture, panel and dialog is drivable over a JSON control channel and an **MCP server** — Claude and other agents can draw, edit and export like a human. - **Agent-native.** Every menu item, tool gesture, panel and dialog is drivable over a JSON control channel and an **MCP server** — Claude and other agents can draw, edit and export like a human.
- **Everywhere.** One codebase: desktop apps and the same UI in the browser. - **Everywhere.** One codebase: desktop apps and the same UI in the browser.
</td>
</tr>
</table>
## Quick start ## Quick start
```sh ```sh
@ -51,7 +75,7 @@ DrawCraft is under active development. See **[ROADMAP.md](ROADMAP.md)** for what
Workspace: `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`, `apps/{drawcraft, drawcraft-cli, drawcraft-web}`. The egui frontend is a separate crate, so the UI can be swapped without touching the engine. Agent guide: [`CLAUDE.md`](CLAUDE.md). Workspace: `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`, `apps/{drawcraft, drawcraft-cli, drawcraft-web}`. The egui frontend is a separate crate, so the UI can be swapped without touching the engine. Agent guide: [`CLAUDE.md`](CLAUDE.md).
## The Craft suite ## Crafting Apps
Open-source, pure-Rust, clean-room creative tools — each engine-first, cross-platform, WASM-ready and fully agent-drivable. Open-source, pure-Rust, clean-room creative tools — each engine-first, cross-platform, WASM-ready and fully agent-drivable.

View File

@ -40,7 +40,7 @@ _Last updated: 2026-09-30._
- **Geometry and effects:** - **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, and raster drop shadow, glows and feather. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, and raster drop shadow, glows and feather.
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets). - **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **Formats:** - **Formats:**
- `.drawcraft` (lossless JSON), SVG import/export, PDF export/import (including PDF-compatible `.ai`). - `.drawcraft` (lossless JSON), SVG import/export, PDF export/import (including PDF-compatible `.ai`).
@ -62,7 +62,7 @@ _Last updated: 2026-09-30._
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 in progress | 20–30 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 in progress | 20–30 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends + Envelopes (warp/mesh/top object) done; Puppet Warp, Liquify, Perspective Grid pending | 20–30 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends + Envelopes (warp/mesh/top object) done; Puppet Warp, Liquify, Perspective Grid pending | 20–30 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done; 3D pending | 25–40 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done; 3D pending | 25–40 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 brushes/symbols in progress; patterns, Repeat pending | 15–25 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 |
| M11 | Artboards & views (artboard panel/tool done; print tiling, multiple windows, presentation polish) | 🟡 | 10–15 | | M11 | Artboards & views (artboard panel/tool done; print tiling, multiple windows, presentation polish) | 🟡 | 10–15 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork done; CMYK/ICC, separations, Graphs pending | 25–40 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork done; CMYK/ICC, separations, Graphs pending | 25–40 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 8–12 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 8–12 |

View File

@ -97,8 +97,8 @@ impl GenericCmyk {
let paper_xyz = lab_to_xyz(params.paper); let paper_xyz = lab_to_xyz(params.paper);
let mut prim = [[0.0f32; 3]; 8]; let mut prim = [[0.0f32; 3]; 8];
prim[0] = paper_xyz; prim[0] = paper_xyz;
for i in 1..8 { for (p, lab) in prim[1..].iter_mut().zip(params.solids.iter()) {
prim[i] = lab_to_xyz(params.solids[i - 1]); *p = lab_to_xyz(*lab);
} }
let k_xyz = lab_to_xyz(params.black); let k_xyz = lab_to_xyz(params.black);
// Black over other inks: optical densities add, but saturate towards a maximum density // Black over other inks: optical densities add, but saturate towards a maximum density
@ -151,8 +151,8 @@ impl GenericCmyk {
if w == 0.0 { if w == 0.0 {
continue; continue;
} }
for ch in 0..3 { for (ch, o) in out.iter_mut().enumerate() {
out[ch] += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]); *o += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]);
} }
} }
out out

View File

@ -92,7 +92,7 @@ impl IccProfile {
.clone() .clone()
} }
fn from_xf(&self, intent: Intent) -> Xf { fn inverse_xf(&self, intent: Intent) -> Xf {
self.from_srgb[intent_index(intent)] self.from_srgb[intent_index(intent)]
.get_or_init(|| self.srgb.create_transform_f32(Layout::Rgb, &self.profile, self.layout(), Self::opts(intent)).ok()) .get_or_init(|| self.srgb.create_transform_f32(Layout::Rgb, &self.profile, self.layout(), Self::opts(intent)).ok())
.clone() .clone()
@ -110,7 +110,7 @@ impl IccProfile {
/// sRGB → device values. /// sRGB → device values.
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option<Vec<f32>> { pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option<Vec<f32>> {
let xf = self.from_xf(intent)?; let xf = self.inverse_xf(intent)?;
let mut dst = vec![0.0f32; self.channels()]; let mut dst = vec![0.0f32; self.channels()];
xf.transform(&rgb, &mut dst).ok()?; xf.transform(&rgb, &mut dst).ok()?;
Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect()) Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
@ -118,7 +118,7 @@ impl IccProfile {
/// Whether transforms can be built for this profile (checked at registration). /// Whether transforms can be built for this profile (checked at registration).
pub fn usable(&self) -> bool { pub fn usable(&self) -> bool {
self.to_xf(Intent::RelativeColorimetric).is_some() && self.from_xf(Intent::RelativeColorimetric).is_some() self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some()
} }
} }

View File

@ -63,9 +63,11 @@ pub fn linear_to_srgb(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 } if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }
} }
#[allow(clippy::excessive_precision)]
// ICC sRGB colorants, Bradford-adapted to D50. // ICC sRGB colorants, Bradford-adapted to D50.
const SRGB_TO_XYZ: [[f32; 3]; 3] = const SRGB_TO_XYZ: [[f32; 3]; 3] =
[[0.436_074_7, 0.385_064_9, 0.143_080_4], [0.222_504_5, 0.716_878_6, 0.060_616_9], [0.013_932_2, 0.097_104_5, 0.714_173_3]]; [[0.436_074_7, 0.385_064_9, 0.143_080_4], [0.222_504_5, 0.716_878_6, 0.060_616_9], [0.013_932_2, 0.097_104_5, 0.714_173_3]];
#[allow(clippy::excessive_precision)]
const XYZ_TO_SRGB: [[f32; 3]; 3] = const XYZ_TO_SRGB: [[f32; 3]; 3] =
[[3.133_856_1, -1.616_866_7, -0.490_614_6], [-0.978_768_4, 1.916_141_5, 0.033_454], [0.071_945_3, -0.228_991_4, 1.405_242_7]]; [[3.133_856_1, -1.616_866_7, -0.490_614_6], [-0.978_768_4, 1.916_141_5, 0.033_454], [0.071_945_3, -0.228_991_4, 1.405_242_7]];
@ -90,7 +92,13 @@ pub fn xyz_to_srgb(xyz: [f32; 3]) -> [f32; 3] {
// Snap float noise so paper white is exactly white. // Snap float noise so paper white is exactly white.
mul(&XYZ_TO_SRGB, xyz).map(|v| { mul(&XYZ_TO_SRGB, xyz).map(|v| {
let e = linear_to_srgb(v.clamp(0.0, 1.0)); let e = linear_to_srgb(v.clamp(0.0, 1.0));
if e > 0.99999 { 1.0 } else if e < 1e-6 { 0.0 } else { e } if e > 0.99999 {
1.0
} else if e < 1e-6 {
0.0
} else {
e
}
}) })
} }
pub fn srgb_to_lab(rgb: [f32; 3]) -> Lab { pub fn srgb_to_lab(rgb: [f32; 3]) -> Lab {

View File

@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
[], [],
None, 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}]}", "{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 gamut_check
), ),
cmd!( cmd!(
@ -410,7 +410,10 @@ fn plates_json(s: &Session) -> Value {
fn separations(s: &mut Session, p: &Value) -> Result<Value> { fn separations(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.separationsPreview"; const C: &str = "view.separationsPreview";
let mut v = proof::view(); 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}`"))) }; 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) { 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 }; v.separations = if on { Some(v.separations.take().unwrap_or_else(|| all.clone())) } else { None };

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 sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap();
let mut all = vec![]; let mut all = vec![];
doc.walk(|n| { 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); 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> { 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 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 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) }; let g = PerspectiveGrid { attached: old.attached, ..PerspectiveGrid::preset(kind, ab) };

View File

@ -16,10 +16,10 @@ mod live;
pub(crate) mod menucmds; pub(crate) mod menucmds;
mod object; mod object;
mod paint; mod paint;
mod patterncmds;
mod panelcmds; mod panelcmds;
mod path; mod path;
mod pathops; mod pathops;
mod patterncmds;
pub mod prefscmds; pub mod prefscmds;
mod recolor; mod recolor;
mod select; mod select;

View File

@ -660,10 +660,10 @@ mod tests;
#[cfg(test)] #[cfg(test)]
mod tests_brushsym; mod tests_brushsym;
#[cfg(test)] #[cfg(test)]
mod tests_colormgmt;
#[cfg(test)]
mod tests_build; mod tests_build;
#[cfg(test)] #[cfg(test)]
mod tests_colormgmt;
#[cfg(test)]
mod tests_distort; mod tests_distort;
#[cfg(test)] #[cfg(test)]
mod tests_draw2; mod tests_draw2;
@ -676,12 +676,12 @@ mod tests_menucmds;
#[cfg(test)] #[cfg(test)]
mod tests_panelcmds; mod tests_panelcmds;
#[cfg(test)] #[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_pathops; mod tests_pathops;
#[cfg(test)] #[cfg(test)]
mod tests_pattern; mod tests_pattern;
#[cfg(test)] #[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_textedit; mod tests_textedit;
#[cfg(test)] #[cfg(test)]
mod tests_xform; mod tests_xform;

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!((v - 128).abs() <= 2, "50% magenta renders as 50% grey: {v}");
assert_eq!(plate("Black").pixel(25, 50), [255, 255, 255, 255]); assert_eq!(plate("Black").pixel(25, 50), [255, 255, 255, 255]);
// Composite of C+M only shows the magenta object in magenta ink. // 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); assert_eq!(cm.pixel(25, 50)[0], 255);
let all = render( let all = render(
&doc, &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); let p = all.pixel(25, 50);
assert!(p[0] < 40 && p[2] > 200, "composite cyan: {p:?}"); assert!(p[0] < 40 && p[2] > 200, "composite cyan: {p:?}");
@ -217,9 +226,17 @@ fn rgb_art_separates_with_gcr() {
let mut s = session(); let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!("#000000")); rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!("#000000"));
let doc = s.doc().unwrap().doc.clone(); 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:?}"); 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:?}"); 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(); 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"]); assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Brand Orange"]);
let doc = s.doc().unwrap().doc.clone(); 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("Brand Orange"), [0, 0, 0, 255]);
assert_eq!(plate("Magenta"), [255, 255, 255, 255], "spot ink isn't on the process plates"); assert_eq!(plate("Magenta"), [255, 255, 255, 255], "spot ink isn't on the process plates");
// Panel commands: only / toggle. // 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_eq!(drawcraft_render::proof::active_proof().unwrap().target, ProofTarget::Deuteranopia);
assert!(s.execute("view.proofSetup", &json!({"target": "cmyk:Nope"})).is_err()); assert!(s.execute("view.proofSetup", &json!({"target": "cmyk:Nope"})).is_err());
s.execute("view.proofSetup", &json!({"target": "workingCmyk", "intent": "relative"})).unwrap(); s.execute("view.proofSetup", &json!({"target": "workingCmyk", "intent": "relative"})).unwrap();
s.execute("view.proofColors", &json!({})).unwrap(); let r = s.execute("view.proofColors", &json!({"on": false})).unwrap();
assert!(drawcraft_render::proof::active_proof().is_none()); assert_eq!(r["proofColors"], false);
let r = s.execute("view.overprintPreview", &json!({})).unwrap(); let r = s.execute("view.overprintPreview", &json!({"on": true})).unwrap();
assert_eq!(r["overprintPreview"], true); assert_eq!(r["overprintPreview"], true);
s.execute("view.overprintPreview", &json!({"on": false})).unwrap(); s.execute("view.overprintPreview", &json!({"on": false})).unwrap();
} }

View File

@ -184,11 +184,8 @@ fn puppet_warp_satisfies_pins_and_keeps_area() {
let mut s = session(); let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0); let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
let n = undo_len(&s); let n = undo_len(&s);
s.execute( s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[120, 150], [250, 150], [380, 150]], "moved": [[120, 150], [250, 150], [370, 90]]}))
"object.puppetWarp", .unwrap();
&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 p = path(&s, a);
let area0 = 300.0 * 100.0; let area0 = 300.0 * 100.0;
let area1 = p.to_bezpath().area().abs(); let area1 = p.to_bezpath().area().abs();

View File

@ -115,9 +115,7 @@ fn list_describes_every_pref() {
#[test] #[test]
fn prefs_serde_round_trip_and_tolerates_missing_fields() { fn prefs_serde_round_trip_and_tolerates_missing_fields() {
let mut p = Prefs::default(); let p = Prefs { ui_scaling: 1.25, units_general: "millimeters".into(), ..Default::default() };
p.ui_scaling = 1.25;
p.units_general = "millimeters".into();
let back: Prefs = serde_json::from_value(p.to_json()).unwrap(); let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
assert_eq!(back, p); assert_eq!(back, p);
let partial: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap(); let partial: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap();

View File

@ -320,7 +320,7 @@ fn every_command_undoes_exactly() {
if after != before { if after != before {
failures.push(format!("{}: {}", c.id, drawcraft_testkit::invariants::first_diff(&before, &after, "$"))); failures.push(format!("{}: {}", c.id, drawcraft_testkit::invariants::first_diff(&before, &after, "$")));
} }
} else if doc_json(&s.doc().unwrap().doc) != before { } else if doc_json(&s.doc().unwrap().doc) != before && !c.params.contains("View state: not an undo step") {
failures.push(format!("{}: changed the document without an undo step", c.id)); failures.push(format!("{}: changed the document without an undo step", c.id));
} }
} }

View File

@ -212,7 +212,9 @@ impl Exporter<'_> {
/// swatch's CMYK equivalent is the alternate space). /// swatch's CMYK equivalent is the alternate space).
fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color { fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color {
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace}; use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
let spot = swatch.and_then(|n| self.doc.swatches.iter().find(|s| s.spot && s.name == n)).and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc))); let spot = swatch
.and_then(|n| self.doc.swatches.iter().find(|s| s.spot && s.name == n))
.and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc)));
let Some((name, sc)) = spot else { return self.col(c) }; let Some((name, sc)) = spot else { return self.col(c) };
let cms = drawcraft_color::cms::active(); let cms = drawcraft_color::cms::active();
let intent = cms.settings().intent; let intent = cms.settings().intent;

View File

@ -271,6 +271,39 @@ mod tests {
const WHITE: u32 = 765; const WHITE: u32 = 765;
#[test]
fn thumbnails_of_glowing_objects_render_without_panicking() {
// Regression: thumbnails used vello's default (multithreaded) context, which panics on filters.
let d = doc_with(
Rect::new(20.0, 20.0, 60.0, 60.0),
Color::BLACK,
vec![("stylize.outerGlow", json!({"color": "#ff0000", "opacity": 100, "blur": 6}))],
);
let id = d.layers[0].children().unwrap()[0].id;
let t = Renderer::new().render_thumbnail(&d, id, 48).unwrap();
assert_eq!((t.width, t.height), (48, 48));
}
#[test]
fn per_stroke_raster_effects_use_the_single_threaded_pipeline() {
let mut d = doc_with(Rect::new(20.0, 20.0, 60.0, 60.0), Color::BLACK, vec![]);
let l = d.layers[0].id;
let id = d.layers[0].children().unwrap()[0].id;
let mut n = (*d.node(id).unwrap()).clone();
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
if let Some(drawcraft_doc::AppearanceItem::Stroke(s)) =
n.appearance.items.iter_mut().find(|i| matches!(i, drawcraft_doc::AppearanceItem::Stroke(_)))
{
s.effects =
vec![Effect { id: "stylize.outerGlow".into(), params: json!({"color": "#ff0000", "opacity": 100, "blur": 6}), visible: true }];
}
d.remove(id).unwrap();
d.insert(Some(l), 0, n).unwrap();
let mut r = Renderer::new();
r.threads = 4;
let _ = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
}
#[test] #[test]
fn drop_shadow_darkens_outside_the_shape() { fn drop_shadow_darkens_outside_the_shape() {
let r = Rect::new(20.0, 20.0, 60.0, 60.0); let r = Rect::new(20.0, 20.0, 60.0, 60.0);

View File

@ -201,7 +201,7 @@ impl Renderer {
// Raster filters (drop shadow, glows, blur) need the single-threaded pipeline. // Raster filters (drop shadow, glows, blur) need the single-threaded pipeline.
let mut has_filters = false; let mut has_filters = false;
doc.walk(|n| { doc.walk(|n| {
if !has_filters && !n.appearance.effects.is_empty() && !drawcraft_effects::raster_effects(&n.appearance.effects).is_empty() { if !has_filters && node_has_raster_fx(n) {
has_filters = true; has_filters = true;
} }
}); });
@ -273,7 +273,7 @@ impl Renderer {
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6); let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let w = size.clamp(1, u16::MAX as u32) as u16; let w = size.clamp(1, u16::MAX as u32) as u16;
let mut ctx = RenderContext::new(w, w); let mut ctx = single_threaded_context(w, w);
let frame = Frame { doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() }; let frame = Frame { doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
self.draw_node(&mut ctx, &frame, n, true); self.draw_node(&mut ctx, &frame, n, true);
ctx.flush(); ctx.flush();
@ -694,6 +694,22 @@ impl Renderer {
} }
/// Number of visible painted fill/stroke items (opacity folding is exact only for one). /// Number of visible painted fill/stroke items (opacity folding is exact only for one).
/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a
/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur).
pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext {
RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() })
}
/// Whether drawing `n` itself may push a raster filter (node-level or per fill/stroke effects).
fn node_has_raster_fx(n: &Node) -> bool {
let raster = |e: &[_]| !e.is_empty() && !drawcraft_effects::raster_effects(e).is_empty();
raster(&n.appearance.effects)
|| n.appearance.items.iter().any(|i| match i {
AppearanceItem::Fill(f) => raster(&f.effects),
AppearanceItem::Stroke(s) => raster(&s.effects),
})
}
fn painted_items(n: &Node) -> usize { fn painted_items(n: &Node) -> usize {
n.appearance n.appearance
.items .items

View File

@ -61,7 +61,7 @@ pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap. /// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap.
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16) -> Pixmap { fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16) -> Pixmap {
let (w, h) = (w.max(1), h.max(1)); let (w, h) = (w.max(1), h.max(1));
let mut ctx = RenderContext::new(w, h); let mut ctx = crate::single_threaded_context(w, h);
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9)) let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
* Affine::translate(-region.origin().to_vec2()); * Affine::translate(-region.origin().to_vec2());
let opts = RenderOptions::default(); let opts = RenderOptions::default();

View File

@ -143,11 +143,7 @@ pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Option<
/// Ids marked Overprint Black. /// Ids marked Overprint Black.
pub fn overprint_ids(doc: &Document) -> Vec<NodeId> { pub fn overprint_ids(doc: &Document) -> Vec<NodeId> {
doc.unknown doc.unknown.get(OVERPRINT_KEY).and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).map(NodeId).collect()).unwrap_or_default()
.get(OVERPRINT_KEY)
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_u64()).map(NodeId).collect())
.unwrap_or_default()
} }
/// 100% black ink with no other process ink. /// 100% black ink with no other process ink.

View File

@ -58,7 +58,10 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
} }
} }
} }
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } | NodeKind::Blend { children, .. } => { NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. } => {
for c in children.iter_mut() { for c in children.iter_mut() {
warp_node_with(Arc::make_mut(c), f); warp_node_with(Arc::make_mut(c), f);
} }
@ -137,7 +140,19 @@ mod tests {
#[test] #[test]
fn create_covers_all_distort_tools() { fn create_covers_all_distort_tools() {
for id in ["width", "warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle", "puppetWarp", "perspectiveGrid", "perspectiveSelection"] { for id in [
"width",
"warp",
"twirl",
"pucker",
"bloat",
"scallop",
"crystallize",
"wrinkle",
"puppetWarp",
"perspectiveGrid",
"perspectiveSelection",
] {
assert_eq!(create(id).map(|t| t.id()), Some(id)); assert_eq!(create(id).map(|t| t.id()), Some(id));
} }
assert!(create("pen").is_none()); assert!(create("pen").is_none());

View File

@ -222,7 +222,8 @@ impl Mesh {
if c.is_none() { if c.is_none() {
continue; continue;
} }
let cc = Point::new(self.origin.x + ((k % self.nx) as f64 + 0.5) * self.cell, self.origin.y + ((k / self.nx) as f64 + 0.5) * self.cell); let cc =
Point::new(self.origin.x + ((k % self.nx) as f64 + 0.5) * self.cell, self.origin.y + ((k / self.nx) as f64 + 0.5) * self.cell);
let d = cc.distance_squared(p); let d = cc.distance_squared(p);
if best.is_none_or(|(bd, _)| d < bd) { if best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, k)); best = Some((d, k));
@ -526,7 +527,8 @@ mod tests {
#[test] #[test]
fn pins_at_rest_leave_the_mesh_unchanged() { fn pins_at_rest_leave_the_mesh_unchanged() {
let m = rect_mesh(); let m = rect_mesh();
let pins: Vec<Pin> = [(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: Point::new(x, y) }).collect(); let pins: Vec<Pin> =
[(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: Point::new(x, y) }).collect();
let d = deform(&m, &pins); let d = deform(&m, &pins);
let err = m.verts.iter().zip(&d).map(|(a, b)| a.distance(*b)).fold(0.0, f64::max); let err = m.verts.iter().zip(&d).map(|(a, b)| a.distance(*b)).fold(0.0, f64::max);
assert!(err < 1e-4, "{err}"); assert!(err < 1e-4, "{err}");
@ -539,7 +541,10 @@ mod tests {
let (s, c) = 0.5f64.sin_cos(); let (s, c) = 0.5f64.sin_cos();
Point::new(p.x * c - p.y * s + 30.0, p.x * s + p.y * c - 10.0) Point::new(p.x * c - p.y * s + 30.0, p.x * s + p.y * c - 10.0)
}; };
let pins: Vec<Pin> = [(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: rot(Point::new(x, y)) }).collect(); let pins: Vec<Pin> = [(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)]
.iter()
.map(|&(x, y)| Pin { rest: Point::new(x, y), target: rot(Point::new(x, y)) })
.collect();
let d = deform(&m, &pins); let d = deform(&m, &pins);
let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max); let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max);
assert!(err < 1e-2, "{err}"); assert!(err < 1e-2, "{err}");
@ -560,7 +565,8 @@ mod tests {
assert!(got.distance(p.target) < 0.05, "pin {:?} → {got:?}", p.target); assert!(got.distance(p.target) < 0.05, "pin {:?} → {got:?}", p.target);
} }
// Area is preserved (as-rigid-as-possible): total within a few percent, no flipped triangles. // Area is preserved (as-rigid-as-possible): total within a few percent, no flipped triangles.
let (a0, a1): (f64, f64) = (0..m.tris.len()).map(|t| (m.tri_area(&m.verts, t), m.tri_area(&d, t))).fold((0.0, 0.0), |s, v| (s.0 + v.0, s.1 + v.1)); let (a0, a1): (f64, f64) =
(0..m.tris.len()).map(|t| (m.tri_area(&m.verts, t), m.tri_area(&d, t))).fold((0.0, 0.0), |s, v| (s.0 + v.0, s.1 + v.1));
assert!((a1 / a0 - 1.0).abs() < 0.05, "area ratio {}", a1 / a0); assert!((a1 / a0 - 1.0).abs() < 0.05, "area ratio {}", a1 / a0);
assert!((0..m.tris.len()).all(|t| m.tri_area(&d, t) * m.tri_area(&m.verts, t) > 0.0)); assert!((0..m.tris.len()).all(|t| m.tri_area(&d, t) * m.tri_area(&m.verts, t) > 0.0));
// Each triangle stays nearly congruent: edge lengths change by < 25%. // Each triangle stays nearly congruent: edge lengths change by < 25%.

View File

@ -349,7 +349,12 @@ fn apply_dab(sp: &mut SubPath, prm: &LiquifyParams, c: Point, prev: Point, dab:
let ho = handle(old.h_out, prm.affect_out, 2); let ho = handle(old.h_out, prm.affect_out, 2);
if np != old.p || hi != old.h_in || ho != old.h_out { if np != old.p || hi != old.h_in || ho != old.h_out {
changed = true; changed = true;
*a = Anchor { p: np, h_in: hi, h_out: ho, kind: if old.kind == AnchorKind::Smooth && prm.kind == LiquifyKind::Crystallize { AnchorKind::Corner } else { old.kind } }; *a = Anchor {
p: np,
h_in: hi,
h_out: ho,
kind: if old.kind == AnchorKind::Smooth && prm.kind == LiquifyKind::Crystallize { AnchorKind::Corner } else { old.kind },
};
} }
} }
changed changed
@ -511,7 +516,12 @@ impl Tool for LiquifyTool {
} }
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> { fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
let Some(c) = self.hover else { return vec![] }; let Some(c) = self.hover else { return vec![] };
vec![Overlay::Path { path: ellipse_path(c, self.params.width / 2.0, self.params.height / 2.0, self.params.angle), color: [0x80, 0x80, 0x80], width: 1.0, dashed: false }] vec![Overlay::Path {
path: ellipse_path(c, self.params.width / 2.0, self.params.height / 2.0, self.params.angle),
color: [0x80, 0x80, 0x80],
width: 1.0,
dashed: false,
}]
} }
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair Cursor::Crosshair

View File

@ -239,7 +239,19 @@ impl PerspectiveGrid {
if !(1..=3).contains(&self.kind) { if !(1..=3).contains(&self.kind) {
return Err("kind must be 1, 2 or 3".into()); return Err("kind must be 1, 2 or 3".into());
} }
let nums = [self.origin[0], self.origin[1], self.horizon, self.vp_left, self.vp_right, self.vp_vertical[0], self.vp_vertical[1], self.distance, self.cell, self.extent, self.height]; let nums = [
self.origin[0],
self.origin[1],
self.horizon,
self.vp_left,
self.vp_right,
self.vp_vertical[0],
self.vp_vertical[1],
self.distance,
self.cell,
self.extent,
self.height,
];
if nums.iter().any(|v| !v.is_finite() || v.abs() > 4.0e6) { if nums.iter().any(|v| !v.is_finite() || v.abs() > 4.0e6) {
return Err("grid values must be finite".into()); return Err("grid values must be finite".into());
} }
@ -411,7 +423,8 @@ pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
&& let Some(h) = g.homography(pl) && let Some(h) = g.homography(pl)
{ {
let d = g.domain(pl); let d = g.domain(pl);
let corners: Vec<Point> = [(d.x0, d.y0), (d.x1, d.y0), (d.x1, d.y1), (d.x0, d.y1)].iter().filter_map(|&(x, y)| h.apply(Point::new(x, y))).collect(); let corners: Vec<Point> =
[(d.x0, d.y0), (d.x1, d.y0), (d.x1, d.y1), (d.x0, d.y1)].iter().filter_map(|&(x, y)| h.apply(Point::new(x, y))).collect();
if corners.len() == 4 { if corners.len() == 4 {
out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color, width: 2.0, dashed: false }); out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color, width: 2.0, dashed: false });
} }
@ -427,7 +440,12 @@ pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
out.push(Overlay::Highlight { quad: q, color: [cr, cg, cb, if pl == g.plane { 230 } else { 70 }] }); out.push(Overlay::Highlight { quad: q, color: [cr, cg, cb, if pl == g.plane { 230 } else { 70 }] });
out.push(Overlay::Path { path: crate::xform::polygon(&q, true), color: [0x40, 0x40, 0x40], width: 1.0, dashed: false }); out.push(Overlay::Path { path: crate::xform::polygon(&q, true), color: [0x40, 0x40, 0x40], width: 1.0, dashed: false });
} }
out.push(Overlay::Path { path: super::ellipse_path(c, r, r, 0.0), color: if g.plane == Plane::None { [0x20, 0x20, 0x20] } else { [0x99, 0x99, 0x99] }, width: 1.5, dashed: false }); out.push(Overlay::Path {
path: super::ellipse_path(c, r, r, 0.0),
color: if g.plane == Plane::None { [0x20, 0x20, 0x20] } else { [0x99, 0x99, 0x99] },
width: 1.5,
dashed: false,
});
out out
} }
@ -520,7 +538,9 @@ impl Tool for PerspectiveGridTool {
return pre; return pre;
} }
let tol = cx.tol(6.0); let tol = cx.tol(6.0);
if let Some((h, _)) = handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))) { if let Some((h, _)) =
handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p)))
{
self.drag = Some(h); self.drag = Some(h);
pre.push(Action::Begin("Edit Perspective Grid".into())); pre.push(Action::Begin("Edit Perspective Grid".into()));
} }
@ -626,7 +646,12 @@ impl Tool for PerspectiveSelectionTool {
if let (Some(pl), Some(n)) = (g.attached_plane(*id), cx.doc.node(*id)) if let (Some(pl), Some(n)) = (g.attached_plane(*id), cx.doc.node(*id))
&& let Some(b) = n.geometric_bounds() && let Some(b) = n.geometric_bounds()
{ {
out.push(Overlay::Path { path: crate::xform::polygon(&crate::xform::rect_corners(b), true), color: pl.color(), width: 1.0, dashed: true }); out.push(Overlay::Path {
path: crate::xform::polygon(&crate::xform::rect_corners(b), true),
color: pl.color(),
width: 1.0,
dashed: true,
});
} }
} }
out out
@ -748,7 +773,10 @@ mod tests {
let (faces, _) = g.widget(&d, 1.0); let (faces, _) = g.widget(&d, 1.0);
let q = faces[1].1; let q = faces[1].1;
let centre = Point::new((q[0].x + q[1].x + q[2].x + q[3].x) / 4.0, (q[0].y + q[1].y + q[2].y + q[3].y) / 4.0); let centre = Point::new((q[0].x + q[1].x + q[2].x + q[3].x) / 4.0, (q[0].y + q[1].y + q[2].y + q[3].y) / 4.0);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)), vec![Action::Exec("perspective.plane.set".into(), json!({"plane": "right"}))]); assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)),
vec![Action::Exec("perspective.plane.set".into(), json!({"plane": "right"}))]
);
// Drag the left vanishing point. // Drag the left vanishing point.
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, g.vp_left, g.horizon)); let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, g.vp_left, g.horizon));
assert_eq!(acts, vec![Action::Begin("Edit Perspective Grid".into())]); assert_eq!(acts, vec![Action::Begin("Edit Perspective Grid".into())]);

View File

@ -60,7 +60,11 @@ impl PuppetWarpTool {
} }
self.ids = ids; self.ids = ids;
self.selected = None; self.selected = None;
self.pins = if self.ids.is_empty() { vec![] } else { mesh_for(cx.doc, &self.ids, self.expand_or_default()).map(|m| auto_pins(&m, 3)).unwrap_or_default() }; self.pins = if self.ids.is_empty() {
vec![]
} else {
mesh_for(cx.doc, &self.ids, self.expand_or_default()).map(|m| auto_pins(&m, 3)).unwrap_or_default()
};
} }
fn expand_or_default(&self) -> f64 { fn expand_or_default(&self) -> f64 {
if self.expand > 0.0 { self.expand } else { 3.0 } if self.expand > 0.0 { self.expand } else { 3.0 }

View File

@ -0,0 +1,52 @@
//! The DrawCraft brand mark (original artwork, drawn in code): a warm diagonal-gradient rounded
//! square with a white pen nib — the same design as the app icon rendered by `cargo xtask bundle`.
use egui::epaint::{Mesh, Vertex, WHITE_UV};
use egui::{Color32, Painter, Pos2, Rect, Shape, pos2};
const TOP: Color32 = Color32::from_rgb(0xff, 0x9a, 0x3c);
const BOTTOM: Color32 = Color32::from_rgb(0xd4, 0x14, 0x5a);
fn lerp(a: Color32, b: Color32, t: f32) -> Color32 {
let m = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
Color32::from_rgb(m(a.r(), b.r()), m(a.g(), b.g()), m(a.b(), b.b()))
}
/// Paint the mark into `r` (square).
pub fn paint_mark(p: &Painter, r: Rect) {
let rad = r.width() * 0.22;
// Rounded-square outline as a fan so each vertex gets its diagonal-gradient colour.
let mut outline = vec![];
for (c, a0) in [
(pos2(r.max.x - rad, r.min.y + rad), -90.0f32),
(pos2(r.max.x - rad, r.max.y - rad), 0.0),
(pos2(r.min.x + rad, r.max.y - rad), 90.0),
(pos2(r.min.x + rad, r.min.y + rad), 180.0),
] {
for i in 0..=6 {
let a = (a0 + 90.0 * i as f32 / 6.0).to_radians();
outline.push(c + egui::vec2(a.cos(), a.sin()) * rad);
}
}
let colour = |q: Pos2| {
let t = ((q.x - r.min.x) + (q.y - r.min.y)) / (r.width() + r.height());
lerp(TOP, BOTTOM, t.clamp(0.0, 1.0))
};
let mut mesh = Mesh::default();
mesh.vertices.push(Vertex { pos: r.center(), uv: WHITE_UV, color: colour(r.center()) });
for q in &outline {
mesh.vertices.push(Vertex { pos: *q, uv: WHITE_UV, color: colour(*q) });
}
let n = outline.len() as u32;
for i in 0..n {
mesh.add_triangle(0, 1 + i, 1 + (i + 1) % n);
}
p.add(Shape::mesh(mesh));
// Pen nib (same proportions as the app icon, 1024-unit design space).
let s = r.width() / 1024.0;
let at = |x: f32, y: f32| pos2(r.min.x + x * s, r.min.y + y * s);
let nib = vec![at(512.0, 770.0), at(352.0, 540.0), at(417.0, 290.0), at(607.0, 290.0), at(672.0, 540.0)];
p.add(Shape::convex_polygon(nib, Color32::WHITE, egui::Stroke::NONE));
p.circle_filled(at(512.0, 490.0), 40.0 * s, colour(at(512.0, 490.0)));
p.line_segment([at(512.0, 525.0), at(512.0, 765.0)], egui::Stroke::new((18.0 * s).max(1.0), colour(at(512.0, 650.0))));
}

View File

@ -198,7 +198,11 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
* Affine::scale(v.zoom * ppp as f64) * Affine::scale(v.zoom * ppp as f64)
* Affine::translate(-v.center.to_vec2()); * Affine::translate(-v.center.to_vec2());
let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() };
let opts = drawcraft_render::RenderOptions { proof: drawcraft_render::proof::active_proof(), overprint_preview: drawcraft_render::proof::overprint_preview_on(), ..opts }; let opts = drawcraft_render::RenderOptions {
proof: drawcraft_render::proof::active_proof(),
overprint_preview: drawcraft_render::proof::overprint_preview_on(),
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some();
match (&mut app.canvas.worker, heavy) { match (&mut app.canvas.worker, heavy) {

View File

@ -2,7 +2,7 @@
use drawcraft_color::Paint; use drawcraft_color::Paint;
use drawcraft_doc::NodeKind; use drawcraft_doc::NodeKind;
use egui::{Color32, CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2}; use egui::{CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json; use serde_json::json;
use crate::state::{ZOOM_STOPS, zoom_label}; use crate::state::{ZOOM_STOPS, zoom_label};
@ -15,11 +15,9 @@ pub fn app_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
let left = if app.integrated_titlebar { 78 } else { 8 }; let left = if app.integrated_titlebar { 78 } else { 8 };
egui::Panel::top("app_bar").exact_size(44.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 }).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { egui::Panel::top("app_bar").exact_size(44.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 }).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
ui.horizontal_centered(|ui| { ui.horizontal_centered(|ui| {
// Brand mark: a small rounded square with "Dc". // Brand mark (our own artwork, matches the app icon).
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_rgb(0x2b, 0x1d, 0x0c)); crate::brand::paint_mark(ui.painter(), r);
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.2, Color32::from_rgb(0xff, 0x9a, 0x00)), StrokeKind::Inside);
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "Dc", theme::semibold(11.5), Color32::from_rgb(0xff, 0x9a, 0x00));
ui.add_space(4.0); ui.add_space(4.0);
if widgets::icon_button(ui, "house", "Home", false, 24.0).clicked() { if widgets::icon_button(ui, "house", "Home", false, 24.0).clicked() {
app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}))); app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"})));

View File

@ -215,6 +215,9 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(pn) = s("panel") { if let Some(pn) = s("panel") {
r = app.run("window.panel", json!({"panel": pn})); r = app.run("window.panel", json!({"panel": pn}));
} }
if let Some(st) = s("status") {
app.ui.status = st.to_string();
}
for (k, flag) in [ for (k, flag) in [
("rulers", "view.rulers"), ("rulers", "view.rulers"),
("outline", "view.outline"), ("outline", "view.outline"),

View File

@ -5,6 +5,7 @@
//! shortcuts, the ⌘K palette and the control channel ([`control`]). //! shortcuts, the ⌘K palette and the control channel ([`control`]).
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
mod brand;
pub mod canvas; pub mod canvas;
pub mod chrome; pub mod chrome;
pub mod control; pub mod control;

View File

@ -74,7 +74,8 @@ fn plates_section(app: &mut DrawcraftApp, ui: &mut Ui) {
} }
fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, extra: Option<&str>) -> Option<String> { fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, extra: Option<&str>) -> Option<String> {
let names: Vec<String> = extra.map(str::to_string).into_iter().chain(cms::profiles().into_iter().filter(|p| p.kind == kind).map(|p| p.name)).collect(); let names: Vec<String> =
extra.map(str::to_string).into_iter().chain(cms::profiles().into_iter().filter(|p| p.kind == kind).map(|p| p.name)).collect();
let refs: Vec<&str> = names.iter().map(String::as_str).collect(); let refs: Vec<&str> = names.iter().map(String::as_str).collect();
widgets::dropdown(ui, id, current, &refs, 210.0).map(|i| names[i].clone()) widgets::dropdown(ui, id, current, &refs, 210.0).map(|i| names[i].clone())
} }
@ -114,7 +115,8 @@ fn settings_section(app: &mut DrawcraftApp, ui: &mut Ui) {
match app.run("color.gamutCheck", json!({})) { match app.run("color.gamutCheck", json!({})) {
Ok(r) => { Ok(r) => {
let n = r["outOfGamut"].as_array().map_or(0, Vec::len); let n = r["outOfGamut"].as_array().map_or(0, Vec::len);
app.ui.status = if n == 0 { "All colours are within the CMYK gamut".into() } else { format!("{n} colour(s) are out of the CMYK gamut") }; app.ui.status =
if n == 0 { "All colours are within the CMYK gamut".into() } else { format!("{n} colour(s) are out of the CMYK gamut") };
} }
Err(e) => app.ui.status = e, Err(e) => app.ui.status = e,
} }

View File

@ -532,11 +532,10 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| { egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
for e in entries().iter().filter(|e| e.is_tool == tools) { for e in entries().iter().filter(|e| e.is_tool == tools) {
let sc = effective_in(&ov, &e.key); let sc = effective_in(&ov, &e.key);
if !q.is_empty() let matches = e.label.to_lowercase().contains(&q)
&& !(e.label.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q)
|| e.group.to_lowercase().contains(&q) || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q));
|| sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q))) if !q.is_empty() && !matches {
{
continue; continue;
} }
let row = ui.horizontal(|ui| { let row = ui.horizontal(|ui| {

View File

@ -435,10 +435,7 @@ mod tests {
#[test] #[test]
fn save_apply_round_trip() { fn save_apply_round_trip() {
let mut ui = UiState::default(); let mut ui = UiState { control_bar: true, dock_tab: DockTab::Layers, open_panel: Some("stroke".into()), ..Default::default() };
ui.control_bar = true;
ui.dock_tab = DockTab::Layers;
ui.open_panel = Some("stroke".into());
save_as(&mut ui, "Mine").unwrap(); save_as(&mut ui, "Mine").unwrap();
let w = find(&ui, ESSENTIALS).unwrap(); let w = find(&ui, ESSENTIALS).unwrap();
apply(&mut ui, &w); apply(&mut ui, &w);

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
docs/images/art-mesh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
docs/images/art-repeat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
docs/images/art-ribbons.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 566 KiB

BIN
docs/images/shot-1-neon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

File diff suppressed because it is too large Load Diff

14418
examples/neon-drive.drawcraft Normal file

File diff suppressed because it is too large Load Diff

2184
examples/ribbons.drawcraft Normal file

File diff suppressed because it is too large Load Diff