Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 70 additions & 6 deletions crates/effects/src/pixel/texture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,22 +189,36 @@ pub(super) fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}

/// Strict distance ordering, with the original hypot comparison at rounding/tie boundaries.
fn distance_less(a2: f64, b2: f64, exact: impl FnOnce() -> (f64, f64)) -> bool {
// Cell distances are small and finite. Preserve hypot's ordering at rounding/tie
// boundaries, without computing it for every candidate of every pixel.
if b2.is_finite() && (a2 - b2).abs() <= 8.0 * f64::EPSILON * (a2 + b2) {
let (a, b) = exact();
a < b
} else {
a2 < b2
}
}

/// The two random points nearest to (`u`, `v`) (in cells), `point(i, j)` being cell (`i`, `j`)'s,
/// found among the cells [`AROUND`] the one holding it.
fn nearest_two(u: f64, v: f64, around: &mut Around<Point>, point: impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
let (i, j) = (cell(u)?, cell(v)?);
let mut best: [(f64, Option<Point>); 2] = [(f64::INFINITY, None); 2];
for (k, (di, dj)) in AROUND.iter().enumerate() {
// A cell farther than the second point so far can't hold a nearer one.
if gap(i.wrapping_add(*di), u).hypot(gap(j.wrapping_add(*dj), v)) >= best[1].0 {
let (gx, gy) = (gap(i.wrapping_add(*di), u), gap(j.wrapping_add(*dj), v));
let exact = |p: Option<Point>| p.map_or(f64::INFINITY, |p| (p.x - u).hypot(p.y - v));
if !distance_less(gx * gx + gy * gy, best[1].0, || (gx.hypot(gy), exact(best[1].1))) {
continue;
}
let Some(p) = around.get((i, j), k, &point) else { continue };
let d = (p.x - u).hypot(p.y - v);
if d < best[0].0 {
best = [(d, Some(p)), best[0]];
} else if d < best[1].0 {
best[1] = (d, Some(p));
let d2 = (p.x - u) * (p.x - u) + (p.y - v) * (p.y - v);
if distance_less(d2, best[0].0, || (exact(Some(p)), exact(best[0].1))) {
best = [(d2, Some(p)), best[0]];
} else if distance_less(d2, best[1].0, || (exact(Some(p)), exact(best[1].1))) {
best[1] = (d2, Some(p));
}
}
Some((best[0].1?, best[1].1?))
Expand Down Expand Up @@ -574,3 +588,53 @@ pub(super) fn texturizer(
*p = premultiply(lit(straight(*p), strength * (facing(slope, toward) + ridge)), f32::from(p[3]) / 255.0);
});
}

#[cfg(test)]
mod nearest_tests {
use super::*;

/// Compare with all 25 candidates, without the production distance-pruning shortcut.
fn brute_force(u: f64, v: f64, point: &impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
let (i, j) = (cell(u)?, cell(v)?);
let mut candidates: Vec<_> = AROUND
.iter()
.map(|(di, dj)| {
let p = point(i.wrapping_add(*di), j.wrapping_add(*dj));
((p.x - u).hypot(p.y - v), p)
})
.collect();
candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
Some((candidates.first()?.1, candidates.get(1)?.1))
}

#[test]
fn nearest_points_match_all_candidates_at_cell_and_rounding_boundaries() {
let mut queries = vec![];
for y in -20..=20 {
for x in -24..=24 {
queries.push((f64::from(x) / 13.0, f64::from(y) / 11.0));
}
}
queries.extend([
(0.0, 0.0),
(0.5, 0.5),
(1.0 - f64::EPSILON, -1.0),
(1.0 + f64::EPSILON, -1.0 - f64::EPSILON),
(4.0e6, -4.0e6),
(1.0e14 + 0.25, -1.0e14 + 0.5),
(f64::INFINITY, 0.0),
(f64::NAN, 0.0),
]);
for pattern in 0..3 {
let point = |i: i64, j: i64| match pattern {
0 => point_in(SALT_CRACKS, i, j),
1 => Point::new(i as f64 + 0.5, j as f64 + 0.5),
_ => Point::new(i as f64, j as f64),
};
let mut around = Around::new();
for &(u, v) in &queries {
assert_eq!(nearest_two(u, v, &mut around, point), brute_force(u, v, &point), "pattern {pattern} at ({u}, {v})");
}
}
}
}
40 changes: 40 additions & 0 deletions crates/render/src/fx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,46 @@ mod tests {
assert_eq!(b.pixels, c.pixels);
}

/// Reproduce #1257's moving, filtered rectangle without reusing a static frame's raster.
/// Run with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn texture_drag_perf() {
for effect in ["texture.craquelure", "texture.mosaicTiles", "texture.stainedGlass"] {
let mut base = Document::new(600.0, 500.0);
base.color_mode = vectorcraft_doc::ColorMode::Cmyk;
let (layer, id) = (base.layers[0].id, base.alloc_id());
let mut n = Node::path(
id,
shapes::rectangle(Rect::new(100.0, 100.0, 309.298, 370.938)),
Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), Paint::solid(Color::BLACK), 1.0),
);
n.appearance.effects = vec![
Effect { id: "stylize.roundCorners".into(), params: json!({"radius": 25.175}), visible: true },
Effect { id: effect.into(), params: json!({}), visible: true },
];
base.insert(Some(layer), 0, n).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let view = Affine::scale(1.9007 * 1.5);
let mut r = Renderer::new();
r.threads = crate::default_threads();
let mut ms = vec![];
for frame in 0..9 {
let mut doc = base.clone();
doc.node_mut(id).unwrap().transform(Affine::translate((3.0 + f64::from(frame) * 0.75, f64::from(frame) * 0.5)), false);
let start = std::time::Instant::now();
let image = r.render(&doc, 1800, 1500, view, &opts);
let time = start.elapsed().as_secs_f64() * 1000.0;
if frame > 0 {
ms.push(time);
}
std::hint::black_box(image);
}
ms.sort_by(f64::total_cmp);
println!("{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}", (ms[3] + ms[4]) * 0.5);
}
}

#[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.
Expand Down
4 changes: 3 additions & 1 deletion docs/gaps.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Gaps

> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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 (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
Expand Down Expand Up @@ -179,6 +179,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall

## G15 Performance budgets and a GPU renderer

- **Improved (#1257):** Craquelure, Mosaic Tiles and Stained Glass reuse squared distances during nearest-cell searches, with the original distance ordering retained near rounding/tie boundaries. A brute-force boundary regression and full-viewport pixel comparisons agree with the old result. `texture_drag_perf` reproduces a moved CMYK rounded rectangle at 190.07% and DPI 1.5; timings from a busy development machine are supporting evidence, with idle Windows drag QA still pending.
- **Missing:** an idle-machine run of `vectorcraft-cli perf` at 50k paths (last: 2026-10-11 at 25k paths, every
budget met, 400% zoom 12.3 ms against 16 ms, measured), a spatial index for zoomed-in frames, dirty-region
rendering, the GPU backend spike (M5). Large-selection edits, cached live effects and Pathfinder Trim, Merge,
Expand Down Expand Up @@ -217,6 +218,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 | 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 |
| 2026-10-12 | minor | G4: full-name font matching and Missing Fonts searches verified (#1258); Windows font-service QA pending; estimate unchanged |
| 2026-10-12 | minor | G11: no-op anchor Align no longer consumes Undo, clears Redo or marks saved paths modified (#1180 follow-up); estimate unchanged |
Expand Down
Loading
Loading