Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa3d78a4b2 | ||
|
|
d464f28ed3 | ||
|
|
83b51ab9c7 | ||
|
|
5acc269a41 | ||
|
|
c4e20023de | ||
|
|
e9bbf43d15 | ||
|
|
1e7f5084d8 | ||
|
|
843507549f |
No files matched your search
+10
-7
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -35,7 +35,7 @@ since 2026-10-01.
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
@@ -53,9 +53,12 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
|
||||
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
|
||||
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort,
|
||||
Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at
|
||||
Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h.
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 52 filters (Artistic, Brush Strokes, Distort,
|
||||
Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery. The pixel-filter pipeline is in place
|
||||
(`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and
|
||||
Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur
|
||||
(Normal mode only) and Sharpen › Unsharp Mask; each new filter is now one function, a catalogue row, menu and
|
||||
translations. Parallelizes well across agents. 26–40 h.
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
@@ -213,7 +216,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 24% | ~52 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur and Unsharp Mask are done | 26–40 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
@@ -239,7 +242,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c
|
||||
|
||||
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
|
||||
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
|
||||
lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//!
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||
@@ -36,6 +37,7 @@ mod distort;
|
||||
mod group;
|
||||
mod live;
|
||||
mod marks;
|
||||
pub mod pixel;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
pub mod stroke;
|
||||
@@ -61,6 +63,7 @@ pub use group::{
|
||||
};
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
@@ -118,7 +121,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"distort.transform" => always(&["moveH", "moveV"]),
|
||||
"path.offsetPath" => always(&["offset"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" => always(&["radius"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
"stylize.dropShadow" => always(&["x", "y", "blur"]),
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
@@ -134,6 +137,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
|
||||
const STYLIZE: &[&str] = &["Effect", "Stylize"];
|
||||
const WARP: &[&str] = &["Effect", "Warp"];
|
||||
const BLUR: &[&str] = &["Effect", "Blur"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
|
||||
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
|
||||
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
|
||||
@@ -265,6 +269,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
),
|
||||
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r(
|
||||
"blur.radial",
|
||||
"Radial Blur…",
|
||||
BLUR,
|
||||
"{amount: 1..100 (10; spin: an arc of `amount`°, zoom: the content scaled by up to about ±amount/2 %), method: \"spin\"|\"zoom\" (\"spin\"), quality: \"draft\"|\"good\"|\"best\" (\"good\"; 16, 64 or 256 samples)} blurs around the object's centre",
|
||||
json!({"amount": 10.0, "method": "spin", "quality": "good"}),
|
||||
),
|
||||
r(
|
||||
"blur.smart",
|
||||
"Smart Blur…",
|
||||
BLUR,
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
|
||||
),
|
||||
r(
|
||||
"sharpen.unsharpMask",
|
||||
"Unsharp Mask…",
|
||||
SHARPEN,
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
];
|
||||
v.extend([
|
||||
g(
|
||||
@@ -428,6 +453,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) {
|
||||
/// Is `id` a raster (painted) effect?
|
||||
pub fn is_raster(id: &str) -> bool {
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
|
||||
|| PIXEL_EFFECTS.contains(&id)
|
||||
}
|
||||
|
||||
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Blur › Radial Blur and Smart Blur.
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::{PixelSpace, Px16, box_blur, to8, to16};
|
||||
|
||||
/// Zoom's total spread, in log scale: `amount` 100 scales the content between 1/√2 and √2.
|
||||
pub(super) fn zoom_extent(amount: f64) -> f64 {
|
||||
(1.0 + amount / 100.0).ln()
|
||||
}
|
||||
|
||||
/// `buf` (`w` × `h`) sampled bilinearly at pixel index position (`x`, `y`) (pixel centres at
|
||||
/// whole coordinates); transparent outside.
|
||||
fn bilinear(buf: &[Px16], w: usize, h: usize, x: f64, y: f64) -> [f32; 4] {
|
||||
if !(x > -1.0 && y > -1.0 && x < w as f64 && y < h as f64) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
let (fx, fy) = ((x - x0) as f32, (y - y0) as f32);
|
||||
let (xi, yi) = (x0 as isize, y0 as isize);
|
||||
let at = |cx: isize, cy: isize| -> [f32; 4] {
|
||||
if cx < 0 || cy < 0 || cx as usize >= w || cy as usize >= h {
|
||||
return [0.0; 4];
|
||||
}
|
||||
buf.get(cy as usize * w + cx as usize).map_or([0.0; 4], |p| p.map(f32::from))
|
||||
};
|
||||
let (a, b, c, d) = (at(xi, yi), at(xi + 1, yi), at(xi, yi + 1), at(xi + 1, yi + 1));
|
||||
let (wa, wb, wc, wd) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy);
|
||||
std::array::from_fn(|i| a[i] * wa + b[i] * wb + c[i] * wc + d[i] * wd)
|
||||
}
|
||||
|
||||
/// Radial Blur: the average of the content turned (spin) or scaled (zoom) about the centre over
|
||||
/// evenly spaced steps spanning `amount`. Each pass averages the previous result with itself
|
||||
/// turned both ways by half the last step, so `passes` passes average 2^`passes` samples at two
|
||||
/// lookups a pixel each.
|
||||
pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, amount: f64, zoom: bool, passes: u32) {
|
||||
// The centre in pixel index coordinates (pixel centres at whole numbers).
|
||||
let c = space.to_doc.inverse() * space.center - Vec2::new(0.5, 0.5);
|
||||
if !(c.x.is_finite() && c.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let total = if zoom { zoom_extent(amount) } else { amount.to_radians() };
|
||||
let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2());
|
||||
// A pixel's reflection in the view (one axis flipped) only turns the spin the other way,
|
||||
// which a symmetric blur doesn't notice.
|
||||
let step = |d: f64| if zoom { about(Affine::scale(d.exp())) } else { about(Affine::rotate(d)) };
|
||||
let mut cur = to16(px);
|
||||
let mut next = vec![[0u16; 4]; cur.len()];
|
||||
for j in 1..=passes.min(12) {
|
||||
let d = total / f64::powi(2.0, j as i32 + 1);
|
||||
let (fwd, back) = (step(d), step(-d));
|
||||
// Where a pixel's two samples lie moves by a fixed step along a row.
|
||||
let (df, db) = (fwd * Point::new(1.0, 0.0) - fwd * Point::ZERO, back * Point::new(1.0, 0.0) - back * Point::ZERO);
|
||||
for (y, row) in next.chunks_exact_mut(w).enumerate().take(h) {
|
||||
let (mut f, mut b) = (fwd * Point::new(0.0, y as f64), back * Point::new(0.0, y as f64));
|
||||
for out in row.iter_mut() {
|
||||
let (s, t) = (bilinear(&cur, w, h, f.x, f.y), bilinear(&cur, w, h, b.x, b.y));
|
||||
*out = std::array::from_fn(|i| ((s[i] + t[i]) * 0.5).round().clamp(0.0, 65535.0) as u16);
|
||||
(f, b) = (f + df, b + db);
|
||||
}
|
||||
}
|
||||
std::mem::swap(&mut cur, &mut next);
|
||||
}
|
||||
for (p, q) in px.iter_mut().zip(&cur) {
|
||||
*p = q.map(to8);
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest radius (pixels) Smart Blur looks over: farther neighbours are as good as gone.
|
||||
const MAX_SMART: f64 = 4096.0;
|
||||
|
||||
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
|
||||
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
|
||||
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
|
||||
/// over the gaps between them so the blur stays smooth (a neighbour counts when both it and its
|
||||
/// blurred surroundings are within the threshold, so at most a fringe under the threshold crosses
|
||||
/// an edge).
|
||||
pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32) {
|
||||
if radius.is_nan() || radius < 0.5 {
|
||||
return;
|
||||
}
|
||||
let r = radius.min(MAX_SMART);
|
||||
let n = samples.clamp(3, 15) as f64;
|
||||
let k = r.round();
|
||||
let (offsets, prefilter): (Vec<Vec2>, usize) = if 2.0 * k < n {
|
||||
let k = k as i32;
|
||||
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
|
||||
} else {
|
||||
let gap = 2.0 * r / (n - 1.0);
|
||||
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
|
||||
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
|
||||
};
|
||||
let offsets: Vec<(isize, isize)> =
|
||||
offsets.into_iter().filter(|o| o.hypot() <= r + 0.5 && *o != Vec2::ZERO).map(|o| (o.x as isize, o.y as isize)).collect();
|
||||
let src = to16(px);
|
||||
let blurred;
|
||||
let near: &[Px16] = if prefilter > 0 {
|
||||
let mut b = src.clone();
|
||||
box_blur(&mut b, w, prefilter);
|
||||
blurred = b;
|
||||
&blurred
|
||||
} else {
|
||||
&src
|
||||
};
|
||||
let t = (threshold.clamp(0.0, 255.0) * 257.0) as i32;
|
||||
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
|
||||
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
|
||||
let mut acc = c.map(u64::from);
|
||||
let mut count = 1u64;
|
||||
for (dx, dy) in &offsets {
|
||||
let (sx, sy) = (x as isize + dx, y as isize + dy);
|
||||
if sx < 0 || sy < 0 || sx as usize >= w || sy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
let i = sy as usize * w + sx as usize;
|
||||
let (Some(s), Some(own)) = (near.get(i), src.get(i)) else { continue };
|
||||
// The neighbour itself and its surroundings both close enough: edges stay sharp.
|
||||
let close = |p: &Px16| p.iter().zip(c).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t);
|
||||
if close(own) && close(s) {
|
||||
for (a, v) in acc.iter_mut().zip(s) {
|
||||
*a += *v as u64;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
*o = acc.map(|a| to8(((a + count / 2) / count).min(65535) as u16));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp
|
||||
//! Mask): filters over the pixels of an object rendered alone, premultiplied RGBA8.
|
||||
//!
|
||||
//! - Every distance is in document units (points) and becomes pixels through the raster's
|
||||
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
|
||||
//! Settings resolution (as Gaussian Blur's radius does).
|
||||
//! - The work per pixel is bounded whatever the parameters: blurs are box approximations whose
|
||||
//! cost doesn't grow with the radius, and sample counts come from the effect's quality.
|
||||
//! - Beyond the raster's edges is transparency.
|
||||
|
||||
mod blur;
|
||||
mod sharpen;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::util::{num, text};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 3] = ["blur.radial", "blur.smart", "sharpen.unsharpMask"];
|
||||
|
||||
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
|
||||
/// are in document units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum PixelFx {
|
||||
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
|
||||
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
|
||||
RadialBlur { amount: f64, zoom: bool, passes: u32 },
|
||||
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
|
||||
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
|
||||
/// `samples` × `samples` neighbours at most.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32 },
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
}
|
||||
|
||||
/// Where a raster's pixels lie in the document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PixelSpace {
|
||||
/// Pixel coordinates (x right, y down, (0, 0) the top-left corner of the first pixel) →
|
||||
/// document coordinates.
|
||||
pub to_doc: Affine,
|
||||
/// The size of a pixel in document units.
|
||||
pub px: f64,
|
||||
/// The object's centre (document coordinates): where Radial Blur blurs around.
|
||||
pub center: Point,
|
||||
}
|
||||
|
||||
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
|
||||
pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
let quality = |levels: [(&str, u32); 3], default: u32| {
|
||||
let q = text(p, "quality", "");
|
||||
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
|
||||
};
|
||||
Some(match id {
|
||||
"blur.radial" => PixelFx::RadialBlur {
|
||||
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
|
||||
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
|
||||
passes: quality([("draft", 4), ("good", 6), ("best", 8)], 6),
|
||||
},
|
||||
"blur.smart" => PixelFx::SmartBlur {
|
||||
radius: num(p, "radius", 3.0).clamp(0.1, 100.0),
|
||||
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
|
||||
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
|
||||
},
|
||||
"sharpen.unsharpMask" => PixelFx::UnsharpMask {
|
||||
amount: num(p, "amount", 50.0).clamp(1.0, 500.0) / 100.0,
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl PixelFx {
|
||||
/// How far beyond `bounds` (the content's, document units) the effect can paint.
|
||||
pub fn outset(&self, bounds: Rect) -> f64 {
|
||||
// From the centre to the farthest corner, and to the nearest side.
|
||||
let far = 0.5 * bounds.width().hypot(bounds.height());
|
||||
let near = 0.5 * bounds.width().min(bounds.height());
|
||||
match *self {
|
||||
// A rotated corner reaches the circle through the corners.
|
||||
PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0),
|
||||
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
|
||||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (document units) from a pixel the content that decides it lies; `None` when that
|
||||
/// is anywhere in the object (Radial Blur).
|
||||
pub fn reach(&self) -> Option<f64> {
|
||||
match *self {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, .. } => Some(radius),
|
||||
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the effect on `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as
|
||||
/// `space` says. Does nothing when the sizes don't match.
|
||||
pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace) {
|
||||
let Some(px) = pixels(data, w, h) else { return };
|
||||
let to_px = |len: f64| len / space.px.max(1e-9);
|
||||
match *self {
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
|
||||
fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> {
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
(w > 0 && h > 0 && rest.is_empty() && w.checked_mul(h) == Some(px.len())).then_some(px)
|
||||
}
|
||||
|
||||
/// The largest box radius the blurs use: wider boxes look the same on any raster we make.
|
||||
const MAX_BOX: usize = 1 << 17;
|
||||
|
||||
/// Radii of three box blurs approximating a Gaussian of `sigma` pixels.
|
||||
pub fn gauss_boxes(sigma: f64) -> [usize; 3] {
|
||||
let sigma = if sigma.is_finite() { sigma.clamp(0.0, MAX_BOX as f64 / 2.0) } else { 0.0 };
|
||||
let n = 3.0;
|
||||
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
|
||||
let mut wl = w_ideal.floor() as i64;
|
||||
if wl % 2 == 0 {
|
||||
wl -= 1;
|
||||
}
|
||||
let wu = wl + 2;
|
||||
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
|
||||
let m = m_ideal.round() as i64;
|
||||
let r = |i: i64| ((((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).min(MAX_BOX);
|
||||
[r(0), r(1), r(2)]
|
||||
}
|
||||
|
||||
/// One horizontal box blur of radius `r` over the rows of `w` values of a plane (running sum,
|
||||
/// zero past the ends).
|
||||
fn box_plane(src: &[f32], dst: &mut [f32], w: usize, r: usize) {
|
||||
if r == 0 {
|
||||
dst.copy_from_slice(src);
|
||||
return;
|
||||
}
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
let mut acc: f32 = row.iter().take(r + 1).sum();
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = acc * norm;
|
||||
if let Some(v) = row.get(x + r + 1) {
|
||||
acc += v;
|
||||
}
|
||||
if let Some(v) = x.checked_sub(r).and_then(|i| row.get(i)) {
|
||||
acc -= v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose<T: Copy>(src: &[T], dst: &mut [T], w: usize, h: usize) {
|
||||
for (y, row) in src.chunks_exact(w).enumerate().take(h) {
|
||||
for (x, v) in row.iter().enumerate() {
|
||||
if let Some(d) = dst.get_mut(x * h + y) {
|
||||
*d = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-blur a `w` × `h` plane of values in place (three box passes per axis, zero past the
|
||||
/// edges). Does nothing when the sizes don't match.
|
||||
pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
if sigma < 0.2 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
|
||||
return;
|
||||
}
|
||||
let boxes = gauss_boxes(sigma);
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for (len, other) in [(w, h), (h, w)] {
|
||||
for r in boxes {
|
||||
box_plane(a, &mut tmp, len, r);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
transpose(a, &mut tmp, len, other);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
pub(super) fn to16(px: &[[u8; 4]]) -> Vec<Px16> {
|
||||
px.iter().map(|p| p.map(|v| v as u16 * 257)).collect()
|
||||
}
|
||||
|
||||
pub(super) fn to8(v: u16) -> u8 {
|
||||
((v as u32 + 128) / 257).min(255) as u8
|
||||
}
|
||||
|
||||
fn add(acc: &mut [u64; 4], p: &Px16) {
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a += *v as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn sub(acc: &mut [u64; 4], p: &Px16) {
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a = a.saturating_sub(*v as u64);
|
||||
}
|
||||
}
|
||||
|
||||
fn mean(acc: &[u64; 4], n: u64) -> Px16 {
|
||||
acc.map(|a| ((a + n / 2) / n.max(1)).min(65535) as u16)
|
||||
}
|
||||
|
||||
/// One box blur of radius `r` along the rows of `w` pixels (transparent past the ends).
|
||||
fn box_rows(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) {
|
||||
let n = 2 * r as u64 + 1;
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
let mut acc = [0u64; 4];
|
||||
row.iter().take(r + 1).for_each(|p| add(&mut acc, p));
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = mean(&acc, n);
|
||||
if let Some(p) = row.get(x + r + 1) {
|
||||
add(&mut acc, p);
|
||||
}
|
||||
if let Some(p) = x.checked_sub(r).and_then(|i| row.get(i)) {
|
||||
sub(&mut acc, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One box blur of radius `r` down the columns of `w`-wide rows (transparent past the ends), row
|
||||
/// by row with a running sum per column.
|
||||
fn box_cols(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) {
|
||||
let n = 2 * r as u64 + 1;
|
||||
let rows: Vec<&[Px16]> = src.chunks_exact(w).collect();
|
||||
let mut acc = vec![[0u64; 4]; w];
|
||||
let add_row = |acc: &mut [[u64; 4]], row: &[Px16]| acc.iter_mut().zip(row).for_each(|(a, p)| add(a, p));
|
||||
rows.iter().take(r + 1).for_each(|row| add_row(&mut acc, row));
|
||||
for (y, out) in dst.chunks_exact_mut(w).enumerate() {
|
||||
for (o, a) in out.iter_mut().zip(&acc) {
|
||||
*o = mean(a, n);
|
||||
}
|
||||
if let Some(row) = rows.get(y + r + 1) {
|
||||
add_row(&mut acc, row);
|
||||
}
|
||||
if let Some(row) = y.checked_sub(r).and_then(|i| rows.get(i)) {
|
||||
acc.iter_mut().zip(*row).for_each(|(a, p)| sub(a, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Box-blur `w`-wide rows of pixels in place with radius `r` along both axes.
|
||||
pub(super) fn box_blur(px: &mut [Px16], w: usize, r: usize) {
|
||||
if r == 0 || w == 0 {
|
||||
return;
|
||||
}
|
||||
let mut tmp = vec![[0u16; 4]; px.len()];
|
||||
box_rows(px, &mut tmp, w, r);
|
||||
box_cols(&tmp, px, w, r);
|
||||
}
|
||||
|
||||
/// Gaussian-blur `w`-wide rows of premultiplied pixels in place (σ = `sigma` pixels, three box
|
||||
/// passes per axis, transparent past the edges).
|
||||
pub(super) fn gaussian(px: &mut [Px16], w: usize, sigma: f64) {
|
||||
if sigma < 0.2 || w == 0 {
|
||||
return;
|
||||
}
|
||||
let mut tmp = vec![[0u16; 4]; px.len()];
|
||||
for r in gauss_boxes(sigma) {
|
||||
box_rows(px, &mut tmp, w, r);
|
||||
px.copy_from_slice(&tmp);
|
||||
}
|
||||
for r in gauss_boxes(sigma) {
|
||||
box_cols(px, &mut tmp, w, r);
|
||||
px.copy_from_slice(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-blur `data`, `w` × `h` premultiplied RGBA8 pixels, in place (σ = `sigma` pixels). Does
|
||||
/// nothing when the sizes don't match.
|
||||
pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
|
||||
if let Some(px) = pixels(data, w, h)
|
||||
&& sigma >= 0.2
|
||||
{
|
||||
let mut wide = to16(px);
|
||||
gaussian(&mut wide, w, sigma);
|
||||
for (p, q) in px.iter_mut().zip(&wide) {
|
||||
*p = q.map(to8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Sharpen › Unsharp Mask.
|
||||
|
||||
use super::{gaussian, to16};
|
||||
|
||||
/// Unsharp Mask: each colour channel pushed away from a Gaussian blur of the pixels (σ = `sigma`
|
||||
/// pixels) by `amount` times their difference, where that difference reaches `threshold` levels.
|
||||
/// The blur is premultiplied, so edges against transparency don't darken; coverage stays as it is.
|
||||
pub(super) fn unsharp(px: &mut [[u8; 4]], w: usize, h: usize, amount: f64, sigma: f64, threshold: f64) {
|
||||
if sigma.is_nan() || sigma < 0.2 || amount.is_nan() || amount <= 0.0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let mut blurred = to16(px);
|
||||
gaussian(&mut blurred, w, sigma);
|
||||
let (amount, threshold) = (amount as f32, threshold as f32);
|
||||
for (p, b) in px.iter_mut().zip(&blurred) {
|
||||
let a = p[3] as f32;
|
||||
if a == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for i in 0..3 {
|
||||
let own = p[i] as f32 * 255.0 / a;
|
||||
let around = if b[3] > 0 { b[i] as f32 * 255.0 / b[3] as f32 } else { own };
|
||||
let diff = own - around;
|
||||
if diff.abs() < threshold || diff == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let v = (own + amount * diff).clamp(0.0, 255.0);
|
||||
p[i] = (v * a / 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
use crate::{RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left).
|
||||
fn space(w: usize, h: usize) -> PixelSpace {
|
||||
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0) }
|
||||
}
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
out.extend(f(x, y));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
|
||||
let i = (y * w + x) * 4;
|
||||
[d[i], d[i + 1], d[i + 2], d[i + 3]]
|
||||
}
|
||||
|
||||
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
let e = Effect { id: id.into(), params: p, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(p)) => *p,
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("blur.radial", json!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6 });
|
||||
assert_eq!(
|
||||
fx("blur.radial", json!({"amount": 1e308, "method": "ZOOM", "quality": "best"})),
|
||||
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
|
||||
);
|
||||
assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 });
|
||||
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7 });
|
||||
assert_eq!(
|
||||
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})),
|
||||
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 }
|
||||
);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 });
|
||||
assert_eq!(
|
||||
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
|
||||
PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 }
|
||||
);
|
||||
for id in PIXEL_EFFECTS {
|
||||
assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outsets_follow_the_object() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let spin = fx("blur.radial", json!({}));
|
||||
let far = 0.5 * 100f64.hypot(50.0);
|
||||
assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9);
|
||||
assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far);
|
||||
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0);
|
||||
assert_eq!(spin.reach(), None);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_sizes_and_empty_rasters_are_left_alone() {
|
||||
for id in PIXEL_EFFECTS {
|
||||
let f = fx(id, json!({}));
|
||||
let mut d = vec![200u8; 4 * 10];
|
||||
f.apply(&mut d, 3, 3, &space(3, 3));
|
||||
f.apply(&mut d, 0, 10, &space(0, 10));
|
||||
f.apply(&mut [], 0, 0, &space(0, 0));
|
||||
assert!(d.iter().all(|v| *v == 200), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_parameters_finish() {
|
||||
let (w, h) = (40, 30);
|
||||
let src = image(w, h, |x, y| if (x / 5 + y / 5) % 2 == 0 { grey(250) } else { [0, 0, 0, 0] });
|
||||
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
|
||||
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
|
||||
for id in PIXEL_EFFECTS {
|
||||
for p in [json!({"amount": 1e308, "radius": 1e308, "threshold": 1e308, "quality": "best"}), json!({"amount": 0, "radius": 0})] {
|
||||
for s in [tiny, huge, space(w, h)] {
|
||||
let mut d = src.clone();
|
||||
fx(id, p.clone()).apply(&mut d, w, h, &s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_effect_is_deterministic() {
|
||||
let (w, h) = (32, 24);
|
||||
let src = image(w, h, |x, y| {
|
||||
let a = if x > 4 { 255 } else { 128 };
|
||||
[((x * 8) as u8).min(a), ((y * 10) as u8).min(a), ((x * y % 256) as u8).min(a), a]
|
||||
});
|
||||
for id in PIXEL_EFFECTS {
|
||||
let f = fx(id, json!({"amount": 40, "radius": 3, "threshold": 40}));
|
||||
let (mut a, mut b) = (src.clone(), src.clone());
|
||||
f.apply(&mut a, w, h, &space(w, h));
|
||||
f.apply(&mut b, w, h, &space(w, h));
|
||||
assert_eq!(a, b, "{id}");
|
||||
assert_ne!(a, src, "{id} changes the image");
|
||||
// Premultiplied stays premultiplied.
|
||||
assert!(a.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_blur_smears_along_arcs_and_keeps_the_centre() {
|
||||
let (w, h) = (41, 41);
|
||||
// A white dot 12 px right of the centre (20.5, 20.5), and one on the centre.
|
||||
let src = image(w, h, |x, y| if (x == 32 || x == 20) && y == 20 { grey(255) } else { [0, 0, 0, 0] });
|
||||
let mut d = src.clone();
|
||||
fx("blur.radial", json!({"amount": 60, "quality": "best"})).apply(&mut d, w, h, &space(w, h));
|
||||
// 60° of arc around the centre: up to 30° either way, 12 px out: (20.5 + 12 cos 20°, 20.5 ± 12 sin 20°).
|
||||
assert!(at(&d, w, 31, 16)[3] > 0 && at(&d, w, 31, 24)[3] > 0, "the dot spreads along its circle");
|
||||
assert_eq!(at(&d, w, 32, 10)[3], 0, "not beyond the arc");
|
||||
assert_eq!(at(&d, w, 26, 20)[3], 0, "not towards the centre");
|
||||
assert!(at(&d, w, 20, 20)[3] > 100, "the centre barely moves: {:?}", at(&d, w, 20, 20));
|
||||
// Zoom smears along the ray instead.
|
||||
let mut z = src.clone();
|
||||
fx("blur.radial", json!({"amount": 60, "method": "zoom", "quality": "best"})).apply(&mut z, w, h, &space(w, h));
|
||||
assert!(at(&z, w, 35, 20)[3] > 0 && at(&z, w, 29, 20)[3] > 0, "the dot streaks along the ray");
|
||||
assert_eq!(at(&z, w, 32, 16)[3], 0, "not across it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_smooths_noise_and_keeps_edges() {
|
||||
let (w, h) = (40, 20);
|
||||
// Left: grey 100 with ±6 noise; right: white.
|
||||
let src = image(w, h, |x, y| if x < 20 { grey(if (x + y) % 2 == 0 { 94 } else { 106 }) } else { grey(255) });
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"radius": 3, "threshold": 25})).apply(&mut d, w, h, &space(w, h));
|
||||
let v = at(&d, w, 8, 10)[0];
|
||||
assert!((97..=103).contains(&v), "noise smoothed: {v}");
|
||||
assert!(at(&d, w, 19, 10)[0] < 120, "the edge's dark side doesn't take the white");
|
||||
assert_eq!(at(&d, w, 20, 10)[0], 255, "the edge's white side stays white");
|
||||
// A threshold above the step blurs across it.
|
||||
let mut all = src.clone();
|
||||
fx("blur.smart", json!({"radius": 3, "threshold": 100})).apply(&mut all, w, h, &space(w, h));
|
||||
assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsharp_mask_overshoots_at_edges() {
|
||||
let (w, h) = (40, 10);
|
||||
let src = image(w, h, |x, _| grey(if x < 20 { 100 } else { 150 }));
|
||||
let mut d = src.clone();
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, w, h, &space(w, h));
|
||||
assert!(at(&d, w, 19, 5)[0] < 95, "dark side darker: {:?}", at(&d, w, 19, 5));
|
||||
assert!(at(&d, w, 20, 5)[0] > 155, "light side lighter: {:?}", at(&d, w, 20, 5));
|
||||
assert_eq!(at(&d, w, 5, 5), grey(100), "flat areas stay");
|
||||
assert_eq!(at(&d, w, 35, 5), grey(150));
|
||||
// Differences under the threshold are left alone.
|
||||
let mut t = src.clone();
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2, "threshold": 60})).apply(&mut t, w, h, &space(w, h));
|
||||
assert_eq!(t, src);
|
||||
// Coverage stays, and a shape's edge against transparency doesn't darken.
|
||||
let shape = image(w, h, |x, _| if x < 20 { [200, 0, 0, 255] } else { [0, 0, 0, 0] });
|
||||
let mut s = shape.clone();
|
||||
fx("sharpen.unsharpMask", json!({"amount": 300, "radius": 3})).apply(&mut s, w, h, &space(w, h));
|
||||
assert_eq!(s, shape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_are_document_units() {
|
||||
// The same art at twice the resolution, sharpened with the same radius in points: the
|
||||
// overshoot spans twice the pixels, the same distance.
|
||||
let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 }));
|
||||
let run = |scale: usize| {
|
||||
let mut d = edge(scale);
|
||||
let s = PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO };
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s);
|
||||
(0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count()
|
||||
};
|
||||
let (one, two) = (run(1), run(2));
|
||||
assert!(one > 1 && (two as f64 / one as f64 - 2.0).abs() < 0.5, "{one} px at 1×, {two} px at 2×");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaussian_and_plane_blurs_keep_their_mass() {
|
||||
let (w, h) = (30, 30);
|
||||
let mut d = image(w, h, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { grey(255) } else { [0, 0, 0, 0] });
|
||||
let before: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
|
||||
gaussian_rgba(&mut d, w, h, 2.0);
|
||||
let after: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
|
||||
assert!((before as f64 - after as f64).abs() / (before as f64) < 0.02, "{before} → {after}");
|
||||
assert!(at(&d, w, 9, 15)[3] > 0 && at(&d, w, 10, 15)[3] < 255);
|
||||
let mut plane: Vec<f32> = (0..w * h).map(|i| if i == 15 * w + 15 { 100.0 } else { 0.0 }).collect();
|
||||
blur_plane(&mut plane, w, h, 2.0);
|
||||
assert!((plane.iter().sum::<f32>() - 100.0).abs() < 0.5 && plane[15 * w + 13] > 0.0 && plane[13 * w + 15] > 0.0);
|
||||
}
|
||||
|
||||
/// Timings on a 2000 × 2000 raster (`cargo test -p vectorcraft-effects --release -- --ignored
|
||||
/// --nocapture pixel_timings`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn pixel_timings() {
|
||||
let (w, h) = (2000, 2000);
|
||||
let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]);
|
||||
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0) };
|
||||
let cases = [
|
||||
("blur.radial", json!({"quality": "draft"})),
|
||||
("blur.radial", json!({"quality": "good"})),
|
||||
("blur.radial", json!({"quality": "best", "method": "zoom"})),
|
||||
("blur.smart", json!({"quality": "low"})),
|
||||
("blur.smart", json!({"quality": "high", "radius": 30})),
|
||||
("sharpen.unsharpMask", json!({"radius": 20})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let mut d = src.clone();
|
||||
let t = std::time::Instant::now();
|
||||
fx(id, p.clone()).apply(&mut d, w, h, &s);
|
||||
println!("{id} {p}: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ use serde_json::Value;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_doc::color::{BlendMode, Color};
|
||||
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::merged_params;
|
||||
use crate::pixel::{self, PixelFx};
|
||||
use crate::util::*;
|
||||
|
||||
/// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses
|
||||
@@ -40,6 +43,8 @@ pub enum RasterFx {
|
||||
GaussianBlur {
|
||||
radius: f64,
|
||||
},
|
||||
/// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…).
|
||||
Pixel(PixelFx),
|
||||
}
|
||||
|
||||
impl RasterFx {
|
||||
@@ -47,13 +52,15 @@ impl RasterFx {
|
||||
pub fn is_below(&self) -> bool {
|
||||
matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. })
|
||||
}
|
||||
/// How far this effect reaches beyond the geometry.
|
||||
pub fn outset(&self) -> f64 {
|
||||
/// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches
|
||||
/// further around larger objects).
|
||||
pub fn outset(&self, bounds: Rect) -> f64 {
|
||||
match self {
|
||||
RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur,
|
||||
RasterFx::OuterGlow { blur, .. } => 1.5 * blur,
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
|
||||
RasterFx::GaussianBlur { radius } => 1.5 * radius,
|
||||
RasterFx::Pixel(p) => p.outset(bounds),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,14 +120,14 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
},
|
||||
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
_ => return None,
|
||||
id => RasterFx::Pixel(pixel::parse(id, &p)?),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far the visible raster effects of `effects` paint beyond the (effected) geometry, in
|
||||
/// document units. Geometry effects are accounted for by evaluating them.
|
||||
pub fn outset(effects: &[Effect]) -> f64 {
|
||||
raster_effects(effects).iter().map(RasterFx::outset).fold(0.0, f64::max)
|
||||
/// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected)
|
||||
/// geometry's, in document units. Geometry effects are accounted for by evaluating them.
|
||||
pub fn outset(effects: &[Effect], bounds: Rect) -> f64 {
|
||||
raster_effects(effects).iter().map(|fx| fx.outset(bounds)).fold(0.0, f64::max)
|
||||
}
|
||||
@@ -301,8 +301,8 @@ fn raster_effects_parse_and_outset() {
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(matches!(r[2], RasterFx::InnerGlow { center: true, .. }));
|
||||
assert!(close(outset(&list), 10.0 + 9.0, 1e-9));
|
||||
assert_eq!(outset(&[]), 0.0);
|
||||
assert!(close(outset(&list, vectorcraft_geom::Rect::ZERO), 10.0 + 9.0, 1e-9));
|
||||
assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -170,8 +170,10 @@ impl Session {
|
||||
return None;
|
||||
}
|
||||
let d = self.clipboard.to_document();
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = super::rasterfx::flatten_pixel_effects(&d);
|
||||
Some(vectorcraft_svg::export(
|
||||
&d,
|
||||
flat.as_ref().unwrap_or(&d),
|
||||
&vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() },
|
||||
))
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste in Front",
|
||||
["Edit"],
|
||||
Some("Cmd+F"),
|
||||
"{swatchConflict?} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
"{swatchConflict?, artboard?: index (the active artboard: the objects go where they were on the artboard they were copied from, on this one)} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Front)
|
||||
),
|
||||
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste in Back",
|
||||
["Edit"],
|
||||
Some("Cmd+B"),
|
||||
"{swatchConflict?} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Back)
|
||||
),
|
||||
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste in Place",
|
||||
["Edit"],
|
||||
Some("Cmd+Shift+V"),
|
||||
"{swatchConflict?} paste where the objects were copied (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste where the objects were copied (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::InPlace)
|
||||
),
|
||||
@@ -310,6 +310,17 @@ fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the
|
||||
/// objects were on the artboard they were copied from, on that one; without it (or a copy from no
|
||||
/// artboard), where they were.
|
||||
fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
|
||||
let target = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| doc.artboards.get(i));
|
||||
match (target, clip.source_artboard) {
|
||||
(Some(t), Some(src)) => Affine::translate(t.rect.origin() - src.origin()),
|
||||
_ => Affine::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
|
||||
let off = s.prefs.paste_offset;
|
||||
let st = s.doc()?;
|
||||
@@ -345,7 +356,8 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let src = clip.source_artboard.or_else(|| st.doc.artboards.first().map(|a| a.rect)).map(|r| r.origin()).unwrap_or_default();
|
||||
st.doc.artboards.iter().map(|a| Affine::translate(a.rect.origin() - src)).collect()
|
||||
}
|
||||
_ => vec![Affine::IDENTITY],
|
||||
// In place, in front, in back: onto the active artboard when the UI names it.
|
||||
_ => vec![onto_artboard(&st.doc, p, clip)],
|
||||
};
|
||||
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
|
||||
@@ -153,6 +153,9 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
let (opts, boards, chosen) = plan(doc, p)?;
|
||||
// Preserve editing embeds the native document (once, shared by every file).
|
||||
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded::default();
|
||||
for artboard in boards {
|
||||
// A chosen artboard's file holds the art over it (#550).
|
||||
|
||||
@@ -1265,11 +1265,11 @@ fn reach(n: &Node) -> Option<Rect> {
|
||||
if !n.visible {
|
||||
return None;
|
||||
}
|
||||
let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&n.appearance.effects), f64::max);
|
||||
let b = match n.children() {
|
||||
Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?,
|
||||
_ => n.visual_bounds()?,
|
||||
};
|
||||
let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max);
|
||||
Some(b.inflate(fx + 1.0, fx + 1.0))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
//! effect: the image (with the object's own area knocked out) goes under the untouched vector
|
||||
//! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it
|
||||
//! with the image. Expand Appearance makes its images with the same helpers.
|
||||
//!
|
||||
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
|
||||
//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those
|
||||
//! into images the same way ([`flatten_pixel_effects`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -167,7 +171,7 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
||||
// effects reach beyond that.
|
||||
let b = vectorcraft_render::painted_bounds(whole)?;
|
||||
let look = out.raster_effects.clone();
|
||||
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(0.0, f64::max) + 2.0 + look.add_around;
|
||||
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
|
||||
let b = b.inflate(reach, reach);
|
||||
// Whole pixels, and no larger than 64 Mpx.
|
||||
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
|
||||
@@ -337,20 +341,39 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
|
||||
Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() })
|
||||
}
|
||||
|
||||
/// Does `n` hold raster effects PDF export draws: its own, its fills' and strokes', its opacity
|
||||
/// mask's or (groups and layers) its members'? Hidden objects and template layers aren't drawn.
|
||||
fn needs(n: &Node) -> bool {
|
||||
/// Which objects' raster effects become images.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Which {
|
||||
/// Every raster effect (PDF).
|
||||
All,
|
||||
/// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects).
|
||||
Pixel,
|
||||
}
|
||||
|
||||
/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image?
|
||||
fn imaged(n: &Node, which: Which) -> bool {
|
||||
let fx = raster_fx(n);
|
||||
match which {
|
||||
Which::All => !fx.is_empty(),
|
||||
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n` hold raster effects that `which` turns into images: its own, its fills' and strokes',
|
||||
/// its opacity mask's or (groups and layers) its members'? Hidden objects and template layers
|
||||
/// aren't drawn.
|
||||
fn needs(n: &Node, which: Which) -> bool {
|
||||
n.visible
|
||||
&& !matches!(n.kind, NodeKind::Layer { template: true, .. })
|
||||
&& (!raster_fx(n).is_empty()
|
||||
|| n.mask.as_ref().is_some_and(|m| needs(&m.art))
|
||||
|| (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c)))))
|
||||
&& (imaged(n, which)
|
||||
|| n.mask.as_ref().is_some_and(|m| needs(&m.art, which))
|
||||
|| (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c, which)))))
|
||||
}
|
||||
|
||||
/// The art of `n`'s opacity mask with its raster effects as images.
|
||||
fn walk_mask(out: &mut Document, n: &mut Node) {
|
||||
fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
|
||||
if let Some(m) = n.mask.as_mut()
|
||||
&& let Some(art) = walk(out, &m.art)
|
||||
&& let Some(art) = walk(out, &m.art, which)
|
||||
{
|
||||
m.art = Arc::new(art);
|
||||
}
|
||||
@@ -359,15 +382,15 @@ fn walk_mask(out: &mut Document, n: &mut Node) {
|
||||
/// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it
|
||||
/// has none. An object whose effects all paint below it keeps its vector art over an image of
|
||||
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
|
||||
fn walk(out: &mut Document, n: &Node) -> Option<Node> {
|
||||
if !needs(n) {
|
||||
fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
|
||||
if !needs(n, which) {
|
||||
return None;
|
||||
}
|
||||
if raster_fx(n).is_empty() {
|
||||
if !imaged(n, which) {
|
||||
let mut m = n.clone();
|
||||
walk_mask(out, &mut m);
|
||||
walk_mask(out, &mut m, which);
|
||||
if is_container(&m)
|
||||
&& let Some(ch) = walk_all(out, n.children()?)
|
||||
&& let Some(ch) = walk_all(out, n.children()?, which)
|
||||
&& let Some(slot) = m.children_mut()
|
||||
{
|
||||
*slot = ch;
|
||||
@@ -375,30 +398,30 @@ fn walk(out: &mut Document, n: &Node) -> Option<Node> {
|
||||
return Some(m);
|
||||
}
|
||||
if let Some(g) = placed_symbol(out, n) {
|
||||
return Some(walk(out, &g).unwrap_or(g));
|
||||
return Some(walk(out, &g, which).unwrap_or(g));
|
||||
}
|
||||
let below = below_only(n);
|
||||
if !below && let Some(g) = split_items(out, n) {
|
||||
return Some(walk(out, &g).unwrap_or(g));
|
||||
return Some(walk(out, &g, which).unwrap_or(g));
|
||||
}
|
||||
// An image that can't be made leaves the object as it is (the writer reports its effects).
|
||||
let image = raster_image(out, n, below, false)?;
|
||||
let mut v = n.clone();
|
||||
strip_raster(&mut v, false);
|
||||
let mut m = if below {
|
||||
let mut m = walk(out, &v).unwrap_or(v);
|
||||
let mut m = walk(out, &v, which).unwrap_or(v);
|
||||
put_below(out, &mut m, image);
|
||||
m
|
||||
} else {
|
||||
replace_with_image(v, image)
|
||||
};
|
||||
walk_mask(out, &mut m);
|
||||
walk_mask(out, &mut m, which);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// `nodes` with their raster effects as images, `None` when none has any.
|
||||
fn walk_all(out: &mut Document, nodes: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
||||
nodes.iter().any(|n| needs(n)).then(|| nodes.iter().map(|n| walk(out, n).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
|
||||
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec<Arc<Node>>> {
|
||||
nodes.iter().any(|n| needs(n, which)).then(|| nodes.iter().map(|n| walk(out, n, which).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
|
||||
}
|
||||
|
||||
/// A copy of `doc` with raster effects turned into images at the document's raster effects
|
||||
@@ -406,26 +429,37 @@ fn walk_all(out: &mut Document, nodes: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
||||
/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers,
|
||||
/// opacity masks, symbol definitions and pattern tiles.
|
||||
pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
||||
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n));
|
||||
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art)) && !doc.patterns.iter().any(|p| any(&p.art)) {
|
||||
flatten(doc, Which::All)
|
||||
}
|
||||
|
||||
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
|
||||
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
|
||||
/// other raster effects live), or `None` when there are none.
|
||||
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, Which::Pixel)
|
||||
}
|
||||
|
||||
fn flatten(doc: &Document, which: Which) -> Option<Document> {
|
||||
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n, which));
|
||||
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) {
|
||||
return None;
|
||||
}
|
||||
// Geometry effects first, so the vector objects kept above shadows are final.
|
||||
let baked = effects::bake_document(doc);
|
||||
let src = baked.as_ref().unwrap_or(doc);
|
||||
let mut out = src.clone();
|
||||
if let Some(layers) = walk_all(&mut out, &src.layers) {
|
||||
if let Some(layers) = walk_all(&mut out, &src.layers, which) {
|
||||
out.layers = layers;
|
||||
}
|
||||
for i in 0..out.symbols.len() {
|
||||
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a))
|
||||
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, which))
|
||||
&& let Some(s) = out.symbols.get_mut(i)
|
||||
{
|
||||
s.art = Arc::new(art);
|
||||
}
|
||||
}
|
||||
for i in 0..out.patterns.len() {
|
||||
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a))
|
||||
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, which))
|
||||
&& let Some(p) = out.patterns.get_mut(i)
|
||||
{
|
||||
p.art = art;
|
||||
|
||||
@@ -1552,6 +1552,8 @@ mod tests_proxyitems;
|
||||
#[cfg(test)]
|
||||
mod tests_puppetwarp;
|
||||
#[cfg(test)]
|
||||
mod tests_rasterfilters;
|
||||
#[cfg(test)]
|
||||
mod tests_rastersettings;
|
||||
#[cfg(test)]
|
||||
mod tests_recolor;
|
||||
|
||||
@@ -551,6 +551,26 @@ fn paste_on_all_artboards_keeps_the_offset_to_the_source_artboard() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste in Place, in Front and in Back onto the active artboard (#693): where the objects were on
|
||||
/// the artboard they were copied from, on the one named; without it, where they were.
|
||||
#[test]
|
||||
fn in_place_pastes_go_onto_the_artboard_named() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
|
||||
let r = rect(&mut s, boards[0].x0 + 10.0, boards[0].y0 + 15.0, 20.0, 20.0);
|
||||
copy(&mut s, &[r]);
|
||||
for cmd in ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack"] {
|
||||
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2})).unwrap());
|
||||
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 15.0), "{cmd}");
|
||||
let ids = ids_of(&s.execute(cmd, &json!({})).unwrap());
|
||||
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} without one");
|
||||
// An artboard that isn't there: where they were.
|
||||
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 9})).unwrap());
|
||||
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} on no artboard");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_remembers_layers_restores_the_source_layers() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen ›
|
||||
//! Unsharp Mask): applied and edited as commands, listed in the catalogue, drawn on the canvas,
|
||||
//! and written to PDF and SVG as images of the effected object.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_render::{Rendered, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The new effects with parameters that change a striped square visibly.
|
||||
const EFFECTS: [(&str, &str); 3] = [
|
||||
("blur.radial", r#"{"amount": 30, "quality": "draft"}"#),
|
||||
("blur.smart", r#"{"radius": 8, "threshold": 100}"#),
|
||||
("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#),
|
||||
];
|
||||
|
||||
/// A group of a red square and a darker red stripe across it; returns the group's id.
|
||||
fn striped() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#e03020", "ids": [id]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap();
|
||||
let stripe = s.execute("shape.rectangle", &json!({"x": 90, "y": 50, "width": 20, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#b04848", "ids": [stripe]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [stripe]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id, stripe]})).unwrap();
|
||||
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
(s, g)
|
||||
}
|
||||
|
||||
fn render(s: &Session) -> Rendered {
|
||||
Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true)
|
||||
}
|
||||
|
||||
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
|
||||
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
|
||||
sum as f64 / a.pixels.len() as f64 / 255.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_applied_edited_and_undone() {
|
||||
let (mut s, g) = striped();
|
||||
let list = s.execute("effect.list", &json!({})).unwrap();
|
||||
for (id, _) in EFFECTS {
|
||||
let e = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("{id} listed"));
|
||||
assert_eq!(e["raster"], json!(true), "{id}");
|
||||
assert!(e["params"].as_str().unwrap().contains("radius") || id == "blur.radial", "{id}");
|
||||
}
|
||||
let menus: Vec<Value> =
|
||||
EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect();
|
||||
assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]);
|
||||
// Junk parameters are stored as given and read clamped; the canvas still draws.
|
||||
for (id, _) in EFFECTS {
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}}))
|
||||
.unwrap();
|
||||
}
|
||||
let _ = render(&s);
|
||||
let n = s.doc().unwrap().doc.node(NodeId(g)).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.len(), 3);
|
||||
assert_eq!(n.appearance.effects[0].params["method"], json!("spin"), "defaults fill the rest");
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"method": "zoom"}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects[0].params["method"], json!("zoom"));
|
||||
for _ in 0..4 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
|
||||
for (id, params) in EFFECTS {
|
||||
let (mut s, g) = striped();
|
||||
let plain = render(&s);
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
|
||||
let a = render(&s);
|
||||
assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a));
|
||||
assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic");
|
||||
// The multithreaded pipeline draws the same.
|
||||
let mut mt = Renderer::new();
|
||||
mt.threads = 4;
|
||||
let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
|
||||
assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_blur_reaches_past_the_corners() {
|
||||
let (mut s, g) = striped();
|
||||
s.execute("effect.apply", &json!({"effect": "blur.radial", "ids": [g], "params": {"amount": 60, "quality": "good"}})).unwrap();
|
||||
let img = render(&s);
|
||||
// Turned corners sweep beyond the square's sides (centre 100, 100; corner 70.7 pt out).
|
||||
let p = img.pixel(100, 46);
|
||||
assert!(p[1] < 250, "the arc of the corners reaches above the square: {p:?}");
|
||||
assert!(img.pixel(100, 20)[1] > 250, "but not beyond the corners' circle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_and_svg_write_the_effected_object_as_an_image() {
|
||||
for (id, params) in EFFECTS {
|
||||
let (mut s, g) = striped();
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
// PDF: an image that reads back as the canvas shows it.
|
||||
let r = crate::cmd::rasterfx::export_pdf_with_report(&doc, &Default::default()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{id}: {:?}", r.warnings);
|
||||
let back = vectorcraft_pdf::import(&r.bytes).unwrap();
|
||||
let diff = mean_diff(&render(&s), &Renderer::new().render_region(&back, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true));
|
||||
assert!(diff < 0.03, "{id}: PDF reads back {:.2}% off", diff * 100.0);
|
||||
// SVG: the object becomes an image; a Gaussian Blur alone stays a live filter.
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains("<image") && !svg.contains("<filter"), "{id}: {svg}");
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(&doc).unwrap();
|
||||
assert!(matches!(flat.layers[0].children().unwrap()[0].kind, NodeKind::Image(_)), "{id}");
|
||||
}
|
||||
let (mut s, g) = striped();
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [g]})).unwrap();
|
||||
assert!(crate::cmd::rasterfx::flatten_pixel_effects(&s.doc().unwrap().doc).is_none());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains("feGaussianBlur") && !svg.contains("<image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_appearance_makes_an_image() {
|
||||
let (mut s, g) = striped();
|
||||
s.execute("effect.apply", &json!({"effect": "sharpen.unsharpMask", "ids": [g]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [g]})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().kind, NodeKind::Image(_)));
|
||||
}
|
||||
@@ -257,6 +257,9 @@ fn structured_junk() {
|
||||
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
|
||||
("effect.apply", json!({"effect": "distort.transform", "params": {"copies": 1e308}})),
|
||||
("effect.apply", json!({"effect": "stylize.dropShadow", "params": {"blur": 1e308, "opacity": -1}})),
|
||||
("effect.apply", json!({"effect": "blur.radial", "params": {"amount": 1e308, "method": 5, "quality": "best"}})),
|
||||
("effect.apply", json!({"effect": "blur.smart", "params": {"radius": 1e308, "threshold": -1e308, "quality": null}})),
|
||||
("effect.apply", json!({"effect": "sharpen.unsharpMask", "params": {"amount": -1, "radius": "1e999", "threshold": 1e308}})),
|
||||
("effect.apply", json!({"effect": "no.such.effect"})),
|
||||
("effect.remove", json!({"index": 99})),
|
||||
("effect.setParams", json!({"index": 0, "params": null})),
|
||||
|
||||
+203
-77
@@ -11,7 +11,12 @@
|
||||
//! - Gaussian Blur: the object painted inside a Gaussian filter layer;
|
||||
//! - Feather: like Gaussian Blur but clipped to the shape, so the edge fades inward;
|
||||
//! - Inner Glow: a blurred inverse silhouette (Edge) or the blurred silhouette (Center), clipped
|
||||
//! to the shape and painted above it.
|
||||
//! to the shape and painted above it;
|
||||
//! - Photoshop-style filters (Radial Blur, Smart Blur, Unsharp Mask…): the content is rendered
|
||||
//! offscreen and its pixels run through the content-changing effects in stack order (those
|
||||
//! filters, Gaussian Blur and Feather, see [`effects::pixel`]), then drawn back
|
||||
//! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't
|
||||
//! change.
|
||||
//!
|
||||
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
|
||||
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
|
||||
@@ -132,8 +137,12 @@ pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let o = n.appearance.outset() + effects::outset(&n.appearance.effects);
|
||||
r.map(|r| r.inflate(o, o))
|
||||
let s = n.appearance.outset();
|
||||
r.map(|r| {
|
||||
let r = r.inflate(s, s);
|
||||
let o = effects::outset(&n.appearance.effects, r);
|
||||
r.inflate(o, o)
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounds used for culling: like `Node::visual_bounds`, but aware of live effects anywhere in
|
||||
@@ -151,7 +160,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
|
||||
} else {
|
||||
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
|
||||
}?;
|
||||
let o = effects::outset(&n.appearance.effects);
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
@@ -161,7 +170,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
|
||||
// The reshaped art (a symbol's art needs the document: its instance box stands in).
|
||||
_ if has_object_fx(n) => {
|
||||
let b = effects::reshape(n, None).and_then(|r| r.visual_bounds()).or_else(|| n.visual_bounds())?;
|
||||
let o = effects::outset(&n.appearance.effects);
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
}
|
||||
_ => n.visual_bounds(),
|
||||
@@ -187,6 +196,56 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
|
||||
peniko::Color::new([r, g, b, 1.0])
|
||||
}
|
||||
|
||||
/// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is
|
||||
/// cropped to the view when its effects only look nearby, else rendered at a lower resolution.
|
||||
const PIXEL_BUDGET: f64 = 8.4e6;
|
||||
|
||||
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
|
||||
/// content itself), in stack order.
|
||||
fn content_changing(fx: &&RasterFx) -> bool {
|
||||
matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_))
|
||||
}
|
||||
|
||||
/// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when
|
||||
/// anywhere in the object.
|
||||
fn looks_around(fx: &RasterFx) -> Option<f64> {
|
||||
match fx {
|
||||
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius),
|
||||
RasterFx::Pixel(p) => p.reach(),
|
||||
_ => Some(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of an object with Photoshop-style effects as filtered pixels, drawn at
|
||||
/// (`anchor`'s view position + `rel`) scaled by 1 / `k` (see [`Renderer::pixel_content`]).
|
||||
pub(crate) struct PixelEntry {
|
||||
node: Node,
|
||||
linear: [u64; 4],
|
||||
anchor: vectorcraft_geom::Point,
|
||||
rel: (f64, f64),
|
||||
k: f64,
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys.
|
||||
fn linear_key(view: Affine) -> [u64; 4] {
|
||||
let c = view.as_coeffs();
|
||||
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
||||
}
|
||||
|
||||
/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel.
|
||||
fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, (x, y): (f64, f64), k: f64) {
|
||||
let (w, h) = (image.width() as f64, image.height() as f64);
|
||||
ctx.set_transform(Affine::translate((x, y)) * Affine::scale(1.0 / k));
|
||||
// On whole pixels at full size the cheapest sampler is exact.
|
||||
let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
|
||||
let sampler = peniko::ImageSampler { quality, ..Default::default() };
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to
|
||||
/// the view-space position of `anchor` (a document point).
|
||||
pub(crate) struct ShadowEntry {
|
||||
@@ -199,73 +258,6 @@ pub(crate) struct ShadowEntry {
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
/// Box sizes approximating a Gaussian of `sigma` with three box blurs.
|
||||
fn gauss_boxes(sigma: f64) -> [usize; 3] {
|
||||
let n = 3.0;
|
||||
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
|
||||
let mut wl = w_ideal.floor() as i64;
|
||||
if wl % 2 == 0 {
|
||||
wl -= 1;
|
||||
}
|
||||
let wu = wl + 2;
|
||||
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
|
||||
let m = m_ideal.round() as i64;
|
||||
let r = |i: i64| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize;
|
||||
[r(0), r(1), r(2)]
|
||||
}
|
||||
|
||||
/// One horizontal box-blur pass of radius `r` over rows of `w` (running sum, edges as zero).
|
||||
fn box_blur_h(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize) {
|
||||
if r == 0 {
|
||||
dst.copy_from_slice(src);
|
||||
return;
|
||||
}
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
for y in 0..h {
|
||||
let row = &src[y * w..(y + 1) * w];
|
||||
let out = &mut dst[y * w..(y + 1) * w];
|
||||
let mut acc: f32 = row.iter().take(r + 1).sum();
|
||||
for x in 0..w {
|
||||
out[x] = acc * norm;
|
||||
if x + r + 1 < w {
|
||||
acc += row[x + r + 1];
|
||||
}
|
||||
if x >= r {
|
||||
acc -= row[x - r];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose(src: &[f32], dst: &mut [f32], w: usize, h: usize) {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
dst[x * h + y] = src[y * w + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-blur an alpha plane in place (three box passes per axis).
|
||||
fn blur_alpha(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
if sigma < 0.2 {
|
||||
return;
|
||||
}
|
||||
let boxes = gauss_boxes(sigma);
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for r in boxes {
|
||||
box_blur_h(a, &mut tmp, w, h, r);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
transpose(a, &mut tmp, w, h);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
for r in boxes {
|
||||
box_blur_h(a, &mut tmp, h, w, r);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
transpose(a, &mut tmp, h, w);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
|
||||
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
|
||||
@@ -287,8 +279,7 @@ impl Renderer {
|
||||
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color),
|
||||
_ => return false,
|
||||
};
|
||||
let c = f.view.as_coeffs();
|
||||
let linear = [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()];
|
||||
let linear = linear_key(f.view);
|
||||
let key = (n as *const Node as usize, i, f.ink);
|
||||
let stamp = self.stamp;
|
||||
let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node == *n);
|
||||
@@ -317,7 +308,7 @@ impl Renderer {
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
|
||||
blur_alpha(&mut a, wu, hu, blur / 2.0 / f.px);
|
||||
effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px);
|
||||
let [cr, cg, cb] = f.ink.rgb(&color);
|
||||
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
|
||||
for (px, av) in pm.data_mut().iter_mut().zip(&a) {
|
||||
@@ -504,7 +495,7 @@ impl Renderer {
|
||||
c.pop_layer();
|
||||
});
|
||||
}
|
||||
// The content itself (blurred / feathered).
|
||||
// The content itself (blurred / feathered / filtered).
|
||||
let blur: f64 = rfx
|
||||
.iter()
|
||||
.map(|fx| match fx {
|
||||
@@ -512,7 +503,9 @@ impl Renderer {
|
||||
_ => 0.0,
|
||||
})
|
||||
.sum();
|
||||
if blur > 0.0 {
|
||||
if rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) {
|
||||
self.pixel_content(ctx, f, content, rfx, paint);
|
||||
} else if blur > 0.0 {
|
||||
self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| {
|
||||
let mut layers = 0;
|
||||
for fx in rfx {
|
||||
@@ -580,6 +573,105 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather and the
|
||||
/// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can
|
||||
/// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels:
|
||||
/// larger content is cropped to the view when every effect only looks nearby, else rendered
|
||||
/// at a lower resolution. A whole raster at full resolution is cached while the object and the
|
||||
/// zoom stay the same (panning only moves it).
|
||||
fn pixel_content(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
content: &Content,
|
||||
rfx: &[RasterFx],
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect();
|
||||
// Where the result can paint: each effect spreads what the ones before it painted.
|
||||
let reach = chain.iter().fold(content.reach, |r, fx| {
|
||||
let o = fx.outset(r);
|
||||
r.inflate(o, o)
|
||||
});
|
||||
let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0);
|
||||
let (sw, sh) = (ctx.width() as f64, ctx.height() as f64);
|
||||
if !(full.area().is_finite() && full.x1 > 0.0 && full.y1 > 0.0 && full.x0 < sw && full.y0 < sh) {
|
||||
return;
|
||||
}
|
||||
let fits = full.area() <= PIXEL_BUDGET;
|
||||
// Device pixels from a pixel to the content deciding it (`None`: anywhere).
|
||||
let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px));
|
||||
let region = match margin {
|
||||
Some(m) if !fits => full.intersect(Rect::new(0.0, 0.0, sw, sh).inflate(m, m)),
|
||||
_ => full,
|
||||
};
|
||||
let (x0, y0) = (region.x0.floor(), region.y0.floor());
|
||||
let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0));
|
||||
let side = u16::MAX as f64;
|
||||
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
|
||||
// The cached raster, when the whole content was rendered at full size at this zoom.
|
||||
let linear = linear_key(f.view);
|
||||
let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink));
|
||||
let stamp = self.stamp;
|
||||
if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node == *content.node) {
|
||||
e.stamp = stamp;
|
||||
let p = f.view * e.anchor;
|
||||
let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k);
|
||||
draw_scaled(ctx, image, at, k);
|
||||
return;
|
||||
}
|
||||
let (wu, hu) = (w as u16, h as u16);
|
||||
let view = Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view;
|
||||
let to_doc = view.inverse();
|
||||
let px = f.px / k;
|
||||
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
|
||||
let mut off = f.offscreen_context(wu, hu);
|
||||
self.inside_layer(|r| paint(r, &mut off, &frame));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(wu, hu);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
let space = effects::PixelSpace { to_doc, px, center: content.reach.center() };
|
||||
let (wz, hz) = (wu as usize, hu as usize);
|
||||
for fx in chain {
|
||||
match fx {
|
||||
RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px),
|
||||
RasterFx::Feather { radius } => {
|
||||
effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px);
|
||||
if let Some((g, rule)) = content.outline {
|
||||
self.clip_pixels(&mut pm, &frame, g, rule);
|
||||
}
|
||||
}
|
||||
RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let image = std::sync::Arc::new(pm);
|
||||
if let Some(key) = key {
|
||||
let anchor = content.reach.origin();
|
||||
let p = f.view * anchor;
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
self.pixel_fx.insert(key, PixelEntry { node: content.node.clone(), linear, anchor, rel, k, image: image.clone(), stamp });
|
||||
}
|
||||
draw_scaled(ctx, image, (x0, y0), k);
|
||||
}
|
||||
|
||||
/// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip).
|
||||
fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) {
|
||||
let mut m = frame.offscreen_context(pm.width(), pm.height());
|
||||
m.set_transform(frame.view);
|
||||
m.set_fill_rule(fill_rule(rule));
|
||||
m.set_paint(peniko::Color::BLACK);
|
||||
m.fill_path(g);
|
||||
m.flush();
|
||||
let mut mask = vello_cpu::Pixmap::new(pm.width(), pm.height());
|
||||
m.render(&mut mask, &mut self.resources);
|
||||
for (p, c) in pm.data_mut().iter_mut().zip(mask.data()) {
|
||||
let keep = |v: u8| ((v as u32 * c.a as u32 + 127) / 255) as u8;
|
||||
*p = vello_cpu::color::PremulRgba8 { r: keep(p.r), g: keep(p.g), b: keep(p.b), a: keep(p.a) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw`
|
||||
/// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context
|
||||
/// the layer is drawn into an offscreen single-threaded context covering `reach` (document
|
||||
@@ -885,4 +977,38 @@ mod tests {
|
||||
assert!(worst <= 4, "{name}: max channel difference {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photoshop_filters_draw_and_follow_a_pan_from_the_cache() {
|
||||
let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![("blur.radial", json!({"amount": 40, "quality": "draft"}))]);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert!(lum(a.pixel(31, 31)) > 60, "the spin softens the corners: {:?}", a.pixel(31, 31));
|
||||
assert_eq!(lum(a.pixel(50, 50)), 0, "the centre stays");
|
||||
assert!(lum(a.pixel(50, 28)) < WHITE, "turned corners reach past the sides");
|
||||
// Panned: the cached raster moves with the view.
|
||||
let b = r.render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts);
|
||||
for (x, y) in [(31, 31), (50, 28), (69, 50), (40, 66)] {
|
||||
assert_eq!(a.pixel(x, y), b.pixel(x + 10, y + 3), "({x}, {y})");
|
||||
}
|
||||
// A fresh renderer draws the panned view the same.
|
||||
let c = Renderer::new().render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts);
|
||||
assert_eq!(b.pixels, c.pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photoshop_filters_on_content_larger_than_the_budget() {
|
||||
// Zoomed in 200×: the 40 pt square covers 8000 × 8000 px around a 200 × 200 view.
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let view = Affine::translate((100.0, 100.0)) * Affine::scale(200.0) * Affine::translate((-70.0, -50.0));
|
||||
for (id, p) in [("sharpen.unsharpMask", json!({"amount": 200})), ("blur.smart", json!({})), ("blur.radial", json!({"quality": "draft"}))] {
|
||||
let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::rgb(0.2, 0.4, 0.8), vec![(id, p)]);
|
||||
let img = Renderer::new().render(&d, 200, 200, view, &opts);
|
||||
// The view shows the square's right side half way down: inside left, outside right.
|
||||
assert!(lum(img.pixel(40, 100)) < 600, "{id}: inside {:?}", img.pixel(40, 100));
|
||||
// (Smart Blur's wide samples read a blurred copy: a faint fringe under its threshold.)
|
||||
assert!(lum(img.pixel(190, 100)) >= WHITE - 3 * 25, "{id}: outside {:?}", img.pixel(190, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,9 @@ pub struct Renderer {
|
||||
live: live::LiveCache,
|
||||
/// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`).
|
||||
shadows: PtrMap<(usize, usize, Ink), fx::ShadowEntry>,
|
||||
/// Objects' content run through Photoshop-style effects, per object and content slot (see
|
||||
/// `fx`): kept only while drawn.
|
||||
pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>,
|
||||
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
|
||||
knockout: bool,
|
||||
/// Address of the knockout-group element being drawn as its knockout shape: at full object
|
||||
@@ -421,6 +424,7 @@ impl Renderer {
|
||||
stats: FrameStats::default(),
|
||||
brushes: Default::default(),
|
||||
shadows: PtrMap::default(),
|
||||
pixel_fx: PtrMap::default(),
|
||||
live: live::LiveCache::default(),
|
||||
knockout: false,
|
||||
shape_of: 0,
|
||||
@@ -491,6 +495,8 @@ impl Renderer {
|
||||
if self.shadows.len() > 256 {
|
||||
self.shadows.retain(|_, e| g - e.stamp <= 3);
|
||||
}
|
||||
// Filtered rasters are large: only those of the last frame stay.
|
||||
self.pixel_fx.retain(|_, e| e.stamp == g);
|
||||
if !inks {
|
||||
proof::post(&mut pixels, opts);
|
||||
}
|
||||
|
||||
@@ -584,6 +584,7 @@ impl Rules<'_> {
|
||||
}
|
||||
RasterFx::GaussianBlur { radius } => filter = Some(format!("blur({})", self.len(radius / 2.0))),
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => art.unsupported("inner glows or feathering"),
|
||||
RasterFx::Pixel(_) => art.unsupported("pixel effects"),
|
||||
}
|
||||
}
|
||||
if !shadows.is_empty() {
|
||||
|
||||
@@ -1486,7 +1486,9 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
|
||||
/// per effect, the first effect innermost (the reference app's stacking order).
|
||||
/// per effect, the first effect innermost (the reference app's stacking order). Photoshop-style
|
||||
/// filters have no SVG equivalent: they are left out with a warning (exports from the app turn
|
||||
/// such objects into images first).
|
||||
fn filtered(&mut self, n: &Node) {
|
||||
let mut inner = n.clone();
|
||||
inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id));
|
||||
@@ -1506,9 +1508,15 @@ impl Writer<'_> {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
let reach: f64 = fx.iter().map(RasterFx::outset).sum::<f64>() + 2.0;
|
||||
let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let bounds = n.visual_bounds();
|
||||
let reach: f64 = fx.iter().map(|f| f.outset(bounds.unwrap_or_default())).sum::<f64>() + 2.0;
|
||||
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let mut opened = 0;
|
||||
for f in fx.iter().rev() {
|
||||
if matches!(f, RasterFx::Pixel(_)) {
|
||||
self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out");
|
||||
continue;
|
||||
}
|
||||
let fid = self.fresh_id("filter");
|
||||
let region_attr = match region {
|
||||
Some(r) => format!(
|
||||
@@ -1557,6 +1565,7 @@ impl Writer<'_> {
|
||||
sd(*radius)
|
||||
),
|
||||
RasterFx::GaussianBlur { radius } => format!("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/>", sd(*radius)),
|
||||
RasterFx::Pixel(_) => continue,
|
||||
};
|
||||
// Filters composite normally: a shadow's or glow's other blend mode is recorded for import.
|
||||
let mode = match f {
|
||||
@@ -1570,8 +1579,9 @@ impl Writer<'_> {
|
||||
self.def(1, &format!("<filter id=\"{fid}\"{region_attr}{mode} color-interpolation-filters=\"sRGB\">{body}</filter>"));
|
||||
self.line(&format!("<g filter=\"url(#{fid})\">"));
|
||||
self.depth += 1;
|
||||
opened += 1;
|
||||
}
|
||||
fx.len()
|
||||
opened
|
||||
}
|
||||
|
||||
fn close_filters(&mut self, opened: usize) {
|
||||
|
||||
@@ -864,6 +864,15 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
|
||||
{
|
||||
v.artboard = i;
|
||||
}
|
||||
// A press with a selection tool inside an artboard makes it the active one (#693), as the
|
||||
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it.
|
||||
if ev.kind == PointerKind::Down
|
||||
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
|
||||
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
|
||||
&& let Some(v) = app.view_mut()
|
||||
{
|
||||
v.artboard = i;
|
||||
}
|
||||
apply_requests(app, r);
|
||||
}
|
||||
|
||||
@@ -2051,6 +2060,34 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A click with the Selection tool inside an artboard makes it the active one, and Paste in
|
||||
/// Place then pastes onto it, where the objects were on their own artboard (#693).
|
||||
#[test]
|
||||
fn a_click_activates_its_artboard_and_paste_in_place_goes_onto_it() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + 10.0, "y": boards[0].y0 + 15.0, "width": 20, "height": 20})).unwrap();
|
||||
app.run("edit.copy", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("selection");
|
||||
let view = app.view_info();
|
||||
let at = boards[2].center();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0 }, view);
|
||||
}
|
||||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||||
let ids: Vec<vectorcraft_doc::NodeId> = app.run("edit.pasteInPlace", json!({})).unwrap()["ids"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| vectorcraft_doc::NodeId(v.as_u64().unwrap()))
|
||||
.collect();
|
||||
let b = app.session.active().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0));
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
body: |app, ui, d| {
|
||||
let command = d.str("__command");
|
||||
let lengths = lengths(&command);
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), app.session.general_unit());
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), &|_| 0, app.session.general_unit());
|
||||
false
|
||||
},
|
||||
confirm,
|
||||
|
||||
@@ -131,7 +131,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let changed = match vectorcraft_plugins::effect::installed(&id) {
|
||||
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
|
||||
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
|
||||
None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|_| None, app.session.general_unit()),
|
||||
None => {
|
||||
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
|
||||
let unit = app.session.general_unit();
|
||||
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|k| choices(&id, k), &|k| doc_rank(doc, k), unit)
|
||||
}
|
||||
};
|
||||
ui.add_space(6.0);
|
||||
let mut pv = d.bool("preview");
|
||||
@@ -148,6 +152,26 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The parameters of the effect dialogs that pick one of some values, as (label, value).
|
||||
fn choices(effect: &str, key: &str) -> Option<form::Choices> {
|
||||
match (effect, key) {
|
||||
("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]),
|
||||
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
|
||||
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where parameter `key` comes in the effect's parameter documentation `doc` (`{radius: …,
|
||||
/// threshold: …}`), which lists them in its dialog's order; the end when it isn't there.
|
||||
pub(super) fn doc_rank(doc: &str, key: &str) -> usize {
|
||||
let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' ');
|
||||
doc.match_indices(key)
|
||||
.map(|(at, _)| at)
|
||||
.find(|at| starts(*at) && doc.get(at + key.len()..).is_some_and(|rest| rest.starts_with(':') || rest.starts_with("?:")))
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// A slider of a colour adjustment dialog: (effect, field, label, range, rail colour at 0..1).
|
||||
type Slider = (&'static str, &'static str, &'static str, (f64, f64), fn(f32) -> egui::Color32);
|
||||
|
||||
|
||||
@@ -197,19 +197,22 @@ pub(super) type Choices = &'static [(&'static str, &'static str)];
|
||||
|
||||
/// Generic editor for command/effect parameters: numbers, booleans, strings and colours; the
|
||||
/// parameters `is_length` names are distances shown and typed in `unit`, those `choices` gives
|
||||
/// choices for are dropdowns. Returns true when a value changed.
|
||||
/// choices for are dropdowns. Fields come in `rank` order (by name among equals). Returns true when
|
||||
/// a value changed.
|
||||
pub(super) fn param_fields(
|
||||
ui: &mut egui::Ui,
|
||||
d: &mut Dialog,
|
||||
is_length: &dyn Fn(&str) -> bool,
|
||||
choices: &dyn Fn(&str) -> Option<Choices>,
|
||||
rank: &dyn Fn(&str) -> usize,
|
||||
unit: Unit,
|
||||
) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut changed = false;
|
||||
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
let keys: Vec<(String, Value)> =
|
||||
let mut keys: Vec<(String, Value)> =
|
||||
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
keys.sort_by_key(|(k, _)| rank(k));
|
||||
for (k, v) in keys {
|
||||
ui.label(egui::RichText::new(humanized(&k)).color(t.text));
|
||||
if let Some(cur) = crate::widgets::blend_param(&k, &v) {
|
||||
|
||||
@@ -166,6 +166,30 @@ fn effect_preview_is_kept_as_one_undo_step() {
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_filter_dialogs_preview_and_apply() {
|
||||
for (effect, key, value) in [("blur.radial", "method", "zoom"), ("blur.smart", "quality", "high"), ("sharpen.unsharpMask", "amount", "120")] {
|
||||
let mut app = app();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect": effect})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
let v: serde_json::Value = value.parse::<f64>().map_or_else(|_| json!(value), |n| json!(n));
|
||||
d.fields.insert(key.into(), v.clone());
|
||||
frame(&mut app, Default::default());
|
||||
assert!(app.session.in_interaction(), "{effect}: previews");
|
||||
confirm(&mut app).unwrap();
|
||||
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.len(), 1, "{effect}");
|
||||
assert_eq!(n.appearance.effects[0].params[key], v, "{effect}");
|
||||
}
|
||||
// Fields follow the effect's documented order (its dialog's), not the alphabet.
|
||||
let doc = vectorcraft_effects::effect_info("blur.smart").unwrap().params;
|
||||
let ranks: Vec<usize> = ["radius", "threshold", "quality"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
|
||||
assert!(ranks.windows(2).all(|w| w[0] < w[1]), "{ranks:?}");
|
||||
assert_eq!(super::effect::doc_rank(doc, "nope"), usize::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_dialog_applies_to_its_appearance_item() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -643,3 +643,9 @@
|
||||
Constrain Omezit
|
||||
Free Transform Volná transformace
|
||||
Perspective Distort Perspektivní deformace
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Radiální rozostření…
|
||||
Smart Blur… Chytré rozostření…
|
||||
Sharpen Zostření
|
||||
Unsharp Mask… Maska rozostření…
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -11,6 +11,23 @@
|
||||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Desenfoque radial…
|
||||
Smart Blur… Desenfoque inteligente…
|
||||
Sharpen Enfocar
|
||||
Unsharp Mask… Máscara de enfoque…
|
||||
Radial Blur Desenfoque radial
|
||||
Smart Blur Desenfoque inteligente
|
||||
Unsharp Mask Máscara de enfoque
|
||||
Gaussian Blur Desenfoque gaussiano
|
||||
Method Método
|
||||
Quality Calidad
|
||||
Radius Radio
|
||||
Spin Giro
|
||||
Draft Borrador
|
||||
Good Buena
|
||||
Best Óptima
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -12,6 +12,23 @@
|
||||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Flou radial…
|
||||
Smart Blur… Flou intelligent…
|
||||
Sharpen Netteté
|
||||
Unsharp Mask… Masque de netteté…
|
||||
Radial Blur Flou radial
|
||||
Smart Blur Flou intelligent
|
||||
Unsharp Mask Masque de netteté
|
||||
Gaussian Blur Flou gaussien
|
||||
Method Méthode
|
||||
Quality Qualité
|
||||
Radius Rayon
|
||||
Spin Rotation
|
||||
Draft Brouillon
|
||||
Good Bonne
|
||||
Best Optimale
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -11,6 +11,23 @@
|
||||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Sfocatura radiale…
|
||||
Smart Blur… Sfocatura intelligente…
|
||||
Sharpen Nitidezza
|
||||
Unsharp Mask… Maschera di contrasto…
|
||||
Radial Blur Sfocatura radiale
|
||||
Smart Blur Sfocatura intelligente
|
||||
Unsharp Mask Maschera di contrasto
|
||||
Gaussian Blur Sfocatura gaussiana
|
||||
Method Metodo
|
||||
Quality Qualità
|
||||
Radius Raggio
|
||||
Spin Rotazione
|
||||
Draft Bozza
|
||||
Good Buona
|
||||
Best Ottima
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3105,3 +3105,20 @@ theme Appearance 外観
|
||||
Constrain 制限
|
||||
Free Transform 自由変形
|
||||
Perspective Distort 遠近変形
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… ぼかし (放射状)…
|
||||
Smart Blur… ぼかし (詳細)…
|
||||
Sharpen シャープ
|
||||
Unsharp Mask… アンシャープマスク…
|
||||
Radial Blur ぼかし (放射状)
|
||||
Smart Blur ぼかし (詳細)
|
||||
Unsharp Mask アンシャープマスク
|
||||
Gaussian Blur ぼかし (ガウス)
|
||||
Method 方法
|
||||
Quality 画質
|
||||
Radius 半径
|
||||
Spin 回転
|
||||
Draft ドラフト
|
||||
Good 標準
|
||||
Best 高品質
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2676,3 +2676,9 @@
|
||||
Constrain Restringir
|
||||
Free Transform Transformação Livre
|
||||
Perspective Distort Distorção de Perspectiva
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Desfoque Radial…
|
||||
Smart Blur… Desfoque Inteligente…
|
||||
Sharpen Nitidez
|
||||
Unsharp Mask… Máscara de Nitidez…
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -9,6 +9,23 @@
|
||||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Радиальное размытие…
|
||||
Smart Blur… Умное размытие…
|
||||
Sharpen Резкость
|
||||
Unsharp Mask… Нерезкая маска…
|
||||
Radial Blur Радиальное размытие
|
||||
Smart Blur Умное размытие
|
||||
Unsharp Mask Нерезкая маска
|
||||
Gaussian Blur Размытие по Гауссу
|
||||
Method Метод
|
||||
Quality Качество
|
||||
Radius Радиус
|
||||
Spin Вращение
|
||||
Draft Черновое
|
||||
Good Хорошее
|
||||
Best Наилучшее
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3357,3 +3357,20 @@ axis Both 两者
|
||||
Select Shaper Face 选择 Shaper 区域
|
||||
Constrain 约束
|
||||
Perspective Distort 透视扭曲
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… 径向模糊…
|
||||
Smart Blur… 智能模糊…
|
||||
Sharpen 锐化
|
||||
Unsharp Mask… 反锐化遮罩…
|
||||
Radial Blur 径向模糊
|
||||
Smart Blur 智能模糊
|
||||
Unsharp Mask 反锐化遮罩
|
||||
Gaussian Blur 高斯模糊
|
||||
Method 方法
|
||||
Quality 质量
|
||||
Radius 半径
|
||||
Spin 旋转
|
||||
Draft 草稿
|
||||
Good 良好
|
||||
Best 最佳
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3135,3 +3135,20 @@ axis Both 兩者
|
||||
Constrain 強制
|
||||
Free Transform 任意變形
|
||||
Perspective Distort 透視扭曲
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… 放射狀模糊…
|
||||
Smart Blur… 智慧型模糊…
|
||||
Sharpen 銳利化
|
||||
Unsharp Mask… 反銳利化遮罩…
|
||||
Radial Blur 放射狀模糊
|
||||
Smart Blur 智慧型模糊
|
||||
Unsharp Mask 反銳利化遮罩
|
||||
Gaussian Blur 高斯模糊
|
||||
Method 方法
|
||||
Quality 品質
|
||||
Radius 半徑
|
||||
Spin 旋轉
|
||||
Draft 草稿
|
||||
Good 良好
|
||||
Best 最佳
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -534,6 +534,14 @@ impl VectorcraftApp {
|
||||
{
|
||||
p.insert("center".into(), serde_json::json!([c.x, c.y]));
|
||||
}
|
||||
// In place, in front, in back: onto the active artboard (#693).
|
||||
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack")
|
||||
&& params.get("artboard").is_none()
|
||||
&& let Some(i) = self.view().map(|v| v.artboard)
|
||||
&& let Some(p) = params.as_object_mut()
|
||||
{
|
||||
p.insert("artboard".into(), serde_json::json!(i));
|
||||
}
|
||||
if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
|
||||
+32
-19
@@ -3223,6 +3223,10 @@ fn font_items() -> Vec<Item> {
|
||||
items.1.clone()
|
||||
}
|
||||
|
||||
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
|
||||
/// vector effects): (submenu, the catalogue's menu path of its effects).
|
||||
const RASTER_MENUS: [(&str, &[&str]); 2] = [("Blur", &["Effect", "Blur"]), ("Sharpen", &["Effect", "Sharpen"])];
|
||||
|
||||
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
|
||||
fn effect_menu() -> Vec<Item> {
|
||||
let cat = vectorcraft_effects::effect_catalog();
|
||||
@@ -3246,33 +3250,25 @@ fn effect_menu() -> Vec<Item> {
|
||||
"Stylize",
|
||||
"SVG Filters",
|
||||
"Warp",
|
||||
"Blur",
|
||||
];
|
||||
let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label);
|
||||
for sub_name in order {
|
||||
if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) {
|
||||
out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })));
|
||||
continue;
|
||||
}
|
||||
let items: Vec<Item> = cat
|
||||
.iter()
|
||||
.filter(|e| e.menu.last().copied() == Some(sub_name))
|
||||
// The effects whose catalogue menu path is `path`.
|
||||
let items_at = |path: &[&str]| -> Vec<Item> {
|
||||
cat.iter()
|
||||
.filter(|e| e.menu == path)
|
||||
.map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
|
||||
// No options (Effect → Pathfinder): apply directly, like Illustrator.
|
||||
true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })),
|
||||
false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })),
|
||||
})
|
||||
.collect();
|
||||
if sub_name == "Blur" {
|
||||
// Live-effect plug-ins close the vector effects.
|
||||
out.extend(crate::dialogs::plugin::effect_menu());
|
||||
if !items.is_empty() {
|
||||
out.push(Sep);
|
||||
out.push(Item::Header("Raster Effects"));
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
.collect()
|
||||
};
|
||||
for sub_name in order {
|
||||
if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) {
|
||||
out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })));
|
||||
continue;
|
||||
}
|
||||
let items = items_at(&["Effect", sub_name]);
|
||||
if items.is_empty() {
|
||||
let placeholder = match sub_name {
|
||||
"3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")],
|
||||
@@ -3284,8 +3280,25 @@ fn effect_menu() -> Vec<Item> {
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
}
|
||||
// Live-effect plug-ins close the vector effects.
|
||||
out.extend(crate::dialogs::plugin::effect_menu());
|
||||
let raster: Vec<Item> = RASTER_MENUS
|
||||
.iter()
|
||||
.filter_map(|(name, path)| {
|
||||
let items = items_at(path);
|
||||
(!items.is_empty()).then(|| sub(name, items))
|
||||
})
|
||||
.collect();
|
||||
if !raster.is_empty() {
|
||||
out.push(Sep);
|
||||
out.push(Item::Header("Raster Effects"));
|
||||
out.extend(raster);
|
||||
}
|
||||
// Anything not placed above (future effects) still shows up.
|
||||
for e in cat.iter().filter(|e| !top_level(e) && !e.menu.last().is_some_and(|m| order.contains(m))) {
|
||||
let placed = |e: &vectorcraft_effects::EffectInfo| {
|
||||
top_level(e) || order.iter().any(|s| e.menu == ["Effect", *s]) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path)
|
||||
};
|
||||
for e in cat.iter().filter(|e| !placed(e)) {
|
||||
out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })));
|
||||
}
|
||||
out
|
||||
|
||||
@@ -67,11 +67,21 @@ pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node) -> Option<f64> {
|
||||
|
||||
/// The Corner Radius field of a live rectangle's or polygon's properties: [`corner_radius`],
|
||||
/// which a new value sets on those corners.
|
||||
/// The width of a panel's number fields: the Transform fields', two to a row beside the reference
|
||||
/// point, their labels and the W/H link, so every field in the panel is as wide (#696). Measured
|
||||
/// from the full row, before its widgets.
|
||||
pub(crate) fn field_width(ui: &Ui) -> f32 {
|
||||
// The reference point and its gap, the four labels and the W/H link.
|
||||
const AROUND: f32 = 37.0 + 70.0;
|
||||
((ui.available_width() - AROUND) / 2.0).clamp(60.0, 110.0)
|
||||
}
|
||||
|
||||
pub(crate) fn corner_radius_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str) {
|
||||
let units = app.session.general_unit();
|
||||
let fw = field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Corner Radius:"));
|
||||
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), units, 80.0) {
|
||||
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), units, fw) {
|
||||
app.run("object.setLiveShape", json!({"radius": r})).ok();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -207,7 +207,7 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() {
|
||||
// Its name as it is (names are never translated).
|
||||
ui.label(egui::RichText::new(&ab.name).size(13.0).color(Tokens::get(ui.ctx()).text));
|
||||
let fw = ((ui.available_width() - 50.0) / 2.0).clamp(60.0, 110.0);
|
||||
let fw = super::field_width(ui);
|
||||
egui::Grid::new("ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
let r = ab.rect;
|
||||
for (row, [(l1, k1, v1), (l2, k2, v2)]) in
|
||||
@@ -364,9 +364,10 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
divider(ui);
|
||||
section_header(ui, tl!("Preferences"));
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Keyboard Increment"));
|
||||
if let Some(v) = widgets::num_field(ui, "kbinc", Some(app.session.prefs.keyboard_increment), units, 80.0) {
|
||||
if let Some(v) = widgets::num_field(ui, "kbinc", Some(app.session.prefs.keyboard_increment), units, fw) {
|
||||
app.session.prefs.keyboard_increment = v.max(0.001);
|
||||
}
|
||||
});
|
||||
@@ -401,14 +402,13 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
|
||||
let rp = bx.reference_point(refi);
|
||||
section_header(ui, tl!("Transform"));
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(i) = widgets::reference_point(ui, refi) {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let link = app.session.prefs.constrain_proportions;
|
||||
// Room for the labels and the W/H link.
|
||||
let fw = ((ui.available_width() - 70.0) / 2.0).clamp(60.0, 110.0);
|
||||
egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
dim_label(ui, "X:");
|
||||
if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) {
|
||||
@@ -431,11 +431,12 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
super::transform::constrain_link(app, ui);
|
||||
});
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
let icon = icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
|
||||
crate::scrub::note_label(ui, icon.rect);
|
||||
// The bounding box's angle: a new value turns the selection to it.
|
||||
if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, 70.0) {
|
||||
if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, fw) {
|
||||
app.run("object.rotate", json!({"angle": a, "absolute": true})).ok();
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
@@ -453,9 +454,10 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
match live {
|
||||
vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"),
|
||||
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Sides:"));
|
||||
if let Some(s) = widgets::plain_field(ui, "sides", *sides as f64, "", 0, 60.0) {
|
||||
if let Some(s) = widgets::plain_field(ui, "sides", *sides as f64, "", 0, fw) {
|
||||
app.run("object.setLiveShape", json!({"sides": s as u32})).ok();
|
||||
}
|
||||
});
|
||||
@@ -470,10 +472,11 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(n) = first_selected(app) else { return };
|
||||
section_header(ui, tl!("Appearance"));
|
||||
fill_stroke_rows(app, ui);
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Opacity")).size(12.0));
|
||||
ui.add_space(8.0);
|
||||
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, 64.0) {
|
||||
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, fw) {
|
||||
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "ellipsis", tl!("Transparency"), false, 22.0).clicked() {
|
||||
@@ -496,6 +499,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
fn fill_stroke_rows(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx()));
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
super::paint_chip(app, ui, false, 22.0, true);
|
||||
ui.label(egui::RichText::new(tl!("Fill")).size(12.0).color(t.text));
|
||||
@@ -504,7 +508,7 @@ fn fill_stroke_rows(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
super::paint_chip(app, ui, true, 22.0, true);
|
||||
super::stroke::link(app, ui, tl!("Stroke"));
|
||||
ui.add_space(8.0);
|
||||
super::stroke::weight_field(app, ui, "ap-w", weight, 90.0);
|
||||
super::stroke::weight_field(app, ui, "ap-w", weight, fw);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -564,6 +568,34 @@ mod tests {
|
||||
frame(app, ctx, vec![b(false)]);
|
||||
}
|
||||
|
||||
/// #696: the number fields are all as wide: the Transform fields, the rotation, the corner
|
||||
/// radius, Opacity and the stroke weight.
|
||||
#[test]
|
||||
fn the_number_fields_are_all_as_wide() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
// The field each value is shown in: the narrowest widget around its text that isn't the
|
||||
// text alone.
|
||||
let field = |value: &str| {
|
||||
let at = texts.iter().find(|(t, _)| t == value).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{value}: {texts:?}"));
|
||||
ctx.viewport(|vp| {
|
||||
vp.prev_pass
|
||||
.widgets
|
||||
.layers()
|
||||
.flat_map(|(_, w)| w.iter())
|
||||
.filter(|w| w.rect.contains(at) && w.rect.width() > 30.0 && w.rect.height() < 40.0)
|
||||
.map(|w| w.rect.width())
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
})
|
||||
};
|
||||
let widths = [field("35 pt"), field("0°"), field("0 pt"), field("100%")];
|
||||
assert!(widths.iter().all(|w| (w - widths[0]).abs() < 1.0), "X, rotation, corner radius, opacity: {widths:?}");
|
||||
}
|
||||
|
||||
/// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit.
|
||||
#[test]
|
||||
fn edit_artboards_adds_artboards_and_exits() {
|
||||
|
||||
Reference in new issue
Block a user