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>
14
ASSETS.md
|
|
@ -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 | |
|
||||||
|
|
|
||||||
44
README.md
|
|
@ -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 & 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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) };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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%.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())]);
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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))));
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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"})));
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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| {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 314 KiB |