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.