mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
#983: supersample Art Optimized edges in exports
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 <[email protected]>
This commit is contained in:
1 parent
c403e57d2a
commit
178f99d60e
7 files changed
+105
-6
No files matched your search
+1
-1
@@ -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 |
|
||||
|
||||
@@ -152,7 +152,8 @@ fn render(doc: &Document, nodes: Vec<Node>, 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
|
||||
|
||||
@@ -582,8 +582,14 @@ pub fn render(doc: &Document, s: &WebSettings) -> std::result::Result<WebRender,
|
||||
let doc = doc.without_edit_modes();
|
||||
let (region, scale) = region(&doc, s)?;
|
||||
let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 4]);
|
||||
let opts =
|
||||
vectorcraft_render::RenderOptions { background: page, skip_templates: true, anti_alias: s.anti_alias, precise: true, ..Default::default() };
|
||||
let opts = vectorcraft_render::RenderOptions {
|
||||
background: page,
|
||||
skip_templates: true,
|
||||
anti_alias: s.anti_alias,
|
||||
precise: true,
|
||||
supersample: true,
|
||||
..Default::default()
|
||||
};
|
||||
let img = vectorcraft_render::Renderer::new().render_region_with(&doc, region, scale, &opts);
|
||||
Ok(WebRender { rgba: img.to_straight(), width: img.width, height: img.height, region, scale })
|
||||
}
|
||||
|
||||
@@ -97,6 +97,8 @@ impl RasterExportOptions {
|
||||
anti_alias: self.anti_alias,
|
||||
// Opaque art stays exactly opaque where translucent edges cross it (#787).
|
||||
precise: true,
|
||||
// Shapes meeting edge to edge leave no seam (#983).
|
||||
supersample: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ pub struct RenderOptions {
|
||||
/// opaque, where 8-bit compositing leaves alpha 254 on some anti-aliased edges (#787). Off,
|
||||
/// the faster 8-bit pipeline (the canvas).
|
||||
pub precise: bool,
|
||||
/// Exports: Art Optimized edges are supersampled ([`supersample_factor`]): the art is drawn
|
||||
/// with hard edges at several times the size and averaged down, so shapes that meet edge to
|
||||
/// edge leave no seam of the background between them (#983). Off, edges are smoothed shape by
|
||||
/// shape (the canvas).
|
||||
pub supersample: bool,
|
||||
}
|
||||
|
||||
/// The opacity of the art around an isolated group or layer.
|
||||
@@ -186,10 +191,48 @@ impl Default for RenderOptions {
|
||||
smooth_images: true,
|
||||
isolated: None,
|
||||
precise: false,
|
||||
supersample: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most pixels a supersampled render draws (the frame and its compositing buffers hold a few
|
||||
/// times this many bytes).
|
||||
const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000;
|
||||
|
||||
/// How many times wider and taller a `w`×`h` render is drawn when supersampled: 4, or less where
|
||||
/// that would pass [`MAX_SUPERSAMPLED_PIXELS`] or the renderer's 65,535-pixel side; `None` when not
|
||||
/// even twice fits (the edges are then smoothed shape by shape).
|
||||
pub fn supersample_factor(w: u32, h: u32) -> Option<u32> {
|
||||
(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<u8> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+1
-2
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user