Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 495694e1ac Part of #758: .ai import doesn't weigh text objects when the layers can't be used
Before deciding to use the layers of an Illustrator file, the import
drew them again for each text object made from the file's text
document, to see whether the page draws it. When the layers differ from
the page by more than the check allows even with every such object
gone (taking one out only changes what is drawn in its box), the
layers aren't used whatever is taken out: that weighing is now
skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:50 +07:00
bflatasticandClaude Opus 5.5 584f08ae70 Fix #755: blurred type no longer shifts as the view moves
Gaussian Blur and Feather drew through a filter layer whose blur works on
a coarser grid tied to the canvas: the blurred art moved by up to about
a pixel, and its steps showed, as the view moved by parts of a pixel
(scrolling, zooming about the pointer). They now blur the content's own
pixels on the CPU, as the Photoshop-style effects already do: the same
look wherever the view is, cached while only the view moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:49 +07:00
bflatasticandClaude Opus 5.5 db8be89052 Fix #830: the control channel's app.quit says when it asks first
Raw app.quit closed the window and answered null at once, so a client
couldn't tell that a save prompt was waiting. It now runs the menu's
Quit: {"pending": "saveChanges"} while a modified document is asked
about, as file.close and file.closeAll answer and the protocol
documentation says; otherwise null, and the window closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:47 +07:00
6 changed files with 179 additions and 45 deletions

No files matched your search

