Compare commits

...
Author SHA1 Message Date
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
bflatasticandClaude Opus 5.5 1ba987ee7a Fix #828: Offset Path's Joins and Zig Zag's Points are dropdowns
The effect versions of Offset Path, Zig Zag and Roughen showed Joins and
Points as free text fields. They now pick Miter, Round or Bevel and
Smooth or Corner, as Object > Path > Offset Path does. A size's
Relative / Absolute (Roughen, Zig Zag, Tweak) is a pair of radio
buttons, and the effect dialogs' other on/off options are checkboxes
that show when they are off too (they were invisible unchecked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:46 +07:00
15 changed files with 229 additions and 49 deletions

No files matched your search

+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}`")),
}
}
+18
View File
@@ -158,6 +158,8 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]),
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]),
("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]),
_ => None,
}
}
@@ -371,3 +373,19 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
app.ui.dialog = None;
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fields_that_pick_a_value_are_dropdowns_of_its_values() {
// The values come from the effect's parameter documentation (`joins: "miter"|"round"|…`).
for (effect, key) in [("path.offsetPath", "joins"), ("distort.zigZag", "points"), ("distort.roughen", "points")] {
let doc = vectorcraft_effects::effect_info(effect).unwrap().params;
let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().to_string();
let values: Vec<String> = choices(effect, key).unwrap().iter().map(|(_, v)| format!("\"{v}\"")).collect();
assert_eq!(values.join("|"), listed, "{effect} {key}");
}
}
}
+16 -4
View File
@@ -215,7 +215,9 @@ pub(super) fn param_fields(
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
keys.sort_by_key(|(k, _)| rank(k));
for (k, v) in keys {
ui.label(egui::RichText::new(humanized(&k)).color(t.text));
// A size's Relative / Absolute pair (Roughen, Zig Zag, Tweak) is its own label.
let relative = k == "relative" && v.is_boolean();
ui.label(egui::RichText::new(if relative { String::new() } else { humanized(&k) }).color(t.text));
if let Some(cur) = crate::widgets::blend_param(&k, &v) {
if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) {
d.fields.insert(k, m);
@@ -247,9 +249,19 @@ pub(super) fn param_fields(
changed = true;
}
}
Value::Bool(mut b) => {
if ui.checkbox(&mut b, "").changed() {
d.fields.insert(k, json!(b));
Value::Bool(b) if relative => {
ui.horizontal(|ui| {
for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] {
if crate::widgets::radio(ui, label, b == on, true) && b != on {
d.fields.insert(k.clone(), json!(on));
changed = true;
}
}
});
}
Value::Bool(b) => {
if crate::widgets::check(ui, "", b, true) {
d.fields.insert(k, json!(!b));
changed = true;
}
}
+3
View File
@@ -2564,6 +2564,9 @@
Smart Guides (⌘U) Guías inteligentes (⌘U)
Smart Punctuation… Puntuación inteligente…
Smooth Suavizar
Corner Vértice
Relative Relativo
Absolute Absoluto
Smooth Color Color suavizado
Smooth Tool Herramienta Suavizar
Snap Curves To Lines Ajustar curvas a líneas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2561,6 +2561,9 @@
Smart Guides (⌘U) Repères commandés (⌘U)
Smart Punctuation… Ponctuation typographique…
Smooth Lisser
Corner Angle
Relative Relatif
Absolute Absolu
Smooth Color Couleur lissée
Smooth Tool Outil Arrondi
Snap Curves To Lines Aimanter les courbes aux lignes
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2564,6 +2564,9 @@
Smart Guides (⌘U) Guide sensibili (⌘U)
Smart Punctuation… Punteggiatura intelligente…
Smooth Smussa
Corner Angolo
Relative Relativo
Absolute Assoluto
Smooth Color Colore graduale
Smooth Tool Strumento Smussa
Snap Curves To Lines Aggancia curve a linee
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2379,6 +2379,9 @@
Smart Guides (View → Smart Guides) スマートガイド (表示 → スマートガイド)
Smart Guides (⌘U) スマートガイド (⌘U)
Smooth スムーズ
Corner コーナー
Relative 相対
Absolute 絶対
Smooth Tool スムーズツール
Snap Curves To Lines 曲線を直線にスナップ
View: 表示:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2560,6 +2560,9 @@
Smart Guides (⌘U) Умные направляющие (⌘U)
Smart Punctuation… Умная пунктуация…
Smooth Сгладить
Corner Угол
Relative Относительно
Absolute Абсолютно
Smooth Color Сглаженный цвет
Smooth Tool Инструмент «Сглаживание»
Snap Curves To Lines Привязывать кривые к линиям
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2556,6 +2556,9 @@
Smart Guides (⌘U) Розумні напрямні (⌘U)
Smart Punctuation… Розумна пунктуація…
Smooth Згладити
Corner Кут
Relative Відносно
Absolute Абсолютно
Smooth Color Згладжений колір
Smooth Tool Інструмент «Згладжування»
Snap Curves To Lines Прив’язувати криві до ліній
Can't render this file because it contains an unexpected character in line 632 and column 40.
+3
View File
@@ -2481,6 +2481,9 @@
Smart Guides (⌘U) 智能参考线 (⌘U)
Smart Punctuation… 智能标点…
Smooth 平滑
Corner 尖角
Relative 相对
Absolute 绝对
Smooth Color 平滑颜色
Smooth Tool 平滑工具
Snap Curves To Lines 将曲线对齐到直线
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2567,6 +2567,9 @@
Smart Guides (⌘U) 智慧型參考線 (⌘U)
Smart Punctuation… 智慧型標點…
Smooth 平滑
Corner 角點
Relative 相對
Absolute 絕對
Smooth Color 平滑顏色
Smooth Tool 平滑工具
Snap Curves To Lines 將曲線靠齊線條
Can't render this file because it contains an unexpected character in line 6 and column 47.
+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");
}
}