G2: add Artistic Film Grain live effect

Add deterministic document-space grain, highlight controls and a live
dialog with preview, cancel, undo and appearance editing. Preserve
coverage, support existing native and raster export paths, translate
new labels and update parity documentation.

Validate the tonal curve, stationarity, subpixel strength, zoom filtering
and tile seams. Full cargo xtask ci passes all seven gates.
This commit is contained in:
Tevinhead authored and bflatastic committed 2026-10-12 07:26:05 +07:00
1 parent 1475a0798d
commit 810af8d001
27 files changed
+746 -29

No files matched your search

+1 -1
View File
@@ -226,7 +226,7 @@ The scores are self-assessed from our code against Illustrator's public document
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- about half of the Photoshop-style raster effects (29 of 57 still missing) and the Effect Gallery;
- about half of the Photoshop-style raster effects (28 of 57 still missing; Film Grain is available under Artistic) and the Effect Gallery;
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features.
+6 -5
View File
@@ -6,7 +6,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -26,7 +26,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 28/57, 3D 1/4 (initial Revolve) |
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 1/4 (initial Revolve) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
@@ -35,7 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|---|---:|---:|---:|---|
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
@@ -73,7 +73,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| Strokes, brushes, width profiles | 88% | 8–15 |
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
| Live vector effects | 85% | 6–10 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 48% | 24–40 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 23–39 |
| 3D and Materials | ~8% | 50–85 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
@@ -120,13 +120,14 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
3. **Launch and stability on real machines** (G10): 20–35 h.
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
5. **Raster effects** (G2) alongside: 29 filters and the Effect Gallery, 24–40 h.
5. **Raster effects** (G2) alongside: 28 filters and the Effect Gallery, 23–39 h.
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
## Progress log
| Date | What landed |
|---|---|
| 2026-10-12 | G2: Effect › Artistic › Film Grain: stable monochrome grain, highlight and intensity controls, live preview, appearance editing, undo and raster exports. Photoshop-style filters 29/57; 28 filters remain. |
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). |
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
+10 -2
View File
@@ -12,8 +12,8 @@
//! the geometry.
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
//! renderer, the exporters and Outline Stroke.
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and
//! default values. Missing parameters always fall back to those defaults.
//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects
//! through their outlines.
//! - [`evaluate_container`] turns a group's or layer's own fills, strokes and geometry effects into
@@ -154,6 +154,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const ARTISTIC: &[&str] = &["Effect", "Artistic"];
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
const DISTORT: &[&str] = &["Effect", "Distort"];
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
@@ -298,6 +299,13 @@ 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(
"artistic.filmGrain",
"Film Grain…",
ARTISTIC,
"{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}),
),
r(
"blur.radial",
"Radial Blur…",
+136
View File
@@ -0,0 +1,136 @@
//! Effect › Artistic. Original filters built from public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
//! These are visual approximations, not reproductions of a proprietary algorithm.
use super::PixelSpace;
use super::pixelate::{Place, cell, rand};
use super::texture::{luma, premultiply, straight};
use vectorcraft_geom::Point;
const SALT_FILM: u64 = 256;
/// Keep the last cell's four corner values: magnified adjacent pixels often share a cell.
/// The cache changes only cost, never the field, and needs constant storage per raster.
#[derive(Default)]
struct CellNoise {
last: Option<(i64, i64, [f64; 4])>,
}
impl CellNoise {
fn at(&mut self, salt: u64, q: Point) -> f64 {
let (Some(i), Some(j)) = (cell(q.x), cell(q.y)) else { return 0.0 };
let corners = match self.last {
Some((x, y, values)) if x == i && y == j => values,
_ => {
let at = |di: i64, dj: i64| 2.0 * rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0) - 1.0;
let values = [at(0, 0), at(1, 0), at(0, 1), at(1, 1)];
self.last = Some((i, j, values));
values
}
};
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
let (fx, fy) = (smooth(q.x - i as f64), smooth(q.y - j as f64));
let [a, b, c, d] = corners;
let top = a + (b - a) * fx;
let bottom = c + (d - c) * fx;
// Divide by the root sum of squared interpolation weights so corners and cell
// interiors have the same variance, rather than imprinting a contrast grid.
let norm = ((fx * fx + (1.0 - fx).powi(2)) * (fy * fy + (1.0 - fy).powi(2))).sqrt();
(top + (bottom - top) * fy) / norm
}
}
#[derive(Default)]
struct FilmNoise {
low: CellNoise,
high: CellNoise,
}
impl FilmNoise {
fn at(&mut self, q: Point) -> f32 {
// Fixed independent rotations (0.6 and -0.35 radians) and a second-octave offset
// keep the two lattices from lining up with pixel rows or with each other.
let low = Point::new(0.8253356149096783 * q.x - 0.5646424733950354 * q.y, 0.5646424733950354 * q.x + 0.8253356149096783 * q.y);
let high = Point::new(
2.0 * (0.9393727128473789 * q.x + 0.34289780745545134 * q.y) + 0.37,
2.0 * (-0.34289780745545134 * q.x + 0.9393727128473789 * q.y) - 0.37,
);
// Normalising the blend can exceed the corner range, so bound the final field.
(0.8 * (0.65 * self.low.at(SALT_FILM, low) + 0.35 * self.high.at(SALT_FILM + 1, high))).clamp(-1.0, 1.0) as f32
}
}
/// Bounded footprint sampling during minification, with attenuation for very large pixels.
/// Up to four samples approximate area filtering, not exact integration of every grain.
fn grain_at(place: &Place, field: &mut FilmNoise, x: usize, y: usize) -> f32 {
if place.px <= 0.5 {
return field.at(place.at(x, y));
}
let mut sample = |dx, dy| field.at(place.to_rel * Point::new(x as f64 + dx, y as f64 + dy));
let diagonal = 0.5 * (sample(0.25, 0.25) + sample(0.75, 0.75));
// Blend sampling modes rather than changing the texture's strength suddenly at a zoom.
if place.px < 0.75 {
let t = ((place.px - 0.5) / 0.25) as f32;
return field.at(place.at(x, y)) * (1.0 - t) + diagonal * t;
}
if place.px <= 1.0 {
return diagonal;
}
let other = 0.5 * (sample(0.75, 0.25) + sample(0.25, 0.75));
let t = (0.5 * ((place.px - 1.0) / 0.5).min(1.0)) as f32;
// The four samples already average about a two-point footprint. Only wider pixels need
// extra attenuation; /px alone suppressed coarse grain twice (checked against dense sampling).
(diagonal * (1.0 - t) + other * t) / (place.px / 2.0).max(1.0) as f32
}
/// Film Grain: monochrome grain in shadows and midtones, with brighter highlights as
/// `highlight` (0..20) widens their area. `intensity` (0..10) increases the highlight lift and
/// quiets highlight grain, including when the Highlight Area is zero;
/// `grain` (0..20) sets the noise strength. The smooth grain field lies relative to the object's
/// bounds' centre, following translations and redraws; changing the bounds shifts the pattern.
/// Its scale is in document units; minification uses bounded approximate footprint filtering.
/// Coverage is preserved, including fully transparent pixels. Constant work and no allocations.
pub(super) fn film_grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, grain: f64, highlight: f64, intensity: f64) {
if ![grain, highlight, intensity].iter().all(|v| v.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let grain = (grain / 20.0).clamp(0.0, 1.0) as f32;
let highlight = (highlight / 20.0).clamp(0.0, 1.0) as f32;
let intensity = (intensity / 10.0).clamp(0.0, 1.0) as f32;
if grain == 0.0 && (intensity == 0.0 || highlight == 0.0) {
return;
}
let mut field = FilmNoise::default();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let c = straight(*p);
let brightness = luma(c);
// Wider highlight areas include more midtones, without lifting black or growing a halo.
let threshold = 0.85 - 0.6 * highlight;
let t = ((brightness - threshold) / (1.0 - threshold)).clamp(0.0, 1.0);
let light = t * t * (3.0 - 2.0 * t) * highlight * intensity * 0.65;
// Keep the grain strength even across shadows and midtones, then quiet it smoothly
// above mid-grey to a quarter-strength floor at full intensity, before highlight
// headroom. This keeps a softer grain on cream. Black takes light speckles after
// clipping; white stays clean.
// This tonal curve is an interpretation of the public description, not a measured
// proprietary transfer function. Shared offsets stay neutral until a channel clips.
let upper = ((brightness - 0.5) * 2.0).clamp(0.0, 1.0);
let upper = upper * upper * (3.0 - 2.0 * upper);
let amplitude = (0.4 * (1.0 - upper * (0.5 + 0.5 * intensity))).max(0.1).min(1.0 - brightness);
let strength = amplitude * grain * (1.0 - light);
// With no highlight lift the starting premultiplied channels are whole bytes. A
// bounded offset below half a byte cannot change rounding, so avoid hashing it.
// Leave a small margin for straight/premultiplied floating-point roundoff.
if light == 0.0 && strength * f32::from(p[3]) < 0.45 {
return;
}
let noise = if strength > 0.0 { grain_at(&place, &mut field, x, y) * strength } else { 0.0 };
let c = c.map(|v| v + (1.0 - v) * light + noise);
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
+16 -4
View File
@@ -1,4 +1,4 @@
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
@@ -12,6 +12,7 @@
//! cost doesn't grow with the radius, and sample counts come from the effect's quality.
//! - Beyond the raster's edges is transparency.
mod artistic;
mod blur;
mod brushstrokes;
mod distort;
@@ -32,7 +33,8 @@ pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 27] = [
pub const PIXEL_EFFECTS: [&str; 28] = [
"artistic.filmGrain",
"blur.radial",
"blur.smart",
"brushStrokes.accentedEdges",
@@ -66,6 +68,9 @@ pub const PIXEL_EFFECTS: [&str; 27] = [
/// are in document units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PixelFx {
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
FilmGrain { grain: f64, highlight: f64, intensity: f64 },
/// 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 },
@@ -192,6 +197,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
};
Some(match id {
"artistic.filmGrain" => PixelFx::FilmGrain {
grain: num(p, "grain", 4.0).clamp(0.0, 20.0),
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
},
"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"),
@@ -338,7 +348,8 @@ impl PixelFx {
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
PixelFx::UnsharpMask { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::ColorHalftone { .. }
PixelFx::FilmGrain { .. }
| PixelFx::ColorHalftone { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::Craquelure { .. }
| PixelFx::Grain { .. }
@@ -378,7 +389,7 @@ impl PixelFx {
// The cells around the pixel's, and the cells around theirs.
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::Mezzotint { .. } => Some(0.0),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
// The dots' points, and the softened colour around them.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
@@ -399,6 +410,7 @@ impl PixelFx {
let Some(px) = pixels(data, w, h) else { return };
let to_px = |len: f64| len / space.px.max(1e-9);
match *self {
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
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::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness),
+412
View File
@@ -45,6 +45,418 @@ fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
#[test]
fn film_grain_parameters_are_bounded_and_neutral_settings_preserve_the_art() {
assert_eq!(fx("artistic.filmGrain", json!({})), PixelFx::FilmGrain { grain: 4.0, highlight: 0.0, intensity: 10.0 });
assert_eq!(
fx("artistic.filmGrain", json!({"grain": 1e308, "highlightArea": -100, "intensity": 200})),
PixelFx::FilmGrain { grain: 20.0, highlight: 0.0, intensity: 10.0 }
);
assert_eq!(fx("artistic.filmGrain", json!({"grain": "1e999", "intensity": []})), fx("artistic.filmGrain", json!({})));
let (w, h) = (24, 18);
let src = image(w, h, |x, y| [(3 * x) as u8, (2 * y) as u8, 12, 128]);
let mut out = src.clone();
let neutral = fx("artistic.filmGrain", json!({"grain": 0, "highlightArea": 0, "intensity": 0}));
neutral.apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src);
assert_eq!(neutral.outset(Rect::new(0.0, 0.0, w as f64, h as f64)), 0.0);
assert_eq!(neutral.reach(), Some(0.0));
}
#[test]
fn film_grain_is_monochrome_and_quieter_in_highlights() {
let (w, h) = (64, 48);
let grain = fx("artistic.filmGrain", json!({"grain": 20, "intensity": 0}));
let mut changes = Vec::new();
for tone in [64, 128, 240] {
let mut out = image(w, h, |_, _| grey(tone));
grain.apply(&mut out, w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255));
changes.push(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0].abs_diff(tone))).sum::<f64>() / (w * h) as f64);
}
assert!(changes[0] > 3.0 * changes[2] && changes[1] > 3.0 * changes[2], "shadows and midtones take more grain: {changes:?}");
}
#[test]
fn film_grain_highlight_area_and_intensity_change_the_tones() {
let (w, h) = (16, 16);
let apply = |tone, area, intensity| {
let mut out = image(w, h, |_, _| grey(tone));
fx("artistic.filmGrain", json!({"grain": 0, "highlightArea": area, "intensity": intensity})).apply(&mut out, w, h, &space(w, h));
out[0]
};
assert_eq!(apply(64, 0, 10), 64, "intensity alone doesn't recolour shadows");
assert_eq!(apply(192, 0, 10), 192, "intensity alone doesn't add global contrast");
assert!(apply(192, 20, 10) > apply(192, 20, 0), "intensity sets highlight strength");
let tone = 192;
let mut strong = image(w, h, |_, _| grey(tone));
let mut quiet = strong.clone();
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 0, "intensity": 0})).apply(&mut strong, w, h, &space(w, h));
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 0, "intensity": 10})).apply(&mut quiet, w, h, &space(w, h));
let spread = |data: &[u8]| data.as_chunks::<4>().0.iter().map(|p| u32::from(p[0].abs_diff(tone))).sum::<u32>();
assert!(spread(&strong) > spread(&quiet), "Intensity also quiets highlight grain with the default Highlight Area");
let highlights = [0, 10, 20].map(|area| apply(192, area, 10));
assert!(highlights.windows(2).all(|p| p[0] < p[1]), "wider highlight area lifts more midtones: {highlights:?}");
assert_eq!(apply(0, 20, 10), 0, "black doesn't become a white background");
}
#[test]
fn film_grain_preserves_coverage_without_a_halo() {
let (w, h) = (40, 30);
let src = image(w, h, |x, y| match (x + y) % 5 {
0 => [0; 4],
1 => [1, 0, 1, 1],
2 => [75, 12, 25, 128],
_ => [180, 60, 90, 255],
});
let mut out = src.clone();
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 20})).apply(&mut out, w, h, &space(w, h));
assert_ne!(out, src);
for (p, original) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], original[3]);
assert!(p[..3].iter().all(|c| *c <= p[3]), "premultiplied: {p:?}");
if original[3] == 0 {
assert_eq!(p, original);
}
}
}
#[test]
fn film_grain_pattern_survives_redraws_moves_resolution_changes_and_tiles() {
let (w, h) = (32, 24);
let src = image(w, h, |_, _| grey(128));
let grain = fx("artistic.filmGrain", json!({"grain": 15}));
let mut whole = src.clone();
grain.apply(&mut whole, w, h, &space(w, h));
let mut again = src.clone();
grain.apply(&mut again, w, h, &space(w, h));
assert_eq!(whole, again);
let moved =
PixelSpace { to_doc: Affine::translate((17.0, -9.0)), center: space(w, h).center + vectorcraft_geom::Vec2::new(17.0, -9.0), ..space(w, h) };
let mut shifted = src;
grain.apply(&mut shifted, w, h, &moved);
assert_eq!(whole, shifted);
let mut two = image(2 * w, 2 * h, |_, _| grey(128));
grain.apply(&mut two, 2 * w, 2 * h, &PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) });
let mut difference = 0u64;
for y in 0..h {
for x in 0..w {
let down = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| u32::from(at(&two, 2 * w, 2 * x + dx, 2 * y + dy)[0]));
let down = (down.iter().sum::<u32>() / 4) as u8;
difference += u64::from(at(&whole, w, x, y)[0].abs_diff(down));
}
}
let mean_delta = difference as f64 / (w * h) as f64;
let fine_deviation = film_grain_deviation(two.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
// Two diagonal samples approximate the four point samples in the finer raster. Bound
// their error relative to grain strength, so a stronger tonal curve does not fail it.
assert!(mean_delta < 0.25 * fine_deviation, "the field keeps its document scale: mean delta {mean_delta}, point deviation {fine_deviation}");
let (tw, th) = (13, 11);
let mut tile = image(tw, th, |_, _| grey(128));
grain.apply(&mut tile, tw, th, &PixelSpace { to_doc: Affine::translate((5.0, 3.0)), ..space(w, h) });
for y in 0..th {
for x in 0..tw {
assert_eq!(at(&tile, tw, x, y), at(&whole, w, x + 5, y + 3), "no tile seam");
}
}
}
#[test]
fn film_grain_keeps_colour_without_an_extra_contrast_curve_and_scales_with_alpha() {
let (w, h) = (256, 256);
let original = [180, 100, 60, 255];
let mut out = image(w, h, |_, _| original);
// Intensity with no highlight area must not silently change colour or clip channels.
fx("artistic.filmGrain", json!({"grain": 0, "intensity": 10})).apply(&mut out, w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().all(|p| *p == original));
let grain = fx("artistic.filmGrain", json!({"grain": 4}));
grain.apply(&mut out, w, h, &space(w, h));
for p in out.as_chunks::<4>().0 {
// A neutral floating offset can round either side of a half-byte tie in each channel.
assert!((i32::from(p[0]) - i32::from(p[1]) - 80).abs() <= 1, "one neutral offset, no channel clipping");
assert!((i32::from(p[1]) - i32::from(p[2]) - 40).abs() <= 1);
}
// These straight colours have exact premultiplied representations at alpha 85 and 170.
let colour = [180, 120, 60];
let mut opaque = image(w, h, |_, _| [colour[0], colour[1], colour[2], 255]);
grain.apply(&mut opaque, w, h, &space(w, h));
for alpha in [85u8, 170] {
let mut translucent = image(w, h, |_, _| {
let [r, g, b] = colour.map(|c| (u32::from(c) * u32::from(alpha) / 255) as u8);
[r, g, b, alpha]
});
grain.apply(&mut translucent, w, h, &space(w, h));
for (p, full) in translucent.as_chunks::<4>().0.iter().zip(opaque.as_chunks::<4>().0) {
for c in 0..3 {
let expected = (u32::from(full[c]) * u32::from(alpha) + 127) / 255;
assert!(i32::from(p[c]).abs_diff(expected as i32) <= 1, "alpha scales colour consistently: {p:?}, {full:?}");
}
assert_eq!(p[3], alpha);
}
}
let mut white = image(w, h, |_, _| grey(255));
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 20, "intensity": 10})).apply(&mut white, w, h, &space(w, h));
assert!(white.as_chunks::<4>().0.iter().all(|p| *p == grey(255)), "white stays clean");
}
#[test]
fn film_grain_is_visible_on_dark_fills_with_even_shadow_and_midtone_strength() {
let (w, h) = (128, 96);
let grain = fx("artistic.filmGrain", json!({}));
let render = |tone| {
let mut out = image(w, h, |_, _| grey(tone));
grain.apply(&mut out, w, h, &space(w, h));
out
};
let black = render(0);
assert!(black.as_chunks::<4>().0.iter().any(|p| p[0] > 2), "pure black takes light grain speckles");
assert!(film_grain_deviation(black.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))) > 2.0);
let dark = render(26);
let mid = render(128);
let [dark, mid] = [dark, mid].map(|out| film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))));
assert!(dark > 4.0 && (dark / mid - 1.0).abs() < 0.02, "default grain must remain even across shadows and midtones: {dark}, {mid}");
}
#[test]
fn film_grain_leaves_soft_but_visible_grain_on_light_fills_at_defaults() {
let (w, h) = (128, 96);
let colour = [245, 240, 225, 255];
let mut cream = image(w, h, |_, _| colour);
fx("artistic.filmGrain", json!({})).apply(&mut cream, w, h, &space(w, h));
let deviation = film_grain_deviation(cream.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
let changed = cream.as_chunks::<4>().0.iter().filter(|p| **p != colour).count();
assert!((0.5..3.0).contains(&deviation), "soft grain must remain visible on cream: {deviation}");
assert!(changed > w * h / 4, "grain should cover the fill, not just a few outliers: {changed}");
assert!(cream.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
}
#[test]
fn film_grain_tiles_match_at_fractional_centres_and_rotated_scales() {
let (w, h) = (40, 32);
let grain = fx("artistic.filmGrain", json!({"grain": 15}));
for scale in [0.7, 2.5] {
let s = PixelSpace { to_doc: Affine::rotate(0.37) * Affine::scale(scale), px: scale, center: Point::new(12.375, -4.625), ..space(w, h) };
let mut whole = image(w, h, |_, _| grey(128));
grain.apply(&mut whole, w, h, &s);
let (tw, th) = (19, 13);
let mut tile = image(tw, th, |_, _| grey(128));
grain.apply(&mut tile, tw, th, &PixelSpace { to_doc: s.to_doc * Affine::translate((7.0, 5.0)), ..s });
for y in 0..th {
for x in 0..tw {
assert_eq!(at(&tile, tw, x, y), at(&whole, w, x + 7, y + 5), "fractional rotated tile seam at scale {scale}");
}
}
}
}
#[test]
fn film_grain_minification_quiets_the_noise_instead_of_aliasing_at_full_strength() {
let (w, h) = (64, 64);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
let spread = |scale| {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(scale), px: scale, ..space(w, h) });
out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0].abs_diff(128))).sum::<f64>() / (w * h) as f64
};
let normal = spread(1.0);
assert!(normal > 2.0);
assert!(spread(2.0) < normal && spread(4.0) < normal / 2.0);
}
#[test]
fn film_grain_sampling_modes_blend_without_a_zoom_threshold_pop() {
let (w, h) = (64, 48);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
for boundary in [0.5, 0.75, 1.0, 1.5, 2.0] {
// Hold sample coordinates fixed to isolate the footprint-mode boundary. Changing
// to_doc as well moves the texture, which can exceed this limit far from the origin.
let render = |scale| {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(boundary), px: scale, ..space(w, h) });
out
};
let (left, right) = (render(boundary - 1e-4), render(boundary + 1e-4));
let change = left.iter().zip(right).map(|(a, b)| f64::from(a.abs_diff(b))).sum::<f64>() / left.len() as f64;
assert!(change < 0.25, "no sudden grain change around px={boundary}: {change}");
}
}
#[test]
fn film_grain_coarse_variance_tracks_an_independent_dense_footprint_average() {
let (w, h, samples) = (32, 32, 16);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
let deviation = |values: &[f64]| {
let mean = values.iter().sum::<f64>() / values.len() as f64;
(values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64).sqrt()
};
for scale in [1.0, 2.0, 4.0, 8.0] {
let s = PixelSpace {
to_doc: Affine::scale(scale),
px: scale,
center: Point::new(w as f64 * scale / 2.0 + 0.375, h as f64 * scale / 2.0 - 0.625),
..space(w, h)
};
let mut actual = image(w, h, |_, _| grey(128));
grain.apply(&mut actual, w, h, &s);
// 16×16 independent point samples per coarse pixel; the finest raster never takes
// the footprint branch, so this does not reproduce the filter's quadrature weights.
let fine_scale = scale / samples as f64;
let mut fine = image(w * samples, h * samples, |_, _| grey(128));
grain.apply(&mut fine, w * samples, h * samples, &PixelSpace { to_doc: Affine::scale(fine_scale), px: fine_scale, ..s });
let mut reference = Vec::new();
let mut coarse = Vec::new();
for y in 0..h {
for x in 0..w {
let mut sum = 0u32;
for dy in 0..samples {
for dx in 0..samples {
sum += u32::from(at(&fine, w * samples, x * samples + dx, y * samples + dy)[0]);
}
}
reference.push(f64::from(sum) / (samples * samples) as f64);
coarse.push(f64::from(at(&actual, w, x, y)[0]));
}
}
let ratio = deviation(&coarse) / deviation(&reference);
assert!((0.75..=1.25).contains(&ratio), "bounded footprint approximation at px={scale}: relative deviation {ratio}");
}
}
#[test]
fn film_grain_strength_is_stable_under_subpixel_centre_shifts() {
let (w, h) = (192, 128);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
for scale in [0.25, 0.5, 1.0] {
let mut deviations = Vec::new();
for phase in [0.0, 0.25, 0.5, 0.75] {
let mut out = image(w, h, |_, _| grey(128));
let s = PixelSpace {
to_doc: Affine::scale(scale),
px: scale,
center: Point::new(w as f64 * scale / 2.0 + phase, h as f64 * scale / 2.0 + phase),
..space(w, h)
};
grain.apply(&mut out, w, h, &s);
deviations.push(film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))));
}
let min = deviations.iter().copied().fold(f64::INFINITY, f64::min);
let max = deviations.iter().copied().fold(0.0, f64::max);
assert!(min > 1.0 && max / min < 1.06, "centre phase must not change grain strength at px={scale}: {deviations:?}");
}
}
fn film_grain_deviation(values: impl Iterator<Item = f64>) -> f64 {
let (mut count, mut sum, mut square_sum) = (0.0, 0.0, 0.0);
for v in values {
count += 1.0;
sum += v;
square_sum += v * v;
}
(square_sum / count - (sum / count).powi(2)).max(0.0).sqrt()
}
#[test]
fn film_grain_preserves_byte_values_when_the_grain_is_too_small_to_round() {
let (w, h) = (256, 256);
let src = image(w, h, |x, y| {
let alpha = y as u8;
[(x as u8).min(alpha), (255 - x as u8).min(alpha), (x / 2) as u8 % alpha.max(1), alpha]
});
let mut out = src.clone();
// Even the maximum field value cannot change half a premultiplied byte at this strength.
fx("artistic.filmGrain", json!({"grain": 0.01})).apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src, "invisible noise preserves every colour/coverage combination");
}
#[test]
fn film_grain_cmy_and_k_planes_share_the_noise_and_preserve_coverage() {
let (w, h) = (64, 48);
let grain = fx("artistic.filmGrain", json!({"grain": 14}));
// Complemented inks of a rich black, premultiplied by the same coverage.
let mut cmy = image(w, h, |_, _| [70, 80, 90, 200]);
let mut k = image(w, h, |_, _| [40, 40, 40, 200]);
grain.apply(&mut cmy, w, h, &PixelSpace { channels: Channels::CmyPlane, ..space(w, h) });
grain.apply(&mut k, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
let mut changed = 0;
for (cmy, k) in cmy.as_chunks::<4>().0.iter().zip(k.as_chunks::<4>().0) {
assert_eq!((cmy[3], k[3]), (200, 200));
assert!(cmy[..3].iter().all(|v| *v <= 200) && k[..3].iter().all(|v| *v <= 200));
assert_eq!(k[0], k[1]);
assert_eq!(k[1], k[2]);
let (dc, dk) = (i16::from(cmy[0]) - 70, i16::from(k[0]) - 40);
if dc != 0 && dk != 0 {
assert_eq!(dc.signum(), dk.signum(), "the same field modulates both ink planes");
changed += 1;
}
}
assert!(changed > w * h / 2);
}
#[test]
fn film_grain_does_not_imprint_a_periodic_contrast_grid_at_close_zoom() {
let (w, h) = (256, 256);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
for (scale, period) in [(0.25, 4), (0.125, 8)] {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(scale), px: scale, center: Point::new(32.0, 32.0), ..space(w, h) });
// Bin by location within a document-unit lattice cell. An axis-aligned value field
// puts stronger noise at corners than between them; rotated normalised fields do not.
let mut deviations = Vec::new();
for dy in 0..period {
for dx in 0..period {
let values = (dy..h).step_by(period).flat_map(|y| (dx..w).step_by(period).map(move |x| (x, y)));
deviations.push(film_grain_deviation(values.map(|(x, y)| f64::from(at(&out, w, x, y)[0]))));
}
}
let min = deviations.iter().copied().fold(f64::INFINITY, f64::min);
let max = deviations.iter().copied().fold(0.0, f64::max);
assert!(max / min < 1.12, "no visible cell-contrast grid at px={scale}: min={min}, max={max}");
}
}
#[test]
fn film_grain_strength_falls_smoothly_across_noninteger_zoom_levels() {
let (w, h) = (128, 96);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
let mut previous = f64::INFINITY;
for scale in [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0] {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(
&mut out,
w,
h,
&PixelSpace {
to_doc: Affine::scale(scale),
px: scale,
center: Point::new(w as f64 * scale / 2.0 + 0.375, h as f64 * scale / 2.0 - 0.625),
..space(w, h)
},
);
let deviation = film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
assert!(deviation <= previous * 1.06, "grain must not gain contrast when zooming out at px={scale}: {deviation} after {previous}");
previous = deviation;
}
}
#[test]
fn film_grain_leaves_invalid_coordinate_spaces_alone() {
let (w, h) = (8, 8);
let src = image(w, h, |_, _| grey(128));
for invalid in [
PixelSpace { px: 0.0, ..space(w, h) },
PixelSpace { px: f64::NAN, ..space(w, h) },
PixelSpace { to_doc: Affine::scale(0.0), ..space(w, h) },
PixelSpace { center: Point::new(f64::INFINITY, 0.0), ..space(w, h) },
] {
let mut out = src.clone();
fx("artistic.filmGrain", json!({})).apply(&mut out, w, h, &invalid);
assert_eq!(out, src);
}
let mut out = src.clone();
PixelFx::FilmGrain { grain: f64::NAN, highlight: 0.0, intensity: 10.0 }.apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src, "directly constructed non-finite parameters are harmless too");
}
#[test]
fn params_take_defaults_and_stay_in_range() {
assert_eq!(fx("blur.radial", json!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6 });
+6 -2
View File
@@ -30,6 +30,9 @@ const BRUSH_STROKES: [(&str, &str); 8] = [
("brushStrokes.sumiE", "{}"),
];
/// Effect › Artistic › Film Grain, at its defaults.
const ARTISTIC: [(&str, &str); 1] = [("artistic.filmGrain", "{}")];
/// Effect › Distort (Diffuse Glow with its highlights glowing from a low brightness up, as the
/// striped art's reds are dark).
const DISTORT: [(&str, &str); 3] =
@@ -90,6 +93,7 @@ fn listed_applied_edited_and_undone() {
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"])]);
for (group, menu) in [
(ARTISTIC.as_slice(), "Artistic"),
(BRUSH_STROKES.as_slice(), "Brush Strokes"),
(DISTORT.as_slice(), "Distort"),
(PIXELATE.as_slice(), "Pixelate"),
@@ -121,7 +125,7 @@ fn listed_applied_edited_and_undone() {
#[test]
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
for (id, params) in EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
let (mut s, g) = striped();
let plain = render(&s);
let params: Value = serde_json::from_str(params).unwrap();
@@ -150,7 +154,7 @@ fn spin_blur_reaches_past_the_corners() {
#[test]
fn pdf_and_svg_write_the_effected_object_as_an_image() {
for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
for (id, params) in EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
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();
+23
View File
@@ -139,6 +139,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()),
None if id == "artistic.filmGrain" => film_grain_fields(ui, d),
None => {
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
let unit = app.session.general_unit();
@@ -160,6 +161,28 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// Film Grain's bounded, whole-number controls, in the same order as its parameter docs.
pub(crate) const FILM_GRAIN_FIELDS: [(&str, &str, f64); 3] =
[("grain", "Grain", 20.0), ("highlightArea", "Highlight Area", 20.0), ("intensity", "Intensity", 10.0)];
fn film_grain_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let before = d.fields.clone();
let rail = |v: f32| egui::Color32::from_gray((48.0 + 192.0 * v).round() as u8);
// Use one column wide enough for this language's longest label, keeping every rail aligned.
let font = egui::TextStyle::Body.resolve(ui.style());
let text = Tokens::get(ui.ctx()).text;
let label_w = FILM_GRAIN_FIELDS
.iter()
.map(|(_, label, _)| ui.painter().layout_no_wrap(tl!(label).to_string(), font.clone(), text).size().x)
.fold(form::SLIDER_LABEL, f32::max)
.ceil()
+ 4.0;
for (key, label, max) in FILM_GRAIN_FIELDS {
form::slider_w(ui, d, (key, label, label_w), 0.0..=max, "", &rail);
}
before != d.fields
}
/// 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) {
+1
View File
@@ -98,6 +98,7 @@ use serde_json::{Value, json};
pub use color_picker::open as open_color_picker;
pub use document_setup::open as open_document_setup;
pub(crate) use effect::FILM_GRAIN_FIELDS;
pub use effect::open as open_effect_dialog;
pub use export_as::open as open_export_as;
pub use export_for_screens::open as open_export_for_screens;
+3
View File
@@ -4459,3 +4459,6 @@ effect Regular Pravidelný
24 bits/pixel 24 bitů na pixel
32 bits/pixel 32 bitů na pixel
DWG (use DXF) DWG (použijte DXF)
Artistic Umělecké
Film Grain… Filmové zrno…
Highlight Area Oblast světel
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4130,3 +4130,7 @@ theme Appearance Erscheinungsbild
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
Linux Display Server Linux-Anzeigeserver
X11 (XWayland) X11 (XWayland)
Artistic Kunstfilter
Film Grain… Filmkörnung…
Grain Körnung
Highlight Area Lichterbereich
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4174,3 +4174,6 @@ lighting Light Luz
@msg unsupported native docking operation Operación nativa de anclaje no admitida
Linux Display Server Servidor de pantalla de Linux
X11 (XWayland) X11 (XWayland)
Artistic Artístico
Film Grain… Granulado de película…
Highlight Area Área de iluminaciones
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4184,3 +4184,6 @@ lighting Light Lumière
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
Linux Display Server Serveur d’affichage Linux
X11 (XWayland) X11 (XWayland)
Artistic Artistique
Film Grain… Grain photographique…
Highlight Area Zone des tons clairs
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4176,3 +4176,6 @@ lighting Light Luce
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
Linux Display Server Server di visualizzazione Linux
X11 (XWayland) X11 (XWayland)
Artistic Artistico
Film Grain… Grana della pellicola…
Highlight Area Area luci
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4170,3 +4170,6 @@ lighting Light ライト
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
Linux Display Server Linux ディスプレイサーバー
X11 (XWayland) X11 (XWayland)
Artistic アーティスティック
Film Grain… フィルム粒子…
Highlight Area ハイライト領域
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3668,3 +3668,7 @@ axis Both Ambos
@msg invalid native panel set conjunto de painéis nativos inválido
@msg missing panel layout layout de painéis ausente
@msg unsupported native docking operation operação de encaixe nativa não suportada
Artistic Artístico
Film Grain… Granulação de filme…
Grain Grão
Highlight Area Área de realces
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -4172,3 +4172,6 @@ lighting Light Свет
@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления
Linux Display Server Дисплейный сервер Linux
X11 (XWayland) X11 (XWayland)
Artistic Художественные
Film Grain… Зернистость плёнки…
Highlight Area Область светов
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4156,3 +4156,6 @@ lighting Light Світло
@msg unsupported native docking operation Непідтримувана власна операція прикріплення
Linux Display Server Дисплейний сервер Linux
X11 (XWayland) X11 (XWayland)
Artistic Художні
Film Grain… Зернистість плівки…
Highlight Area Область світлих тонів
Can't render this file because it contains an unexpected character in line 659 and column 40.
+3
View File
@@ -3774,3 +3774,6 @@ lighting Light 光源
Move Guide to Layer 将参考线移至图层
Linux Display Server Linux 显示服务器
X11 (XWayland) X11 (XWayland)
Artistic 艺术效果
Film Grain… 胶片颗粒…
Highlight Area 高光区域
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3555,3 +3555,6 @@ lighting Light 光源
Move Guide to Layer 將參考線移至圖層
Linux Display Server Linux 顯示伺服器
X11 (XWayland) X11 (XWayland)
Artistic 藝術效果
Film Grain… 底片顆粒…
Highlight Area 亮部範圍
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -1
View File
@@ -3403,7 +3403,8 @@ fn font_items(english_names: bool) -> Vec<Item> {
/// 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]); 7] = [
const RASTER_MENUS: [(&str, &[&str]); 8] = [
("Artistic", &["Effect", "Artistic"]),
("Blur", &["Effect", "Blur"]),
("Brush Strokes", &["Effect", "Brush Strokes"]),
("Distort", &["Effect", "Distort"]),
@@ -3620,6 +3621,7 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
}
out.extend(crate::prefs_dialog::UI_FIELDS.iter().map(|f| f.2.to_string()));
out.extend(crate::dialogs::button_labels().into_iter().map(str::to_string));
out.extend(crate::dialogs::FILM_GRAIN_FIELDS.iter().map(|(_, label, _)| label.to_string()));
out.extend(crate::chrome::hint_strings().into_iter().map(str::to_string));
out.remove("");
out
+34
View File
@@ -29,6 +29,40 @@ fn the_effect_menu_lists_the_adjustments_and_the_object_menu_the_halftone() {
assert!(items("Object").contains("Vector Halftone…"));
}
#[test]
fn film_grain_dialog_previews_cancels_and_edits_as_one_undo_step() {
let mut app = app();
app.run("effect.dialog", json!({"effect": "artistic.filmGrain"})).unwrap();
let text = dialog_text(&mut app);
for label in ["Film Grain", "Grain", "Highlight Area", "Intensity", "Preview"] {
assert!(text.lines().any(|line| line == label), "{label} in {text}");
}
assert!(app.session.in_interaction());
dialogs::cancel(&mut app);
let effects = |app: &VectorcraftApp| {
let st = app.session.active().unwrap();
st.doc.node(st.selection.objects[0]).unwrap().appearance.effects.clone()
};
assert!(effects(&app).is_empty(), "Cancel removes the preview");
app.run("effect.dialog", json!({"effect": "artistic.filmGrain"})).unwrap();
dialog_text(&mut app);
app.ui.dialog.as_mut().unwrap().fields.insert("grain".into(), json!(12));
dialog_text(&mut app);
let before = app.session.doc().unwrap().history.undo.len();
dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().history.undo.len(), before + 1);
assert_eq!(effects(&app)[0].params["grain"], 12);
app.run("effect.dialog", json!({"effect": "artistic.filmGrain", "index": 0})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("grain", 0.0), 12.0);
app.ui.dialog.as_mut().unwrap().fields.insert("grain".into(), json!(7));
dialog_text(&mut app);
dialogs::confirm(&mut app).unwrap();
assert_eq!(effects(&app).len(), 1, "editing doesn't add a second effect");
assert_eq!(effects(&app)[0].params["grain"], 7);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(effects(&app)[0].params["grain"], 12);
}
#[test]
fn an_adjustment_dialog_has_sliders_and_previews_live() {
let mut app = app();
+12
View File
@@ -50,6 +50,18 @@ fn effect_menu_has_a_vector_effects_section() {
assert!(effect.iter().any(|i| matches!(i, Item::Header("Vector Effects"))));
}
#[test]
fn effect_menu_lists_film_grain_in_artistic_before_blur() {
let app = app();
let entries = menus::menu_entries(&app);
let artistic: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Artistic"]).collect();
assert_eq!(artistic.len(), 1);
assert_eq!(artistic[0].label, "Film Grain…");
assert_eq!(artistic[0].command.as_deref(), Some("effect.dialog"));
let at = |name: &str| entries.iter().position(|e| e.path == ["Effect", name]).unwrap();
assert!(at("Artistic") < at("Blur"));
}
#[test]
fn effect_menu_lists_the_pixelate_filters_between_blur_and_sharpen() {
let app = app();
+30 -4
View File
@@ -1,6 +1,6 @@
# Effects parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (PDF raster-effect resolution-cap warnings; Zig Zag's dialog with sliders and radio buttons, #1133; earlier: Brush Strokes landed) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G2: Film Grain added; Photoshop effects 29/57; 28 filters remain) · **Target:** Adobe Illustrator 2026 (30.x)
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
@@ -12,7 +12,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
| Group | Illustrator | VectorCraft | % | Remaining (h) |
|---|---:|---:|---:|---:|
| Illustrator effects (vector and stylize) | 50 | 44 | 88% | 6–10 |
| Photoshop effects (raster, Effect Gallery) | 57 | 28 | 49% | 24–40 |
| Photoshop effects (raster, Effect Gallery) | 57 | 29 | 51% | 23–39 |
| 3D and Materials | 4 (+3 Classic) | 1 (initial Revolve) | ~8% | 45–75 |
| Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — |
@@ -38,7 +38,7 @@ its resolution, PDF export warns once per object with the requested and the lowe
| Submenu | Items | Ours | Missing |
|---|---|---:|---|
| Artistic | Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting, Watercolor | 0/15 | all |
| Artistic | Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting, Watercolor | 1/15 (approximate Film Grain) | Colored Pencil, Cutout, Dry Brush, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting, Watercolor |
| Blur | Gaussian Blur, Radial Blur, Smart Blur | 3/3 | Smart Blur's Edge Only and Overlay Edge modes |
| Brush Strokes | Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e | 8/8 | — |
| Distort | Diffuse Glow, Glass, Ocean Ripple | 3/3 | Glass's Load Texture |
@@ -52,9 +52,34 @@ its resolution, PDF export warns once per object with the requested and the lowe
The pixel-filter pipeline (`vectorcraft_effects::pixel`) is in place: each new filter is one function, a
catalogue row, a menu entry and translations. Measured throughput: 0.7–1.2 agent hours per filter
(see [calibration](target-app-parity.md#calibration)), so the 29 missing filters are ~20–35 h and the Effect
(see [calibration](target-app-parity.md#calibration)), so the 28 missing filters are ~19–34 h and the Effect
Gallery dialog another 4–6 h. These parallelize well across agents.
Film Grain (`artistic.filmGrain`, Effect › Artistic) adds stable monochrome noise to shadows and
midtones, with Grain, Highlight Area and Intensity sliders, live preview and appearance editing.
Its smooth grain field follows translations, preserves transparency and stays in document units;
zoomed-out rendering uses bounded approximate footprint filtering. Changing the bounds shifts
the pattern. Command exports bake it to an image in SVG and PDF, while the native document keeps the effect editable. Behaviour is an original visual
approximation based on [Illustrator's public effects reference](https://helpx.adobe.com/illustrator/using/summary-effects.html)
and the [public Artistic filters guide](https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html),
not a pixel-identical proprietary implementation. Intensity is interpreted as highlight strength and
quieter highlight grain. Grain strength stays even across shadows and midtones and rolls off above
mid-grey to a quarter-strength floor at full Intensity before highlight headroom; cream retains
soft grain, black takes light speckles and white stays clean. The tonal curve and endpoint behaviour
are interpretations, not verified proprietary behaviour. Saturated colour channels may clip. The parameter
ranges and defaults are chosen for VectorCraft, not asserted as measured proprietary defaults.
Near white, highlight headroom can limit grain equally at every Intensity; highlight lift still
depends on Intensity when Highlight Area is nonzero.
The two rotated, variance-normalised noise layers can leave a faint diagonal weave at high
magnification. Removing that remaining direction would require a more expensive field; the
current implementation keeps bounded per-pixel work. Film Grain is still slower than Texture
Grain (Regular), particularly when its wider footprint needs four samples.
Calibration on uniform grey at Grain 20, using 32×32 fine samples per coarse pixel, measured
grain standard deviation within about 15% of the dense area average at pixel widths of 2, 4
and 8 document units (ratios 1.13, 0.89, 0.85). This is evidence for the bounded approximation,
not an accuracy guarantee for arbitrary artwork. Tests: `pixel::tests::film_grain_*`,
`tests_rasterfilters`, `tests_adjust::film_grain_dialog_*` and `tests_labels::effect_menu_lists_film_grain_*`.
## 3D and Materials
| Item | Ours |
@@ -74,6 +99,7 @@ rendering, Turntable, 3D export) is 45–75 h, still the largest single gap.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | G2: Artistic › Film Grain added with live controls and raster exports; measured Photoshop filters 29/57, 28 remaining (23–39 h). |
| 2026-10-11 | minor | PDF cap warnings identify original objects after appearance splitting and report the lowest achieved ppi; cap/rendering and scores unchanged |
| 2026-10-11 | minor | Zig Zag's dialog: Size and Ridges per segment sliders, Points as Smooth / Corner radio buttons (#1133) |
| 2026-10-11 | minor | Brush Strokes landed (8 filters): 28/57 Photoshop effects |
+7 -6
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
@@ -22,7 +22,7 @@ stage ([ROADMAP.md](../ROADMAP.md)).
| 5 | [G11 Correctness bugs in core tools](#g11-correctness-bugs-in-core-tools) | 8–15 | yes | [target-app-parity.md](target-app-parity.md) |
| 6 | [G4 Advanced type](#g4-advanced-type) | 34–47 | core part | [type-parity.md](type-parity.md) |
| 7 | [G12 Preferences and panels that don't act yet](#g12-preferences-and-panels-that-dont-act-yet) | 13–20 | yes | [ui-parity.md](ui-parity.md) |
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 24–40 | no | [effects-parity.md](effects-parity.md) |
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 23–39 | no | [effects-parity.md](effects-parity.md) |
| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 15–25 | no | [target-app-parity.md](target-app-parity.md) |
| 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 8–16 | macOS pressure | [hardware-parity.md](hardware-parity.md) |
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 17–28 | no | [target-app-parity.md](target-app-parity.md) |
@@ -127,12 +127,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G2 Photoshop-style raster effects and the Effect Gallery
- **Missing:** 29 of 57 filters (all of Artistic and Sketch), the Effect Gallery dialog, Load
- **Missing:** 28 of 57 filters (the remaining 14 Artistic filters and all of Sketch), the Effect Gallery dialog, Load
Texture for Glass and Texturizer, Smart Blur's Edge Only and Overlay Edge. Measured in
[effects-parity.md](effects-parity.md).
- **Done:** the pixel-filter pipeline and 28 filters (Blur, Brush Strokes, Distort, Pixelate, Sharpen, Stylize,
Texture, Video), Brush Strokes on 2026-10-11.
- **Estimate:** 24–40 h (measured 0.7–1.2 h per filter); parallelizes well.
- **Done:** the pixel-filter pipeline and 29 filters (Artistic: Film Grain, Blur, Brush Strokes, Distort, Pixelate, Sharpen, Stylize,
Texture, Video), Brush Strokes on 2026-10-11 and Film Grain on 2026-10-12.
- **Estimate:** 23–39 h (measured 0.7–1.2 h per filter); parallelizes well.
## G13 Illustrator 2026 (30.x) additions
@@ -219,6 +219,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | G2: Artistic › Film Grain added with live controls and raster exports; measured Photoshop filters 29/57, 28 remaining (23–39 h). |
| 2026-10-12 | minor | G10: RPM required dependencies and glibc floor metadata checked (#1275); real-distro install/start-up QA remains; estimates unchanged |
| 2026-10-12 | minor | G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged |
| 2026-10-12 | minor | G1: Curvature point display improved; existing-path interpolation request remains open (#1236, in part); estimates unchanged |
+4 -3
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
@@ -31,7 +31,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
2. **`.ai` files from real users** (G9): layer structure (#951, #868), guides (#779). 10–20 h.
3. **Launch and stability** (G10): the Windows and Linux start-up reports. 20–35 h.
4. **Core-tool bugs** (G11): Shape Builder regions (#937), Asset Export borders (#983). 8–15 h.
5. **Raster effects** (G2) continue alongside: Artistic, Sketch, then the Effect Gallery. 24–40 h.
5. **Raster effects** (G2) continue alongside: Artistic (Film Grain done; 14 left), Sketch, then the Effect Gallery. 23–39 h.
## Next
@@ -61,7 +61,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Film Grain done (29/57 Photoshop filters); initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
@@ -85,6 +85,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | G2: Artistic › Film Grain added with live controls and raster exports; measured Photoshop filters 29/57, 28 remaining (23–39 h). |
| 2026-10-12 | minor | M14: RPM runtime dependencies use shared-library capabilities and glibc 2.35 (#1275); metadata verified, distro QA pending; estimates unchanged |
| 2026-10-12 | minor | M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged |
| 2026-10-12 | minor | M2: Curvature point display improved (#1236, in part); existing-path interpolation unchanged; estimates unchanged |
+10 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -78,6 +78,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Strokes, brushes, width profiles | 5 | 88% | Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated |
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated |
| Raster effects (Effect Gallery) | 4 | 49% | 28 Photoshop-style filters (14 Artistic, 14 Sketch), the Effect Gallery dialog, Load Texture | 23–39 | measured count 29/57; depth estimated |
| Raster effects (Effect Gallery) | 4 | 48% | 29 Photoshop-style filters (Artistic, Sketch), the Effect Gallery dialog, Load Texture | 24–40 | measured count 28/57; texture redraw distances optimized (#1257), pixels verified |
| 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) |
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258) |
@@ -284,6 +285,13 @@ sandboxed WebAssembly plug-ins, and every command, gesture and dialog drivable b
- **Illustrator's private editing data, beyond its structure:** see
[file-format-parity.md](file-format-parity.md#illustrators-private-editing-data).
Film Grain (G2, 2026-10-12) is the first Artistic filter: deterministic document-space monochrome
grain, transparency-preserving tone controls, a bounded live dialog, undo and appearance editing,
with PNG rendering and SVG/PDF raster baking. Evidence: `crates/effects/src/pixel/artistic.rs`,
its `film_grain_*` tests and the canvas/export cases in `crates/engine/src/tests_rasterfilters.rs`.
This raises the measured Photoshop filter count to 29/57; the depth estimate is conservatively 49%.
The full feature/readiness headline remains unchanged at its existing rounding.
## Detailed missing items per area (2026-10-05 table, still current unless the area table above says otherwise)
The previous estimate's per-area missing lists, kept as evidence. Scores in it are superseded by the table above.
@@ -488,6 +496,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | G2: Artistic › Film Grain added with live controls and raster exports; measured Photoshop filters 29/57, 28 remaining (23–39 h). |
| 2026-10-12 | minor | Platforms: RPM runtime SONAME capabilities and glibc 2.35 floor (#1275); metadata checked, distro installation/start-up pending; scores unchanged |
| 2026-10-12 | minor | Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged |
| 2026-10-12 | minor | Drawing: Curvature uses circular point markers without direction handles or duplicate markers (#1236, in part); existing-path interpolation unchanged; score and hours unchanged (estimated) |