+8
View File
@@ -936,6 +936,14 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
return 0;
}
let mut with = view.difference(doc, rect).any;
// Taking a text object out changes only what is drawn in its box. When the art would still
// differ from the page by more than `compare` allows with every candidate gone, the layers
// aren't used whatever is pruned: don't draw them again for each one (#758).
let margin = OUTLINE_REACH + 1.0;
let boxes: f64 = found.iter().filter_map(|(_, b)| *b).map(|b| b.inflate(margin, margin).intersect(rect).area().max(0.0)).sum();
if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE {
return 0;
}
let mut removed = 0;
for (id, bounds) in found {
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
+5 -41
View File
@@ -187,10 +187,6 @@ fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter {
})
}
fn blur_filter(sigma: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None })
}
fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
let [r, g, b] = ink.rgb(c);
peniko::Color::new([r, g, b, 1.0])
@@ -495,44 +491,12 @@ impl Renderer {
c.pop_layer();
});
}
// The content itself (blurred / feathered / filtered).
let blur: f64 = rfx
.iter()
.map(|fx| match fx {
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => radius.max(0.0),
_ => 0.0,
})
.sum();
if rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) {
// The content itself (blurred / feathered / filtered), on its pixels: a filter layer's blur
// works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel
// and looked blocky as the view moved by parts of a pixel (#755).
let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0));
if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) {
self.pixel_content(ctx, f, content, rfx, paint);
} else if blur > 0.0 {
self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| {
let mut layers = 0;
for fx in rfx {
match fx {
RasterFx::Feather { radius } if *radius > 0.0 => {
c.set_transform(fr.view);
if let Some((g, rule)) = content.outline {
c.set_fill_rule(fill_rule(rule));
c.push_clip_layer(g);
layers += 1;
}
c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
layers += 1;
}
RasterFx::GaussianBlur { radius } if *radius > 0.0 => {
c.set_transform(fr.view);
c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
layers += 1;
}
_ => {}
}
}
paint(r, c, fr);
for _ in 0..layers {
c.pop_layer();
}
});
} else {
paint(self, ctx, f);
}
+2
View File
@@ -1760,6 +1760,8 @@ mod tests_fontchange;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_fxzoom;
#[cfg(test)]
mod tests_isolation;
#[cfg(test)]
mod tests_knockout;
+142
View File
@@ -0,0 +1,142 @@
//! Raster effects stay where they are, and look the same, at every zoom and pan (#755).
use serde_json::json;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, Effect, Node, NodeKind};
use vectorcraft_geom::{Point, shapes};
use super::*;
/// Type blurred by `radius` points (Gaussian Blur, or Feather when `feather`) over a rectangle
/// that overlays at half opacity; without the type when `radius` is `None`.
fn scene(radius: Option<f64>, feather: bool) -> Document {
let mut d = Document::new(200.0, 100.0);
let l = d.layers[0].id;
let mut back = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)),
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.4, 0.9)), Paint::None, 0.0),
);
back.blend = BlendMode::Overlay;
back.opacity = 0.5;
d.insert(Some(l), 0, back).unwrap();
if let Some(radius) = radius {
let style = CharStyle { size: 36.0, ..CharStyle::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(30.0, 60.0), "Hello", style))));
let id = if feather { "stylize.feather" } else { "blur.gaussian" };
t.appearance.effects.push(Effect { id: id.into(), params: json!({"radius": radius}), visible: true });
d.insert(Some(l), 1, t).unwrap();
}
d
}
/// `d` with its layer's art in one group: the overlay and the type grouped, as in #755's report.
fn grouped(mut d: Document) -> Document {
let l = d.layers[0].id;
let id = d.alloc_id();
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
d.insert(Some(l), 0, Node::new(id, NodeKind::Group { children: members, clip: false })).unwrap();
d
}
/// The canvas's options: art on nothing, no artboards.
fn screen() -> RenderOptions {
RenderOptions { background: None, artboards: false, ..Default::default() }
}
fn render(d: &Document, threads: u16, view: Affine, (w, h): (u32, u32)) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
r.render(d, w, h, view, &screen())
}
/// Where the type's ink is on the document (its centroid), rendered `threads`-threaded through
/// `view` on a `w` × `h` canvas.
fn ink_centre(radius: f64, threads: u16, view: Affine, size: (u32, u32)) -> Point {
let with = render(&scene(Some(radius), false), threads, view, size);
let without = render(&scene(None, false), threads, view, size);
let (mut sx, mut sy, mut sw) = (0.0, 0.0, 0.0);
for (i, (p, q)) in with.pixels.chunks(4).zip(without.pixels.chunks(4)).enumerate() {
let d: f64 = (0..4).map(|c| f64::from(p[c].abs_diff(q[c]))).sum();
let (x, y) = ((i % size.0 as usize) as f64 + 0.5, (i / size.0 as usize) as f64 + 0.5);
sx += d * x;
sy += d * y;
sw += d;
}
assert!(sw > 0.0, "the type shows");
view.inverse() * Point::new(sx / sw, sy / sw)
}
#[test]
fn blurred_type_stays_in_place_at_every_zoom_and_pan() {
for (radius, threads) in [(3.0, 0), (3.0, 4), (12.0, 0), (12.0, 4)] {
let base = ink_centre(radius, threads, Affine::IDENTITY, (200, 100));
// The whole document in view, at other zooms and moved by parts of a pixel.
for (zoom, pan) in
[(1.0, (13.3, 7.6)), (2.0, (41.7, 20.2)), (3.3, (15.25, 9.5)), (0.5, (10.4, 5.1)), (6.0, (30.6, 25.3)), (1.37, (0.5, 0.25))]
{
let view = Affine::translate(pan) * Affine::scale(zoom);
let size = ((200.0 * zoom + 2.0 * pan.0).ceil() as u32, (100.0 * zoom + 2.0 * pan.1).ceil() as u32);
let at = ink_centre(radius, threads, view, size);
assert!(
(at.x - base.x).abs() < 1.0 && (at.y - base.y).abs() < 1.0,
"radius {radius}, threads {threads}, zoom {zoom}, pan {pan:?}: {at:?} vs {base:?}"
);
}
}
}
/// Scrolling moves the blurred type with the page and nothing else: two views a whole number of
/// pixels apart draw the same pixels where they overlap, whether the type fits on the canvas or
/// runs off it.
#[test]
fn scrolling_moves_blurred_type_without_changing_it() {
for (feather, group) in [(false, false), (true, false), (false, true)] {
let d = scene(Some(12.0), feather);
let d = if group { grouped(d) } else { d };
for threads in [0, 4] {
for (zoom, (w, h)) in [(1.0, (240, 140)), (4.0, (500, 300)), (8.0, (400, 300))] {
let at = |dx: f64, dy: f64| render(&d, threads, Affine::translate((-dx, -dy)) * Affine::scale(zoom), (w, h));
let a = at(20.0, 10.0);
for (sx, sy) in [(1u32, 0u32), (3, 2), (37, 23), (101, 57)] {
let b = at(20.0 + f64::from(sx), 10.0 + f64::from(sy));
let mut worst = 0;
for y in sy..h {
for x in sx..w {
let i = ((y * w + x) * 4) as usize;
let j = (((y - sy) * w + x - sx) * 4) as usize;
for c in 0..4 {
worst = worst.max(a.pixels[i + c].abs_diff(b.pixels[j + c]));
}
}
}
assert!(
worst <= 3,
"feather {feather}, grouped {group}, threads {threads}, zoom {zoom}, scrolled by ({sx}, {sy}): a pixel changed by {worst}"
);
}
}
}
}
}
/// Scrolling by parts of a pixel (a trackpad, a zoom about the pointer) moves heavily blurred type
/// by just that: its ink stays put on the page within a tenth of a pixel, at any zoom.
#[test]
fn blurred_type_doesnt_wobble_as_the_view_moves_by_parts_of_a_pixel() {
for threads in [0, 4] {
for zoom in [1.0, 4.0] {
let at = |p: f64| {
ink_centre(30.0, threads, Affine::translate((400.0 - 75.0 * zoom + p, 330.0 - 48.0 * zoom + p)) * Affine::scale(zoom), (800, 660))
};
let base = at(0.0);
for k in 1..10 {
let c = at(f64::from(k) * 0.1);
// How far it moved on the screen, besides the view's own move.
let (dx, dy) = ((c.x - base.x) * zoom, (c.y - base.y) * zoom);
assert!(dx.abs() < 0.1 && dy.abs() < 0.1, "threads {threads}, zoom {zoom}, moved by {}: off by ({dx}, {dy}) px", f64::from(k) * 0.1);
}
}
}
}
+3 -4
View File
@@ -335,10 +335,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
// No save dialog for an agent: the bytes come back, as in headless mode.
None => wrap(app.run("document.export", p.clone())),
},
"app.quit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
// As the menu's Quit: `{"pending": "saveChanges"}` while a modified document is asked
// about; otherwise null, and the host closes the window (#830).
"app.quit" => wrap(app.run("app.quit", json!({}))),
other => err(format!("unknown method `{other}`")),
}
}
+19
View File
@@ -206,4 +206,23 @@ mod tests {
app.run("app.quit", json!({})).unwrap();
assert_eq!(app.ui.status, "quit");
}
#[test]
fn the_control_channels_quit_says_when_it_asks_first() {
// #830: it answered null at once, before the question.
let (mut app, _) = app();
let ctx = egui::Context::default();
let quit = |app: &mut VectorcraftApp| {
let (req, _rx) = crate::control::ControlRequest::new("app.quit", json!({}));
let crate::control::Outcome::Done(r) = crate::control::handle(app, &ctx, &req) else { panic!("not done") };
r
};
new_doc(&mut app, true);
assert_eq!(quit(&mut app), json!({"ok": true, "result": {"pending": KIND}}));
assert_eq!((pending(&app), app.ui.status.as_str()), (Some(0), ""));
app.ui.dialog = None;
app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap();
assert_eq!(quit(&mut app), json!({"ok": true, "result": null}));
assert_eq!(app.ui.status, "quit");
}
}