From 178f99d60ef1f5617075dc8e76031ecc90ebb0b2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:46:42 +0700 Subject: [PATCH] #983: supersample Art Optimized edges in exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asset Export, File › Export, Save for Web and Object › Rasterize drew each shape with its own smoothed edge, so where two shapes met edge to edge (Divide, Shape Builder, abutting art) both half-covered the pixels on the line and the background showed through as a thin ghost border. With Art Optimized (the default) these now supersample, as the option's name in Illustrator says: the art is drawn with hard edges at four times the size (three or two times for large images, within a 40-megapixel budget and the renderer's 65,535-pixel side) and each block averaged into one pixel. Pixels shared by two shapes are fully owned by one or the other at the finer size, so no seam is left, and the outer edges are still smooth. The canvas keeps shape-by-shape smoothing for speed; None and Type Optimized are unchanged. RenderOptions::supersample turns it on (exports and Rasterize set it). A render test checks two triangles sharing a diagonal: a seam with shape-by-shape smoothing, none supersampled, and the factor's limits. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/rasterfx.rs | 3 +- crates/engine/src/cmd/webexport.rs | 10 ++++-- crates/render/src/encode/mod.rs | 2 ++ crates/render/src/lib.rs | 53 ++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 38 +++++++++++++++++++++ docs/gaps.md | 3 +- 7 files changed, 105 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index eaca3a38..166186be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 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). | +| 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). | | 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) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 77b97e31..f2cc7bcc 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -152,7 +152,8 @@ fn render(doc: &Document, nodes: Vec, region: Rect, scale: f64, anti_alias tmp.layers = vec![Arc::new(layer)]; let mut r = vectorcraft_render::Renderer::new(); r.threads = 0; - r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, ..Default::default() }) + // Art Optimized is supersampled, as in exports: shapes meeting edge to edge leave no seam. + r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, supersample: true, ..Default::default() }) } /// Pixels per point of raster effects rendered as images (the document's raster effects diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index e809440f..0ca8294f 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -582,8 +582,14 @@ pub fn render(doc: &Document, s: &WebSettings) -> std::result::Result Option { + (2..=4u32).rev().find(|k| { + let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k)); + sw <= u64::from(u16::MAX) && sh <= u64::from(u16::MAX) && sw * sh <= MAX_SUPERSAMPLED_PIXELS + }) +} + +/// Average each `k`×`k` block of `src` (`w`×`h` pixels of 4 premultiplied bytes) into one pixel. +fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec { + let (ow, oh) = (w / k.max(1), h / k.max(1)); + let n = (k * k).max(1) as u32; + let mut out = vec![0u8; ow * oh * 4]; + for (o, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let (ox, oy) = (o % ow.max(1), o / ow.max(1)); + let mut acc = [0u32; 4]; + for y in oy * k..(oy + 1) * k { + let row = y * w + ox * k; + for p in src.get(row * 4..(row + k) * 4).unwrap_or_default().as_chunks::<4>().0 { + for (a, v) in acc.iter_mut().zip(p) { + *a += u32::from(*v); + } + } + } + for (d, a) in px.iter_mut().zip(acc) { + *d = ((a + n / 2) / n).min(255) as u8; + } + } + out +} + /// A rendered image (premultiplied RGBA8, row-major). pub struct Rendered { pub width: u32, @@ -476,6 +519,16 @@ impl Renderer { /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { let start = now(); + if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() { + let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32)); + if let Some(k) = supersample_factor(w, h) { + let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; + let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks); + let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize); + self.stats.micros = now().saturating_sub(start); + return Rendered { width: w, height: h, pixels }; + } + } let prepared = proof::prepare(doc, opts); let doc: &Document = &prepared; let w = width.clamp(1, u16::MAX as u32) as u16; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b9c4692b..b94ce1f3 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -385,3 +385,41 @@ fn images_take_their_nearest_pixel_without_smoothing() { assert!(greys(true) >= 4, "smooth: a ramp from black to white"); assert_eq!(greys(false), 0, "nearest: black, then white"); } + +/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no +/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer +/// edges stay smooth. +#[test] +fn supersampled_art_leaves_no_seam_between_shapes_that_meet() { + let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + // Two triangles sharing the diagonal of the square (10, 10)–(90, 90). + for pts in [[(10.0, 10.0), (90.0, 10.0), (90.0, 90.0)], [(10.0, 10.0), (90.0, 90.0), (10.0, 90.0)]] { + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to(pts[0]); + b.line_to(pts[1]); + b.line_to(pts[2]); + b.close_path(); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), Appearance::basic(red(), Paint::None, 0.0))).unwrap(); + } + let draw = |supersample: bool| { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), precise: true, supersample, ..Default::default() }; + Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts) + }; + // The palest pixel on the shared diagonal, away from the corners. + let palest = |r: &Rendered| (20..80).map(|i| r.pixel(i, i)[1].max(r.pixel(i + 1, i)[1])).max().unwrap(); + assert!(palest(&draw(false)) > 20, "shape-by-shape smoothing shows the seam"); + assert_eq!(palest(&draw(true)), 0, "supersampled: no seam"); + // The outer edge is still smoothed: a pixel half covered is between white and red. + let ss = draw(true); + let edge = ss.pixel(50, 9); + assert!(edge == [255, 255, 255, 255] || (edge[1] > 0 && edge[1] < 255) || ss.pixel(50, 10)[1] < 255, "{edge:?}"); + // The factor fits the size and the renderer's limits. + assert_eq!(supersample_factor(100, 100), Some(4)); + assert_eq!(supersample_factor(2000, 2000), Some(3)); + assert_eq!(supersample_factor(3000, 3000), Some(2)); + assert_eq!(supersample_factor(5000, 5000), None); + assert_eq!(supersample_factor(40_000, 10), None); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9d92e4ba..07b16be3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -86,8 +86,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G11 Correctness bugs in core tools -- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Asset Export - anti-aliasing ghost borders (#983); Transform effect dialog's invisible checkboxes and missing options (#885); +- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885); PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834). - **Impact:** wrong output in everyday work. - **Estimate:** 8–15 h.