From f703e6bd8502840560a0d30f6c0accd54f0f7309 Mon Sep 17 00:00:00 2001 From: alpha-rock <44344200+alpha-rock@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:04:47 +0530 Subject: [PATCH 1/2] M9: smooth Revolve rendering and default Cap Use automatic vector smooth shading without a separate control. Enable Cap by default and keep continuous rim curves and bounded seam joins. Integrate the shared dialog with the other three 3D effects. --- crates/effects/src/lib.rs | 4 +- crates/effects/src/revolve.rs | 113 +++++++++- crates/engine/src/cmd/effectcmd.rs | 70 ++++++ crates/pdf/src/tests.rs | 26 +++ crates/render/src/tests_objectfx.rs | 139 +++++++++++- crates/svg/tests/effects_export.rs | 37 ++++ crates/three-d/src/caps.rs | 183 ++++++++++++++++ crates/three-d/src/lib.rs | 231 ++++++++++++++++---- crates/three-d/src/shading.rs | 43 ++++ crates/three-d/src/tests_caps.rs | 201 +++++++++++++++++ crates/three-d/src/tests_smooth.rs | 123 +++++++++++ crates/three-d/src/visibility.rs | 118 +++++++--- crates/three-d/src/visibility_parts.rs | 121 ++++++++++ crates/ui-egui/src/dialogs/revolve.rs | 12 + crates/ui-egui/src/dialogs/tests_revolve.rs | 91 +++++++- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/effects-parity.md | 11 +- docs/gaps.md | 7 +- docs/mcp.md | 9 +- docs/revolve.md | 46 +++- docs/roadmap.md | 5 +- docs/target-app-parity.md | 7 +- 36 files changed, 1526 insertions(+), 116 deletions(-) create mode 100644 crates/three-d/src/caps.rs create mode 100644 crates/three-d/src/shading.rs create mode 100644 crates/three-d/src/tests_caps.rs create mode 100644 crates/three-d/src/tests_smooth.rs create mode 100644 crates/three-d/src/visibility_parts.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 219971b8c..61230ba7e 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -200,8 +200,8 @@ pub fn effect_catalog() -> Vec { REVOLVE, "Revolve…", &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"expandVisibleOnly":true}), + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), capEnds: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"capEnds":true,"expandVisibleOnly":true}), ), g( EXTRUDE, diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index f0b00b9b8..f10958e13 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{PathData, Rect}; use vectorcraft_three_d::{Edge, Revolve}; @@ -38,6 +38,8 @@ pub fn revolve_options(p: &Value) -> Revolve { light_softness: num(p, "lightSoftness", 0.0) / 100.0, ambient: num(p, "ambient", 25.0) / 100.0, shade: flag(p, "shade", true), + smooth_shading: true, + cap_ends: flag(p, "capEnds", true), } } @@ -52,6 +54,9 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) { return Err("Revolve edge must be left or right".into()); } + if p.get("capEnds").is_some_and(|v| v.as_bool().is_none()) { + return Err("Revolve capEnds must be a boolean".into()); + } vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) } @@ -95,7 +100,10 @@ fn surface( .then(|| vectorcraft_three_d::visible_faces(&faces).ok()) .flatten(); let faces = visible.unwrap_or_else(|| { - faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect() + faces + .into_iter() + .map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient }) + .collect() }); let children = faces .into_iter() @@ -104,13 +112,17 @@ fn surface( let mut items = vec![]; if let Some(c) = paint.color() { let [r, g, b] = c.to_rgb(); - let s = face.brightness as f32; - fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s)); + let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32); + fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0)); items.push(AppearanceItem::Fill(fill)); } else { items.push(AppearanceItem::Fill(fill)); let mut shadow = FillLayer::new(Paint::solid(Color::BLACK)); - shadow.opacity = (1.0 - face.brightness) as f32; + if let Some(g) = face.gradient { + shadow.paint = shading_paint(g, |_| Color::BLACK, |s| (1.0 - s) as f32); + } else { + shadow.opacity = (1.0 - face.brightness) as f32; + } items.push(AppearanceItem::Fill(shadow)); } // Independently antialiased adjacent polygons otherwise leave a hairline grid. @@ -121,6 +133,9 @@ fn surface( .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75); + // Acute face corners must stay within the overlap width. Miter joins + // amplify this seam stroke into visible spikes along the silhouette. + edge.join = LineJoin::Round; edge.opacity = fill.opacity; vec![item, AppearanceItem::Stroke(edge)] }) @@ -134,6 +149,15 @@ fn surface( Some(group) } +fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { + use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = + g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect(); + let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None }); + Paint::Gradient(Box::new(paint)) +} + fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) { match item { AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend), @@ -224,6 +248,85 @@ mod tests { crate::new_effect(REVOLVE, &json!({})).unwrap() } #[test] + fn seam_strokes_stay_within_half_a_stroke_width_of_each_face() { + use kurbo::Shape; + for shade in [false, true] { + for angle in [110.0, 360.0] { + let mut n = profile_node(); + n.appearance.effects.push( + crate::new_effect( + REVOLVE, + &json!({ + "offset":20,"rotationX":-18,"segments":24,"capEnds":true, + "shade":shade,"angle":angle + }), + ) + .unwrap(), + ); + for art in [revolve_art(&n).unwrap(), expand_revolve_art(&n).unwrap()] { + let mut checked = 0; + art.walk(&mut |face| { + let Some(path) = face.path_data() else { return }; + for item in &face.appearance.items { + let AppearanceItem::Stroke(stroke) = item else { continue }; + let bounds = path.bounds().unwrap().inflate(stroke.width / 2.0 + 0.005, stroke.width / 2.0 + 0.005); + let ink = crate::stroke::line_outline(&path.to_bezpath(), stroke, stroke.width, 0.001).bounding_box(); + assert!( + bounds.contains_rect(ink), + "seam protrudes beyond its overlap: {ink:?}, {bounds:?}, shade {shade}, sweep {angle}" + ); + checked += 1; + } + }); + assert!(checked > 20); + } + } + } + } + #[test] + fn smooth_shading_is_automatic_and_non_solid_paints_keep_their_base_paint() { + let mut n = profile_node(); + n.appearance.effects.push(fx()); + assert!(!n.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading")); + let smooth = revolve_art(&n).unwrap(); + assert!( + smooth.children().unwrap()[0].children().unwrap().iter().any(|face| { + matches!(face.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if matches!(fill.paint, Paint::Gradient(_))) + }) + ); + n.appearance.effects[0].params["smoothShading"] = json!(false); + assert_eq!(revolve_art(&n), Some(smooth.clone())); + n.appearance.effects[0].params["smoothShading"] = json!(true); + assert_eq!(revolve_art(&n), Some(smooth)); + use vectorcraft_doc::color::{Gradient, GradientPaint}; + let base = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + n.appearance.set_fill(base.clone()); + let art = revolve_art(&n).unwrap(); + let faces = art.children().unwrap()[0].children().unwrap(); + assert!(faces.iter().all(|f| matches!(f.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if fill.paint == base))); + assert!(faces.iter().any(|f| f.appearance.items.iter().any(|item| { + matches!(item, AppearanceItem::Fill(fill) if matches!(&fill.paint, Paint::Gradient(g) if g.gradient.stops.iter().all(|s| s.color == Color::BLACK) && g.gradient.stops[0].opacity != g.gradient.stops[1].opacity)) + }))); + assert_eq!(expand_revolve_art(&n), Some(art), "unknown paint opacity keeps complete surfaces"); + } + #[test] + fn missing_cap_parameter_defaults_on_and_explicit_false_restores_open_ends() { + let mut n = profile_node(); + let mut effect = fx(); + effect.params["offset"] = json!(20); + effect.params["rotationX"] = json!(-35); + effect.params.as_object_mut().unwrap().remove("capEnds"); + n.appearance.effects.push(effect); + let capped = revolve_art(&n).unwrap(); + n.appearance.effects[0].params["capEnds"] = json!(false); + let uncapped = revolve_art(&n).unwrap(); + assert_ne!(uncapped, capped); + n.appearance.effects[0].params["capEnds"] = json!(true); + assert_eq!(revolve_art(&n), Some(capped)); + n.appearance.effects[0].params["capEnds"] = json!(false); + assert_eq!(revolve_art(&n), Some(uncapped)); + } + #[test] fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() { let mut n = profile_node(); let before = n.path_data().unwrap().clone(); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index edfad8582..5f065453b 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -651,6 +651,76 @@ mod tests { effects::revolve_art(&node(s, id)).unwrap() } + #[test] + fn smooth_revolve_commands_validate_save_expand_and_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap(); + let initial = node(&s, id); + let source = initial.path_data().unwrap().clone(); + assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading")); + assert!(initial.appearance.effects[0].params["capEnds"].as_bool().unwrap()); + let mut gradients = 0; + effects_for_test(&s, id).walk(&mut |face| { + gradients += face.appearance.items.iter().filter(|item| { + matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_))) + }).count(); + }); + assert!(gradients > 0, "commands use smooth vector shading by default"); + s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap(); + let smooth = node(&s, id); + assert_eq!(smooth.path_data(), Some(&source)); + assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap()); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id))); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), smooth); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), initial); + } + + #[test] + fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute( + "effect.apply", + &json!({"effect":"threeD.revolve","ids":[id.0],"item":null, + "params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"capEnds":false}}), + ) + .unwrap(); + let uncapped = node(&s, id); + assert_eq!(uncapped.appearance.effects[0].params["capEnds"], json!(false)); + let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); + let open_count = count(&effects_for_test(&s, id)); + s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"capEnds":true}})).unwrap(); + let capped = node(&s, id); + let art = effects_for_test(&s, id); + assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap"); + assert_eq!(capped.path_data(), Some(&source)); + assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"capEnds":"yes"}})).is_err()); + assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document"); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["capEnds"], json!(true)); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone())); + let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); + assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF")); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert_eq!(count(&node(&s, id)), count(&art)); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), capped); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), uncapped); + } + #[test] fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() { let mut s = Session::new(); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 28e02d633..335c2eaf4 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -285,6 +285,32 @@ fn gradients_exported_as_shadings_and_reimported() { assert!((g.geom.unwrap().start - Point::new(200.0, 50.0)).hypot() < 0.1); } +#[test] +fn smooth_revolve_exports_axial_vector_shading_without_images_and_keeps_the_source_live() { + let mut d = doc(200.0, 200.0); + let bp = vectorcraft_geom::BezPath::from_svg("M100 30 C140 50 140 120 100 160").unwrap(); + let mut n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0)); + n.appearance.effects.push(vectorcraft_doc::Effect { + id: "threeD.revolve".into(), + visible: true, + params: serde_json::json!({"offset":20,"rotationX":-25,"capEnds":true,"segments":16}), + }); + add(&mut d, n); + let before = d.clone(); + let pdf = uncompressed(&d).replace(' ', ""); + assert!(pdf.matches("/ShadingType2").count() > 50); + assert!(!pdf.contains("/Subtype/Image")); + assert_eq!(d, before); + let back = roundtrip(&d); + let strokes: Vec<_> = leaves(&back) + .into_iter() + .flat_map(|n| n.appearance.items.iter()) + .filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) + .collect(); + assert!(strokes.len() >= 2, "continuous cap rims remain vector strokes"); + assert!(strokes.iter().all(|s| s.join == LineJoin::Round), "seam strokes keep round joins in PDF"); +} + #[test] fn hidden_objects_and_template_layers_skipped() { let mut d = doc(200.0, 200.0); diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 65c84df83..f4028302f 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -40,6 +40,42 @@ fn lum(p: [u8; 4]) -> u32 { const WHITE: u32 = 765; +#[test] +fn revolve_caps_cover_the_opening_and_match_export_and_visible_only_expansion() { + let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap()); + let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0)); + for (angle, opacity) in [(360.0, 1.0), (90.0, 1.0), (360.0, 0.5)] { + n.opacity = opacity; + n.appearance.effects = vec![fx( + "threeD.revolve", + json!({"offset":20,"rotationX":-35,"lightAzimuth":0, + "angle":angle,"segments":32,"capEnds":false,"expandVisibleOnly":true}), + )]; + let open = render(&doc_with(Document::new(100.0, 100.0), n.clone())); + n.appearance.effects[0].params["capEnds"] = json!(true); + let d = doc_with(Document::new(100.0, 100.0), n.clone()); + let live = render(&d); + assert_ne!(live.pixels, open.pixels, "caps change the opening: {angle}, {opacity}"); + if angle == 360.0 && opacity == 1.0 { + let pixel = (34 * 100 + 50) * 4; + assert!(live.pixels[pixel] as u16 > open.pixels[pixel] as u16 + 30, "the lit cap covers the visible interior"); + } + assert_eq!(live.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels); + let mut expanded = d.clone(); + let id = expanded.layers[0].children().unwrap()[0].id; + let source = expanded.node(id).unwrap().clone(); + let mut stroke = |_: &mut Document, _: &vectorcraft_geom::PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None; + let art = vectorcraft_effects::expand_leaf(&mut expanded, &source, &mut stroke).unwrap(); + *expanded.node_mut(id).unwrap() = art; + let flat = render(&expanded); + let difference: u64 = live.pixels.iter().zip(&flat.pixels).map(|(a, b)| a.abs_diff(*b) as u64).sum(); + assert!(difference as f64 / (live.pixels.len() as f64) < 1.0, "visible-only expansion preserves cap appearance"); + if opacity < 1.0 { + assert_eq!(live.pixels, flat.pixels); + } + } +} + #[test] fn revolve_canvas_matches_baked_art_and_shades_its_surface() { let mut bp = vectorcraft_geom::BezPath::new(); @@ -99,6 +135,95 @@ fn solid_effects_canvas_matches_baked_art_and_shades_their_faces() { } } +#[test] +fn smooth_revolve_reduces_banding_without_changing_radial_resolution() { + let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 20 L70 80").unwrap()); + let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0)); + n.appearance + .effects + .push(fx("threeD.revolve", json!({"offset":30,"segments":16,"lightAzimuth":0,"lightElevation":0,"ambient":10,"lightIntensity":80}))); + let d = doc_with(Document::new(100.0, 100.0), n); + let smooth = render(&d); + let levels = |r: &Rendered| (14..66).map(|x| r.pixels[(50 * 100 + x) * 4 + 2]).collect::>().len(); + // More colour levels than radial facets proves interpolation inside the faces. + assert!(levels(&smooth) > 16, "default rendering interpolates lighting: {} blue levels", levels(&smooth)); + let jump = |r: &Rendered| (15..66).map(|x| r.pixels[(50 * 100 + x) * 4 + 2].abs_diff(r.pixels[(50 * 100 + x - 1) * 4 + 2])).max().unwrap(); + assert!(jump(&smooth) < 20, "default lighting has no abrupt changes between face colours"); + assert_eq!(smooth.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels); + assert_eq!(smooth.pixels, render_threads(&d, 4).pixels); +} + +#[test] +fn revolve_rim_has_no_spikes_at_high_zoom_and_keeps_seams_covered() { + use vectorcraft_doc::AppearanceItem; + let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap()); + let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0)); + n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":-18,"segments":24,"capEnds":true}))); + let d = doc_with(Document::new(100.0, 100.0), n); + let baked = vectorcraft_effects::bake_document(&d).unwrap(); + let mut fills = baked.clone(); + fn remove_edges(n: &mut Node) { + n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_))); + for child in n.children_mut().into_iter().flatten() { + remove_edges(Arc::make_mut(child)); + } + } + for layer in &mut fills.layers { + remove_edges(Arc::make_mut(layer)); + } + let bounds = fills.layers[0].geometric_bounds().unwrap(); + let zoom = 24.0; + let xf = Affine::translate((600.0 - 50.0 * zoom, 50.0 - bounds.y0 * zoom)) * Affine::scale(zoom); + let draw = + |doc: &Document| Renderer::new().render(doc, 1200, 400, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let reference = draw(&fills); + let live = draw(&d); + // The seam stroke may extend half its width, plus antialiasing, past the fill + // silhouette. Miter spikes previously reached several times that distance. + let reach = (0.375 * zoom) as u32 + 2; + let top = |img: &Rendered| (0..1200).map(|x| (0..400).find(|y| lum(img.pixel(x, *y)) < 600).unwrap_or(400)).collect::>(); + let fill_top = top(&reference); + for (x, y) in top(&live).into_iter().enumerate().filter(|(_, y)| *y < 400) { + let near = *fill_top[x.saturating_sub(reach as usize)..=(x + reach as usize).min(1199)].iter().min().unwrap(); + assert!(y + reach >= near, "rim spike at x {x}: ink starts at {y}, nearest fill at {near}"); + } + assert!((200..1000).all(|x| lum(live.pixel(x, 200)) < 600), "seam coverage leaves no white gaps across the cap"); + assert_eq!(live.pixels, draw(&baked).pixels, "live and exported geometry agree at high zoom"); +} + +#[test] +fn continuous_revolve_caps_keep_one_colour_along_the_curved_rim_at_high_zoom() { + let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap()); + for rotation in [-18.0f64, 18.0] { + for shade in [false, true] { + let mut n = Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0)); + n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":rotation,"segments":8,"capEnds":true,"shade":shade}))); + let d = doc_with(Document::new(100.0, 100.0), n); + let center_y = 50.0 + if rotation < 0.0 { -20.0 } else { 20.0 } * rotation.to_radians().cos(); + let zoom = 24.0; + let xf = Affine::translate((600.0 - 50.0 * zoom, 220.0 - center_y * zoom)) * Affine::scale(zoom); + let draw = |doc: &Document| { + Renderer::new().render(doc, 1200, 440, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) + }; + let live = draw(&d); + let color = live.pixel(600, 220); + assert!(lum(color) < 600, "the visible cap is filled"); + // Sample 0.15 pt inside the projected ellipse. A triangle fan with + // seam strokes lets body colours intrude here between mesh corners. + for step in 1..80 { + let theta = std::f64::consts::PI * step as f64 / 80.0; + let p = xf * Point::new(50.0 + 19.85 * theta.cos(), center_y + 19.85 * rotation.to_radians().abs().sin() * theta.sin()); + let pixel = live.pixel(p.x.round() as u32, p.y.round() as u32); + assert!( + pixel.iter().zip(color).all(|(a, b)| a.abs_diff(b) <= 1), + "cap rim is continuous: rotation {rotation}, shade {shade}, theta {theta}, {pixel:?} vs {color:?}" + ); + } + assert_eq!(live.pixels, draw(&vectorcraft_effects::bake_document(&d).unwrap()).pixels); + } + } +} + #[test] fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() { let mut bp = vectorcraft_geom::BezPath::new(); @@ -109,13 +234,19 @@ fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() { vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0), ); - for (angle, rotation, perspective, opacity) in - [(360.0, 0.0, 0.0, 1.0), (360.0, -35.0, 55.0, 1.0), (150.0, -28.0, 40.0, 1.0), (360.0, -35.0, 55.0, 0.5)] - { + for (angle, rotation, perspective, opacity, shade) in [ + (360.0, 0.0, 0.0, 1.0, false), + (360.0, -35.0, 55.0, 1.0, false), + (150.0, -28.0, 40.0, 1.0, false), + (360.0, -35.0, 55.0, 0.5, false), + (360.0, -35.0, 55.0, 1.0, true), + (150.0, -28.0, 40.0, 1.0, true), + (360.0, -35.0, 55.0, 0.5, true), + ] { n.opacity = opacity; n.appearance.effects = vec![fx( "threeD.revolve", - json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true}), + json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true,"shade":shade}), )]; let d = doc_with(Document::new(100.0, 100.0), n.clone()); let live = render(&d); diff --git a/crates/svg/tests/effects_export.rs b/crates/svg/tests/effects_export.rs index ea63c7fc4..c994b8651 100644 --- a/crates/svg/tests/effects_export.rs +++ b/crates/svg/tests/effects_export.rs @@ -21,6 +21,22 @@ fn fx(id: &str) -> Effect { Effect { id: id.into(), params: json!({}), visible: true } } +#[test] +fn revolve_caps_export_as_additional_vector_faces_and_leave_source_editable() { + let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M100 30 L100 160").unwrap()); + let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0)); + n.appearance.effects.push(Effect { params: json!({"offset":30,"rotationX":-35,"segments":16,"capEnds":false}), ..fx("threeD.revolve") }); + let mut d = doc_with(n); + let open = export(&d, &ExportOptions::default()); + let id = d.layers[0].children().unwrap()[0].id; + d.node_mut(id).unwrap().appearance.effects[0].params["capEnds"] = json!(true); + let before = d.clone(); + let capped = export(&d, &ExportOptions::default()); + assert_eq!(capped.matches(" 50); + assert!(svg.contains("gradientUnits=\"userSpaceOnUse\"")); + assert!(svg.matches(" 100); + assert!(svg.matches("stroke-linejoin=\"round\"").count() > 100, "seam strokes keep bounded joins in SVG"); + assert!(!svg.contains("stroke-miterlimit")); + assert!(!svg.contains(", + points: Vec, +} + +pub(super) struct Ring { + pub arcs: Vec, + center: V3, + seed: [V3; 3], + near: f64, + far: f64, +} + +fn project(v: V3, origin: Point, eye: Option) -> Point { + let factor = perspective_scale(eye, v.2); + Point::new(origin.x + v.0 * factor, origin.y + v.1 * factor) +} + +fn fit_arc( + sample: &impl Fn(f64) -> (Point, Vec2), + a: f64, + b: f64, + depth: usize, + curves: &mut Vec, + budget: &mut usize, +) -> Result<(), String> { + let (p, dp) = sample(a); + let (q, dq) = sample(b); + let curve = CubicBez::new(p, p + dp * ((b - a) / 3.0), q - dq * ((b - a) / 3.0), q); + let error = [0.25, 0.5, 0.75].into_iter().map(|t| curve.eval(t).distance(sample(a + (b - a) * t).0)).fold(0.0, f64::max); + if error > CURVE_TOLERANCE && depth < 10 { + let mid = (a + b) / 2.0; + fit_arc(sample, a, mid, depth + 1, curves, budget)?; + fit_arc(sample, mid, b, depth + 1, curves, budget)?; + } else { + *budget += 1; + if *budget > MAX_RING_CURVES { + return Err("Revolve surface is too complex; simplify the profile".into()); + } + curves.push(curve); + } + Ok(()) +} + +impl Ring { + pub fn new(end: Point, bounds: Rect, o: Revolve, steps: usize, budget: &mut usize) -> Result { + let origin = o.rotation_origin(bounds); + let radius = end.x - origin.x; + let eye = eye_distance(o.perspective, (bounds.x0 - origin.x).abs().max((bounds.x1 - origin.x).abs()).max(bounds.height().abs()).max(1.0)); + let sweep = o.angle.to_radians(); + let vertex = |t: f64| o.rotate(V3(radius * t.cos(), end.y - origin.y, radius * t.sin())); + let sample = |t: f64| { + let v = vertex(t); + let d = o.rotate(V3(-radius * t.sin(), 0.0, radius * t.cos())); + let f = perspective_scale(eye, v.2); + let df = eye.map_or(0.0, |e| if e - v.2 > e * 1e-3 { f * f * d.2 / e } else { 0.0 }); + (project(v, origin, eye), Vec2::new(d.0 * f + v.0 * df, d.1 * f + v.1 * df)) + }; + // Visibility gets a bounded dense polygon; output keeps the cubic outline. + // Every body edge uses these same samples and curves, preventing rim gaps. + let samples = (512.0 * o.angle / 360.0 / steps as f64).ceil().max(1.0) as usize; + let center = o.rotate(V3(0.0, end.y - origin.y, 0.0)); + let (mut near, mut far) = (center.2, center.2); + let mut arcs = vec![]; + for j in 0..steps { + let (a, b) = (sweep * j as f64 / steps as f64, sweep * (j + 1) as f64 / steps as f64); + let mut curves = vec![]; + fit_arc(&sample, a, b, 0, &mut curves, budget)?; + let points = (0..=samples) + .map(|k| { + let v = vertex(a + (b - a) * k as f64 / samples as f64); + near = near.max(v.2); + far = far.min(v.2); + project(v, origin, eye) + }) + .collect(); + arcs.push(RingArc { curves, points }); + } + Ok(Self { arcs, center, seed: [center, vertex(0.0), vertex(sweep / steps as f64)], near, far }) + } + + pub fn face(&self, bounds: Rect, o: Revolve, light: V3, outward: f64) -> Result, String> { + let origin = o.rotation_origin(bounds); + let eye = eye_distance(o.perspective, (bounds.x0 - origin.x).abs().max((bounds.x1 - origin.x).abs()).max(bounds.height().abs()).max(1.0)); + let Some(mut face) = project_face(&self.seed, None, o, origin, eye, light)? else { return Ok(None) }; + let Some(first) = self.arcs.first().and_then(|a| a.points.first()).copied() else { return Ok(None) }; + let center = project(self.center, origin, eye); + let mut path = BezPath::new(); + path.move_to(if o.angle == 360.0 { first } else { center }); + if o.angle < 360.0 { + path.line_to(first); + } + face.points = vec![center, first]; + for arc in &self.arcs { + arc.append(&mut path, &mut face.points, false); + } + path.close_path(); + if path.area().abs() < 1e-10 { + return Ok(None); + } + face.outline = Some(PathData::from_bezpath(&path)); + face.cap = true; + // An outward-facing disk covers its rim's seam strokes. An inward-facing + // disk stays behind the body, including when the view turns upside down. + face.depth = if o.rotate(V3(0.0, outward, 0.0)).2 >= 0.0 { self.near } else { self.far }; + Ok(Some(face)) + } +} + +impl RingArc { + fn append(&self, path: &mut BezPath, points: &mut Vec, reverse: bool) { + if reverse { + for c in self.curves.iter().rev() { + path.curve_to(c.p2, c.p1, c.p0); + } + points.extend(self.points.iter().rev().skip(1)); + } else { + for c in &self.curves { + path.curve_to(c.p1, c.p2, c.p3); + } + points.extend(self.points.iter().skip(1)); + } + } +} + +pub(super) fn curve_edges(face: &mut Face, edges: &[(usize, &RingArc, bool)]) { + if edges.is_empty() { + return; + } + if face.points.len() == 4 + && let (Some((_, bottom, _)), Some((_, top, _))) = + (edges.iter().find(|(edge, _, _)| *edge == 1), edges.iter().find(|(edge, _, _)| *edge == 3)) + { + // Connecting equal angular samples avoids the long, narrow triangles + // produced by ear clipping a cylinder's concave projected boundary. + let strips: Vec<_> = top + .points + .windows(2) + .zip(bottom.points.windows(2)) + .filter_map(|(a, b)| { + let ([a0, a1], [b0, b1]) = (a, b) else { return None }; + Some(vec![*a0, *b0, *b1, *a1]) + }) + .collect(); + let areas: Vec<_> = strips + .iter() + .map(|p| { + let origin = p.first().copied().unwrap_or_default(); + p.iter().zip(p.iter().cycle().skip(1)).take(p.len()).map(|(a, b)| (*a - origin).cross(*b - origin)).sum::() + }) + .collect(); + // A grazing projection can fold a radial strip back over itself. Keep + // the original boundary's winding there rather than unioning its folds. + if !(areas.iter().any(|a| *a > 1e-10) && areas.iter().any(|a| *a < -1e-10)) { + face.visibility_pieces = Some(strips); + } + } + let Some(first) = face.points.first().copied() else { return }; + let mut path = BezPath::new(); + path.move_to(first); + let mut points = vec![first]; + for (i, next) in face.points.iter().cycle().skip(1).take(face.points.len()).enumerate() { + if let Some((_, arc, reverse)) = edges.iter().find(|(edge, _, _)| *edge == i) { + arc.append(&mut path, &mut points, *reverse); + } else { + path.line_to(*next); + points.push(*next); + } + } + path.close_path(); + points.pop(); // The final closing edge already repeated the first point. + face.points = points; + face.outline = Some(PathData::from_bezpath(&path)); +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index c0e36a91f..cc8adf750 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -1,8 +1,8 @@ //! Portable 3D effect geometry: Revolve, Extrude & Bevel, Inflate and Rotate. No windowing, GPU or //! document dependencies. //! -//! Open profiles generate uncapped surfaces. Faces are projected and sorted back to front, -//! then flat shaded. This vector preview uses a painter's algorithm, not a depth buffer: +//! Open profiles optionally close their circular ends with caps. Faces are projected and sorted back to front, +//! then flat or smoothly shaded with vector gradients. This preview uses a painter's algorithm, not a depth buffer: //! intersecting profiles can have ambiguous visibility and are not solid modelling inputs. #![forbid(unsafe_code)] @@ -10,8 +10,11 @@ mod solid; mod visibility; pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, plane}; pub use visibility::{VisibleFace, visible_faces}; +mod shading; +pub use shading::ShadeGradient; +mod caps; -use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect}; +use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv}; /// Resource limits shared by desktop, CLI and browser evaluation. const MAX_PROFILE_POINTS: usize = 1024; @@ -70,6 +73,11 @@ pub struct Revolve { /// Ambient Light, 0 to 2. pub ambient: f64, pub shade: bool, + /// Interpolate profile normals using vector gradients. Enabled by default. + pub smooth_shading: bool, + /// Close circular endpoint rings of open profiles. Partial sweeps get sectors; + /// the angular cut sides are left open. Enabled by default. + pub cap_ends: bool, } /// The default light's Rotation and Height: the light of the earlier default direction (45° to the @@ -94,6 +102,8 @@ impl Default for Revolve { light_softness: 0.0, ambient: 0.25, shade: true, + smooth_shading: true, + cap_ends: true, } } } @@ -259,16 +269,27 @@ impl Revolve { } } -/// One opaque vector polygon, already projected. Brightness multiplies the source colour. +/// One projected vector face. Brightness multiplies the source colour; an optional +/// gradient replaces that constant brightness for a smoothly shaded triangle. #[derive(Clone, Debug, PartialEq)] pub struct Face { pub points: Vec, pub depth: f64, pub brightness: f64, + pub gradient: Option, + /// Curved vector boundary; points remain a bounded visibility approximation. + pub outline: Option, + /// A single endpoint disk or sector, with its axis centre first in points. + pub cap: bool, + /// Bounded radial strips for a body face joining two curved endpoint rings. + pub visibility_pieces: Option>>, } impl Face { pub fn path(&self) -> PathData { + if let Some(path) = &self.outline { + return path.clone(); + } let mut bp = BezPath::new(); if let Some(first) = self.points.first() { bp.move_to(*first); @@ -281,6 +302,71 @@ impl Face { } } +/// Body faces and a cap's planar seed use identical two-sided shading and projection. +fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Point, eye: Option, light: V3) -> Result, String> { + let Some(first) = vertices.first() else { return Ok(None) }; + // A triangle fan also handles a quad whose first edge lies on the axis. + let mut normal = vertices + .get(1..) + .unwrap_or_default() + .windows(2) + .filter_map(|pair| { + let [a, b] = pair else { return None }; + Some(a.sub(*first).cross(b.sub(*first)).unit()) + }) + .find(|n| n.dot(*n) >= 0.5) + .unwrap_or_default(); + if normal.dot(normal) < 0.5 { + return Ok(None); + } + // Profiles may run up or down. Shade the visible side of this two-sided surface. + if normal.2 < 0.0 { + normal = V3(-normal.0, -normal.1, -normal.2); + } + let flat_brightness = brightness(&o, normal, light); + let points: Vec<_> = vertices + .iter() + .map(|v| { + let factor = perspective_scale(eye, v.2); + Point::new(origin.x + v.0 * factor, origin.y + v.1 * factor) + }) + .collect(); + if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return Err("Revolve projection is not finite".into()); + } + let area = points + .iter() + .zip(points.iter().cycle().skip(1)) + .take(points.len()) + .map(|(a, b)| (a.x - origin.x) * (b.y - origin.y) - (b.x - origin.x) * (a.y - origin.y)) + .sum::(); + if area.abs() < 1e-10 { + return Ok(None); + } + let (brightness, gradient) = if let Some(normals) = normals { + let values: Vec<_> = normals + .iter() + .map(|n| { + let n = if n.dot(normal) < 0.0 { V3(-n.0, -n.1, -n.2) } else { *n }; + brightness(&o, n, light) + }) + .collect(); + let brightness = if values.is_empty() { flat_brightness } else { values.iter().sum::() / values.len() as f64 }; + (brightness, ShadeGradient::triangle(&points, &values)) + } else { + (flat_brightness, None) + }; + Ok(Some(Face { + points, + depth: vertices.iter().map(|v| v.2).sum::() / vertices.len() as f64, + brightness, + gradient, + outline: None, + cap: false, + visibility_pieces: None, + })) +} + /// Evaluate a profile in document space. All subpaths share one axis from `bounds`. /// Invalid or excessively complex inputs return an error, never a partial surface. pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { @@ -297,12 +383,34 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, cost: usize| -> Result<(), String> { + if let Some(face) = face { + faces.push(face); + mesh_cost += cost; + if mesh_cost > MAX_FACES { + return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into()); + } + } + Ok(()) + }; let mut count = 0; + let mut curve_budget = 0; for sp in &path.subpaths { let mut profile = Vec::new(); + let mut tangents = Vec::new(); if let Some(first) = sp.anchors.first() { profile.push(first.p); count += 1; @@ -319,67 +427,98 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result 1e-20 { + return v; + } + // Repeated endpoint handles still have a one-sided curve tangent. + let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2(); + if v.hypot2() > 1e-20 { v } else { b - a } + }; + tangents.push([tangent(t0), tangent(t1)]); + profile.push(b); count += 1; if count > MAX_PROFILE_POINTS { return Err("Revolve profile is too complex".into()); } } } - for pair in profile.windows(2) { + let ends = o.cap_ends && !sp.closed && profile.len() >= 2 && profile.first().zip(profile.last()).is_some_and(|(a, b)| a.distance(*b) > 1e-10); + let mut rings = [None, None]; + if ends { + for (ring, end) in rings.iter_mut().zip([profile.first(), profile.last()]) { + if let Some(end) = end + && (end.x - axis).abs() > 1e-10 + { + *ring = Some(caps::Ring::new(*end, bounds, o, steps, &mut curve_budget)?); + } + } + } + for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() { let [a, b] = pair else { continue }; if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve profile contains invalid coordinates".into()); } for j in 0..steps { - let vertex = |p: Point, step: usize| { - let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); - o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s)) + let [v0, v1, v2, v3] = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)]; + let first = (i == 0).then(|| rings.first().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); + let last = (i + 2 == profile.len()).then(|| rings.last().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); + let projected = |vertices: &[V3], normals: Option<&[V3]>, edges: &[(usize, &caps::RingArc, bool)]| { + let mut face = project_face(vertices, normals, o, Point::new(axis, center_y), eye, light)?; + if let Some(face) = &mut face { + caps::curve_edges(face, edges); + } + Ok::<_, String>(face) }; - let vertices = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)]; - // The diagonal gives a nonzero normal even when one profile endpoint is on-axis. - let mut normal = vertices[1].sub(vertices[0]).cross(vertices[2].sub(vertices[0])).unit(); - if normal.dot(normal) < 0.5 { - normal = vertices[2].sub(vertices[0]).cross(vertices[3].sub(vertices[0])).unit(); - } - if normal.dot(normal) < 0.5 { - continue; - } - // Profiles may run up or down. Shade the visible side of this two-sided surface. - if normal.2 < 0.0 { - normal = V3(-normal.0, -normal.1, -normal.2); - } - let brightness = brightness(&o, normal, light); - let points: Vec<_> = vertices - .iter() - .map(|v| { - let factor = perspective_scale(eye, v.2); - Point::new(axis + v.0 * factor, center_y + v.1 * factor) - }) - .collect(); - if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { - return Err("Revolve projection is not finite".into()); - } - let area = points - .iter() - .zip(points.iter().cycle().skip(1)) - .take(4) - .map(|(a, b)| (a.x - axis) * (b.y - center_y) - (b.x - axis) * (a.y - center_y)) - .sum::(); - if area.abs() < 1e-10 { - continue; - } - faces.push(Face { points, depth: vertices.iter().map(|v| v.2).sum::() / 4.0, brightness }); - if faces.len() > MAX_FACES { - return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into()); + if smooth { + let [n0, n1, n2, n3] = [normal(ta, j), normal(tb, j), normal(tb, j + 1), normal(ta, j + 1)]; + emit(projected(&[v0, v1, v2], Some(&[n0, n1, n2]), &last.map(|arc| (1, arc, false)).into_iter().collect::>())?, 1)?; + emit(projected(&[v0, v2, v3], Some(&[n0, n2, n3]), &first.map(|arc| (2, arc, true)).into_iter().collect::>())?, 1)?; + } else { + let edges: Vec<_> = last.map(|arc| (1, arc, false)).into_iter().chain(first.map(|arc| (3, arc, true))).collect(); + emit(projected(&[v0, v1, v2, v3], None, &edges)?, 1)?; } } } + for (index, ring) in rings.iter().enumerate().filter_map(|(i, r)| r.as_ref().map(|r| (i, r))) { + let neighbours = + if index == 0 { profile.first().zip(profile.get(1)) } else { profile.last().zip(profile.get(profile.len().saturating_sub(2))) }; + if let Some((end, neighbour)) = neighbours { + let dy = end.y - neighbour.y; + let outward = if dy.abs() > 1e-10 { + dy.signum() + } else if end.y < center_y { + -1.0 + } else { + 1.0 + }; + // Keep the original tessellation work budget, even though each cap + // now produces only one curved vector face. + emit(ring.face(bounds, o, light, outward)?, steps)?; + } + } } faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); Ok(faces) } +#[cfg(test)] +mod tests_caps; +#[cfg(test)] +mod tests_smooth; + #[cfg(test)] mod tests { use super::*; @@ -563,7 +702,7 @@ mod tests { fn malformed_and_degenerate_inputs_are_bounded() { let (p, b) = cylinder(); let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap(); - assert!(f.len() <= 128); + assert!(f.len() <= 258); assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err()); assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty()); } diff --git a/crates/three-d/src/shading.rs b/crates/three-d/src/shading.rs new file mode 100644 index 000000000..3e250ede2 --- /dev/null +++ b/crates/three-d/src/shading.rs @@ -0,0 +1,43 @@ +//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle. + +use vectorcraft_geom::{Point, Vec2}; + +/// Document-space gradient placement and the brightness at its two ends. Clipping a +/// face must keep this placement unchanged, so newly cut edges retain their lighting. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShadeGradient { + pub start: Point, + pub end: Point, + pub brightness: [f64; 2], +} + +impl ShadeGradient { + pub(crate) fn triangle(points: &[Point], values: &[f64]) -> Option { + let [p0, p1, p2] = points else { return None }; + let [b0, b1, b2] = values else { return None }; + let u = *p1 - *p0; + let v = *p2 - *p0; + let det = u.cross(v); + if det.abs() < 1e-10 { + return None; + } + // Solve the brightness plane in screen space. Its perpendicular is the gradient + // vector, so all three corner values and shared triangle edges agree exactly. + let g = Vec2::new(((b1 - b0) * v.y - (b2 - b0) * u.y) / det, (u.x * (b2 - b0) - v.x * (b1 - b0)) / det); + let lo = b0.min(*b1).min(*b2); + let hi = b0.max(*b1).max(*b2); + if hi - lo < 1e-10 || g.hypot2() < 1e-30 { + return None; + } + let start = *p0 + g * ((lo - b0) / g.hypot2()); + let end = *p0 + g * ((hi - b0) / g.hypot2()); + [start.x, start.y, end.x, end.y].iter().all(|n| n.is_finite()).then_some(Self { start, end, brightness: [lo, hi] }) + } + + /// Brightness at a projected point, including a point introduced by visibility clipping. + pub fn at(self, p: Point) -> f64 { + let v = self.end - self.start; + let t = if v.hypot2() > 0.0 { ((p - self.start).dot(v) / v.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + self.brightness[0] + (self.brightness[1] - self.brightness[0]) * t + } +} diff --git a/crates/three-d/src/tests_caps.rs b/crates/three-d/src/tests_caps.rs new file mode 100644 index 000000000..c8398ca0f --- /dev/null +++ b/crates/three-d/src/tests_caps.rs @@ -0,0 +1,201 @@ +use super::*; + +fn cylinder() -> (PathData, Rect) { + let mut path = BezPath::new(); + path.move_to((40.0, 10.0)); + path.line_to((40.0, 90.0)); + (PathData::from_bezpath(&path), Rect::new(0.0, 10.0, 40.0, 90.0)) +} + +fn options() -> Revolve { + Revolve { cap_ends: true, rotation_x: 90.0, shade: false, ..Default::default() } +} + +fn caps(faces: &[Face]) -> impl Iterator { + faces.iter().filter(|face| face.cap) +} + +fn area(face: &Face) -> f64 { + use vectorcraft_geom::Shape; + face.path().to_bezpath().area().abs() +} + +#[test] +fn full_turn_caps_cover_both_circular_ends_and_follow_depth_order() { + let (path, bounds) = cylinder(); + let faces = revolve(&path, bounds, options()).unwrap(); + assert_eq!(caps(&faces).count(), 2); + let expected = 2.0 * std::f64::consts::PI * 40.0f64.powi(2); + assert!((caps(&faces).map(area).sum::() - expected).abs() < 0.05); + assert!(caps(&faces).any(|f| (f.depth - 40.0).abs() < 1e-8)); + assert!(caps(&faces).any(|f| (f.depth + 40.0).abs() < 1e-8)); + assert!(faces.windows(2).all(|pair| pair[0].depth <= pair[1].depth)); + assert!(faces.iter().all(|face| face.brightness == 1.0)); +} + +#[test] +fn partial_turn_caps_are_sectors_and_zero_turn_has_no_geometry() { + let (path, bounds) = cylinder(); + let faces = revolve(&path, bounds, Revolve { angle: 90.0, ..options() }).unwrap(); + assert_eq!(caps(&faces).count(), 2); + let expected = 0.5 * std::f64::consts::PI * 40.0f64.powi(2); + assert!((caps(&faces).map(area).sum::() - expected).abs() < 0.05); + assert!(caps(&faces).flat_map(|face| &face.points).all(|point| point.x >= -1e-8 && point.y <= 50.0 + 1e-8)); + assert!(revolve(&path, bounds, Revolve { angle: 0.0, ..options() }).unwrap().is_empty()); +} + +#[test] +fn offsets_and_both_axis_sides_use_the_endpoint_ring_radius() { + let (path, bounds) = cylinder(); + for (edge, offset, radius) in [(Edge::Left, 20.0, 60.0f64), (Edge::Right, 20.0, 20.0f64)] { + let faces = revolve(&path, bounds, Revolve { edge, offset, ..options() }).unwrap(); + let expected = 2.0 * std::f64::consts::PI * radius.powi(2); + assert!((caps(&faces).map(area).sum::() - expected).abs() < 0.05); + } +} + +#[test] +fn closed_coincident_and_on_axis_ends_do_not_gain_caps() { + for data in ["M0 10 L40 30 L40 70 L0 90", "M40 10 L60 40 L40 10", "M40 10 L60 40 L40 90 Z"] { + let path = PathData::from_bezpath(&BezPath::from_svg(data).unwrap()); + let bounds = path.bounds().unwrap(); + let uncapped = revolve(&path, bounds, Revolve { cap_ends: false, ..options() }).unwrap(); + assert_eq!(revolve(&path, bounds, options()).unwrap(), uncapped, "{data}"); + } + let path = PathData::from_bezpath(&BezPath::from_svg("M0 10 L40 90").unwrap()); + let faces = revolve(&path, path.bounds().unwrap(), options()).unwrap(); + assert_eq!(caps(&faces).count(), 1, "only the nonzero endpoint needs a cap"); +} + +#[test] +fn curved_compound_profiles_caps_remain_finite_shaded_and_expandable() { + let path = PathData::from_bezpath(&BezPath::from_svg("M40 10 C70 30 20 60 50 90 M30 110 L50 150").unwrap()); + let bounds = Rect::new(0.0, 10.0, 70.0, 150.0); + let faces = + revolve(&path, bounds, Revolve { rotation_x: 35.0, rotation_y: 27.0, perspective: perspective_from_strength(0.8), shade: true, ..options() }) + .unwrap(); + assert_eq!(caps(&faces).count(), 4); + assert!(caps(&faces).all(|face| face.brightness.is_finite() && (0.0..=1.0).contains(&face.brightness))); + assert!(!visible_faces(&faces).unwrap().is_empty()); + for sp in &path.subpaths { + let single = PathData::new(vec![sp.clone()]); + let reversed = PathData::from_bezpath(&single.to_bezpath().reverse_subpaths()); + let a = revolve(&single, bounds, options()).unwrap(); + let b = revolve(&reversed, bounds, options()).unwrap(); + assert!((caps(&a).map(area).sum::() - caps(&b).map(area).sum::()).abs() < 1e-8); + } +} + +#[test] +fn caps_share_surface_limits_and_malformed_inputs_stay_bounded() { + let (path, bounds) = cylinder(); + let faces = revolve(&path, bounds, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..options() }).unwrap(); + assert!(faces.len() <= 384); + let mut excessive = BezPath::new(); + excessive.move_to((40.0, 0.0)); + for i in 1..=1024 { + excessive.line_to((40.0, i as f64)); + } + assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, 1024.0), options()).is_err()); + assert!(revolve(&PathData::default(), bounds, options()).unwrap().is_empty()); + // Viewed along the axis, the side walls disappear but the caps still consume + // the same face budget. Repeated rings cannot bypass it through degenerate walls. + let repeated = PathData::new(vec![path.subpaths[0].clone(); 129]); + let o = Revolve { segments: 128, ..options() }; + assert!(revolve(&repeated, bounds, Revolve { cap_ends: false, ..o }).unwrap().is_empty()); + assert!(revolve(&repeated, bounds, o).unwrap_err().contains("surface is too complex")); +} + +#[test] +fn caps_are_single_curved_paths_and_body_rims_share_their_exact_curves() { + use vectorcraft_geom::PathSeg; + let (path, bounds) = cylinder(); + for smooth_shading in [false, true] { + for angle in [90.0, 270.0, 360.0] { + let faces = revolve( + &path, + bounds, + Revolve { + angle, + segments: 8, + shade: true, + smooth_shading, + rotation_x: -28.0, + perspective: perspective_from_strength(0.7), + ..options() + }, + ) + .unwrap(); + let body: Vec<_> = faces.iter().filter(|f| !f.cap).flat_map(|f| f.path().to_bezpath().segments().collect::>()).collect(); + for cap in caps(&faces) { + let outline = cap.path(); + assert_eq!(outline.subpaths.len(), 1); + assert!(outline.subpaths[0].closed); + let mut curves = 0; + for segment in outline.to_bezpath().segments() { + if let PathSeg::Cubic(c) = segment { + curves += 1; + let same = |a: Point, b: Point| a.distance(b) < 1e-10; + assert!( + body.iter().any(|s| match s { + PathSeg::Cubic(b) => + (same(c.p0, b.p0) && same(c.p1, b.p1) && same(c.p2, b.p2) && same(c.p3, b.p3)) + || (same(c.p0, b.p3) && same(c.p1, b.p2) && same(c.p2, b.p1) && same(c.p3, b.p0)), + _ => false, + }), + "cap and body use the same rim" + ); + } + } + assert!(curves >= 8, "the cap contains curves instead of a polygon fan"); + assert!(cap.gradient.is_none(), "flat cap lighting has no radial seams"); + } + let uncut = visible_faces(&faces[faces.len() - 1..]).unwrap(); + assert_eq!(uncut[0].path, faces.last().unwrap().path(), "uncut visibility preserves the curved outline"); + } + } +} + +#[test] +fn cap_centers_follow_the_projected_axis_at_wide_perspective_angles() { + let (path, bounds) = cylinder(); + for perspective in [0.0, perspective_from_strength(0.7), 160.0] { + let o = Revolve { perspective, rotation_x: -28.0, rotation_y: 27.0, shade: true, ..options() }; + let faces = revolve(&path, bounds, o).unwrap(); + let centers: Vec<_> = caps(&faces).map(|face| face.points[0]).collect(); + assert_eq!(centers.len(), 2); + for center in o.projected_axis(bounds) { + assert!(centers.iter().any(|p| p.distance(center) < 1e-10), "cap and axis share the perspective projection"); + } + assert!(faces.iter().flat_map(|face| &face.points).all(|p| p.x.is_finite() && p.y.is_finite())); + } +} + +#[test] +fn capped_visibility_matches_painted_surfaces_above_below_and_for_major_sectors() { + use vectorcraft_geom::Shape; + let (path, bounds) = cylinder(); + for edge in [Edge::Left, Edge::Right] { + for (angle, rotation_x, perspective, smooth_shading) in + [(360.0, -28.0, 0.0, false), (360.0, 28.0, perspective_from_strength(0.7), true), (270.0, -32.0, perspective_from_strength(0.7), true)] + { + let faces = revolve( + &path, + bounds, + Revolve { angle, edge, offset: 20.0, shade: true, rotation_x, rotation_y: 17.0, perspective, smooth_shading, ..options() }, + ) + .unwrap(); + let visible = visible_faces(&faces).unwrap(); + let all: Vec<_> = faces.iter().map(|f| (f.path().to_bezpath(), f.depth)).collect(); + let cut: Vec<_> = visible.iter().map(|f| (f.path.to_bezpath(), f.depth)).collect(); + for x in 0..40 { + for y in 0..40 { + let p = Point::new(-100.0 + (x as f64 + 0.337) * 6.0, -50.0 + (y as f64 + 0.413) * 6.0); + let expected = all.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, depth)| depth); + let actual = cut.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, depth)| depth); + assert_eq!(actual, expected, "{edge:?}, {angle}, {rotation_x}, {p:?}"); + } + } + } + } +} diff --git a/crates/three-d/src/tests_smooth.rs b/crates/three-d/src/tests_smooth.rs new file mode 100644 index 000000000..fd92bbada --- /dev/null +++ b/crates/three-d/src/tests_smooth.rs @@ -0,0 +1,123 @@ +use super::*; + +fn path(d: &str) -> PathData { + PathData::from_bezpath(&BezPath::from_svg(d).unwrap()) +} + +fn value(f: &Face, p: Point) -> f64 { + f.gradient.map_or(f.brightness, |g| g.at(p)) +} + +#[test] +fn gradient_matches_three_vertex_values_and_shared_diagonal() { + let [a, b, c, d] = [Point::new(10.0, 20.0), Point::new(90.0, 30.0), Point::new(80.0, 95.0), Point::new(0.0, 85.0)]; + let g = ShadeGradient::triangle(&[a, b, c], &[0.2, 0.9, 0.6]).unwrap(); + let h = ShadeGradient::triangle(&[a, c, d], &[0.2, 0.6, 0.4]).unwrap(); + for (p, expected) in [(a, 0.2), (b, 0.9), (c, 0.6)] { + assert!((g.at(p) - expected).abs() < 1e-12); + } + for t in [0.0, 0.1, 0.5, 0.9, 1.0] { + assert!((g.at(a.lerp(c, t)) - h.at(a.lerp(c, t))).abs() < 1e-12); + } + assert!(ShadeGradient::triangle(&[a, a, c], &[0.1, 0.5, 0.9]).is_none()); + assert!(ShadeGradient::triangle(&[a, b, c], &[0.4; 3]).is_none()); +} + +#[test] +fn curved_profile_shares_lighting_across_radial_and_curve_sample_edges() { + let p = path("M40 0 C70 20 70 60 40 80"); + let o = Revolve { smooth_shading: true, cap_ends: false, offset: 20.0, angle: 90.0, rotation_y: -45.0, segments: 16, ..Default::default() }; + let faces = revolve(&p, p.bounds().unwrap(), o).unwrap(); + assert!(faces.iter().filter(|f| f.gradient.is_some()).count() > 60); + let mut edges = 0; + for (i, a) in faces.iter().enumerate() { + for b in faces.iter().skip(i + 1) { + let shared: Vec<_> = a.points.iter().filter(|p| b.points.iter().any(|q| p.distance(*q) < 1e-8)).copied().collect(); + if let [p, q] = shared.as_slice() { + edges += 1; + for t in [0.0, 0.5, 1.0] { + let p = p.lerp(*q, t); + assert!((value(a, p) - value(b, p)).abs() < 1e-8, "lighting seam at {p:?}"); + } + } + } + } + assert!(edges > 100, "test both curve sample boundaries and triangle diagonals"); +} + +#[test] +fn profile_corners_and_caps_retain_hard_edges() { + let p = path("M40 0 L40 40 L70 40"); + let o = Revolve { smooth_shading: true, offset: 30.0, angle: 90.0, rotation_y: -45.0, rotation_x: -20.0, segments: 16, ..Default::default() }; + let faces = revolve(&p, p.bounds().unwrap(), o).unwrap(); + let mut hard = false; + for a in &faces { + for b in &faces { + let shared: Vec<_> = a.points.iter().filter(|p| b.points.iter().any(|q| p.distance(*q) < 1e-8)).copied().collect(); + if shared.len() == 2 && shared.iter().any(|p| (value(a, *p) - value(b, *p)).abs() > 0.1) { + hard = true; + } + } + } + assert!(hard, "the profile's ninety-degree corner is not rounded by shading"); + let p = path("M40 0 L40 80"); + let flat = revolve(&p, p.bounds().unwrap(), Revolve { cap_ends: true, smooth_shading: false, ..o }).unwrap(); + let smooth = revolve(&p, p.bounds().unwrap(), Revolve { cap_ends: true, ..o }).unwrap(); + let caps: Vec<_> = flat.iter().filter(|f| f.cap).collect(); + assert_eq!(caps.len(), 2); + assert!(caps.into_iter().all(|cap| smooth.contains(cap)), "caps keep their exact flat shading"); +} + +#[test] +fn smooth_vertices_use_the_shared_soft_light_and_back_light() { + let p = path("M40 0 L40 80"); + let bounds = p.bounds().unwrap(); + for behind in [false, true] { + let o = Revolve { + offset: 30.0, + angle: 90.0, + rotation_x: -20.0, + rotation_y: -30.0, + cap_ends: false, + light_rotation: 0.0, + light_height: 30.0, + light_behind: behind, + light_softness: 0.8, + ambient: 0.15, + ..Default::default() + }; + let faces = revolve(&p, bounds, o).unwrap(); + let vertex = o.rotate(V3(30.0, -40.0, 0.0)); + let point = Point::new(10.0 + vertex.0, 40.0 + vertex.1); + let normal = o.rotate(V3(1.0, 0.0, 0.0)); + let [x, y, z] = o.light_direction(); + let expected = (0.15 + 0.8 * ((normal.dot(V3(x, y, z)) + 0.8) / 1.8).max(0.0)).clamp(0.0, 1.0); + let samples: Vec<_> = faces.iter().filter(|face| face.points.iter().any(|p| p.distance(point) < 1e-10)).collect(); + assert!(!samples.is_empty()); + for face in samples { + assert!((value(face, point) - expected).abs() < 1e-10, "smooth shading preserves Softness and the light's side"); + } + } +} + +#[test] +fn unshaded_legacy_degenerate_and_high_complexity_surfaces_stay_bounded() { + let p = path("M40 0 L40 80"); + let b = p.bounds().unwrap(); + let o = Revolve { offset: 30.0, ..Default::default() }; + assert!(revolve(&p, b, o).unwrap().iter().any(|f| f.gradient.is_some())); + assert!(revolve(&p, b, Revolve { smooth_shading: false, ..o }).unwrap().iter().all(|f| f.gradient.is_none())); + assert_eq!(revolve(&p, b, Revolve { shade: false, ..o }).unwrap(), revolve(&p, b, Revolve { shade: false, smooth_shading: true, ..o }).unwrap()); + for edge in [Edge::Left, Edge::Right] { + for perspective in [0.0, perspective_from_strength(1.0), 160.0] { + let o = Revolve { smooth_shading: true, edge, perspective, rotation_x: -35.0, ..o }; + let faces = revolve(&p, b, o).unwrap(); + assert!(faces.iter().all(|f| f.points.iter().all(|p| value(f, *p).is_finite() && (0.0..=1.0).contains(&value(f, *p))))); + assert!(visible_faces(&faces).is_ok()); + } + } + assert!(revolve(&p, b, Revolve { smooth_shading: true, angle: 0.0, ..o }).unwrap().is_empty()); + let mut p = path("M40 0"); + p.subpaths[0].anchors.extend((1..400).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64)))); + assert!(revolve(&p, p.bounds().unwrap(), Revolve { smooth_shading: true, segments: 128, offset: 30.0, ..o }).is_err()); +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs index 01f76c7d1..9feac1a3b 100644 --- a/crates/three-d/src/visibility.rs +++ b/crates/three-d/src/visibility.rs @@ -4,12 +4,16 @@ use vectorcraft_geom::{PathData, Point, Rect, SubPath}; -use crate::{Face, MAX_FACES}; +use crate::{Face, MAX_FACES, ShadeGradient}; +#[path = "visibility_parts.rs"] +mod parts; const GRID: usize = 32; const MAX_GRID_REFS: usize = 2_000_000; const MAX_WORK: usize = 16_000_000; -const MAX_FRAGMENTS: usize = 256; +// Curved boundaries may begin with several convex pieces. Their total work and +// anchors still share the global limits used for ordinary projected faces. +const MAX_FRAGMENTS: usize = 1024; const MAX_OUTPUT_POINTS: usize = 262_144; /// The visible portions of one source face, with its original shading and paint order. @@ -18,6 +22,7 @@ pub struct VisibleFace { pub path: PathData, pub brightness: f64, pub depth: f64, + pub gradient: Option, } struct Polygon { @@ -81,6 +86,34 @@ fn subtract(points: Vec, cover: &Polygon, epsilon: f64, work: &mut usize) if !overlaps(bounds(&points), cover.bounds) { return Ok(vec![points]); } + // Bounding boxes of neighbouring curved pieces overlap even when the pieces + // only share an edge. Reject separated convex polygons before cutting them, + // so irrelevant covers cannot subdivide a face into hundreds of fragments. + let mut separated = false; + 'edges: for (polygon, other) in [(&cover.points, &points), (&points, &cover.points)] { + for (a, b) in polygon.iter().zip(polygon.iter().cycle().skip(1)).take(polygon.len()) { + *work += other.len(); + if *work > MAX_WORK { + return Err("Revolve visibility exceeds its geometry work limit".into()); + } + if other.iter().all(|p| (*b - *a).cross(*p - *a) <= epsilon * b.distance(*a)) { + separated = true; + break 'edges; + } + } + } + if separated { + return Ok(vec![points]); + } + if cover + .points + .iter() + .zip(cover.points.iter().cycle().skip(1)) + .take(cover.points.len()) + .all(|(a, b)| points.iter().all(|p| (*b - *a).cross(*p - *a) >= -epsilon * b.distance(*a))) + { + return Ok(vec![]); + } let mut remaining = points; let mut visible = vec![]; for (a, b) in cover.points.iter().zip(cover.points.iter().cycle().skip(1)).take(cover.points.len()) { @@ -138,13 +171,27 @@ pub fn visible_faces(faces: &[Face]) -> Result, String> { if faces.len() > MAX_FACES { return Err("Too many Revolve faces for visibility evaluation".into()); } + let count = |n: usize, len: usize| n.checked_add(len).filter(|&n| n <= MAX_OUTPUT_POINTS).ok_or("Revolve visibility has too many input anchors"); + faces.iter().map(|f| f.points.len()).try_fold(0, count)?; + faces.iter().filter_map(|f| f.visibility_pieces.as_ref()).flatten().map(Vec::len).try_fold(0, count)?; + faces.iter().filter_map(|f| f.outline.as_ref()).flat_map(|p| &p.subpaths).map(|p| p.anchors.len()).try_fold(0, count)?; if faces.iter().any(|f| { - f.points.len() > 4 + f.points.len() > 1026 || !f.brightness.is_finite() || !f.depth.is_finite() + || f.gradient.is_some_and(|g| [g.start.x, g.start.y, g.end.x, g.end.y, g.brightness[0], g.brightness[1]].iter().any(|v| !v.is_finite())) || f.points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8) + || f.visibility_pieces.as_ref().is_some_and(|pieces| { + pieces.iter().any(|p| p.len() > 1026 || p.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8)) + }) + || f.outline.as_ref().is_some_and(|p| { + p.subpaths + .iter() + .flat_map(|s| &s.anchors) + .any(|a| [a.p, a.h_in, a.h_out].iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8)) + }) }) { - return Err("Revolve visibility requires finite projected convex faces".into()); + return Err("Revolve visibility requires finite projected faces".into()); } let all = faces.iter().flat_map(|f| &f.points).copied().collect::>(); let total = bounds(&all); @@ -152,33 +199,27 @@ pub fn visible_faces(faces: &[Face]) -> Result, String> { return Ok(vec![]); } let epsilon = (total.width().max(total.height()) * 1e-10).clamp(1e-8, 1e-3); - let polygons: Vec<_> = faces + let mut work = 0; + let polygons: Vec> = faces .iter() - .map(|f| { - let points = clean(f.points.clone(), epsilon); - Polygon { bounds: bounds(&points), points } - }) - .collect(); - // Generated quads are convex. Validate the public helper rather than accepting concave - // callers and silently deleting geometry they can see. - for p in &polygons { - if p.points.iter().enumerate().any(|(i, a)| { - let Some(b) = p.points.get((i + 1) % p.points.len()) else { return false }; - let Some(c) = p.points.get((i + 2) % p.points.len()) else { return false }; - (*b - *a).cross(*c - *b) < -epsilon * b.distance(*a).max(1.0) - }) { - return Err("Revolve visibility requires convex projected faces".into()); - } - } + .map(|f| Ok(parts::pieces(f, epsilon, &mut work)?.into_iter().map(|points| Polygon { bounds: bounds(&points), points }).collect())) + .collect::>()?; + let covers: Vec<_> = polygons.iter().flatten().collect(); let mut index = Index { cells: vec![vec![]; GRID * GRID], bounds: total, refs: 0 }; - let mut seen = vec![usize::MAX; faces.len()]; - let (mut out, mut work, mut output_points) = (vec![], 0, 0); - for (source, (face, polygon)) in faces.iter().zip(&polygons).enumerate().rev() { - if polygon.points.is_empty() { + let mut seen = vec![usize::MAX; covers.len()]; + let (mut out, mut output_points) = (vec![], 0); + let mut cover_end = covers.len(); + for (source, (face, pieces)) in faces.iter().zip(&polygons).enumerate().rev() { + let cover_start = cover_end.saturating_sub(pieces.len()); + let source_covers = cover_start..cover_end; + cover_end = cover_start; + if pieces.is_empty() { continue; } - let mut fragments = vec![polygon.points.clone()]; - 'covers: for tile in index.tiles(polygon.bounds) { + let source_bounds = bounds(&face.points); + let mut fragments: Vec<_> = pieces.iter().map(|p| p.points.clone()).collect(); + let mut clipped = false; + 'covers: for tile in index.tiles(source_bounds) { for &candidate in index.cells.get(tile).into_iter().flatten() { work += 1; if work > MAX_WORK { @@ -188,16 +229,17 @@ pub fn visible_faces(faces: &[Face]) -> Result, String> { && *mark != source { *mark = source; - if let Some(cover) = polygons.get(candidate) - && overlaps(polygon.bounds, cover.bounds) + if let Some(cover) = covers.get(candidate) + && overlaps(source_bounds, cover.bounds) { let mut remaining = vec![]; - for fragment in fragments { - remaining.extend(subtract(fragment, cover, epsilon, &mut work)?); + for fragment in &fragments { + remaining.extend(subtract(fragment.clone(), cover, epsilon, &mut work)?); if remaining.len() > MAX_FRAGMENTS { return Err("Revolve visibility creates too many fragments".into()); } } + clipped |= remaining != fragments; fragments = remaining; if fragments.is_empty() { break 'covers; @@ -213,11 +255,15 @@ pub fn visible_faces(faces: &[Face]) -> Result, String> { if output_points > MAX_OUTPUT_POINTS { return Err("Revolve visibility creates too many vector anchors".into()); } - let path = PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect()); - out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth }); + let path = if !clipped { face.path() } else { PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect()) }; + out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth, gradient: face.gradient }); // A completely covered face need not occupy the grid. Its coverage already belongs // to nearer faces; this keeps repeated overlapping surfaces bounded in practice. - index.insert(polygon.bounds, source)?; + for candidate in source_covers { + if let Some(cover) = covers.get(candidate) { + index.insert(cover.bounds, candidate)?; + } + } } out.reverse(); Ok(out) @@ -234,6 +280,10 @@ mod tests { points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)], depth: brightness, brightness, + gradient: None, + outline: None, + cap: false, + visibility_pieces: None, } } diff --git a/crates/three-d/src/visibility_parts.rs b/crates/three-d/src/visibility_parts.rs new file mode 100644 index 000000000..2a1443502 --- /dev/null +++ b/crates/three-d/src/visibility_parts.rs @@ -0,0 +1,121 @@ +//! Bounded convex pieces for continuous caps and curved endpoint faces. + +use super::{Face, MAX_WORK, Point, area, clean}; + +fn convex(points: &[Point], epsilon: f64) -> bool { + points.iter().enumerate().all(|(i, a)| { + let Some(b) = points.get((i + 1) % points.len()) else { return true }; + let Some(c) = points.get((i + 2) % points.len()) else { return true }; + (*b - *a).cross(*c - *b) >= -epsilon * b.distance(*a).max(1.0) + }) +} + +fn triangulate(mut points: Vec, epsilon: f64, work: &mut usize, depth: usize) -> Result>, String> { + if points.is_empty() { + return Ok(vec![]); + } + if convex(&points, epsilon) { + return Ok(vec![points]); + } + // At a grazing view a curved rim can fold across a triangle's diagonal. + // Split that crossing before ear clipping; its two lobes both have nonzero + // winding and must remain visible. Bound splitting for public helper inputs. + for (i, (a, b)) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).enumerate() { + for j in i + 2..points.len() { + if (j + 1) % points.len() == i { + continue; + } + *work += 1; + if *work > MAX_WORK { + return Err("Revolve visibility exceeds its geometry work limit".into()); + } + let Some(c) = points.get(j) else { continue }; + let Some(d) = points.get((j + 1) % points.len()) else { continue }; + let (ab, cd) = (*b - *a, *d - *c); + let denominator = ab.cross(cd); + if denominator.abs() <= epsilon * epsilon { + continue; + } + let (t, u) = ((*c - *a).cross(cd) / denominator, (*c - *a).cross(ab) / denominator); + if t > 1e-9 && t < 1.0 - 1e-9 && u > 1e-9 && u < 1.0 - 1e-9 { + if depth >= 8 { + return Err("Revolve visibility has too many boundary crossings".into()); + } + let intersection = a.lerp(*b, t); + let first = std::iter::once(intersection).chain(points.get(i + 1..=j).unwrap_or_default().iter().copied()).collect(); + let second = std::iter::once(intersection).chain(points.iter().cycle().skip(j + 1).take(points.len() + i - j).copied()).collect(); + let mut out = triangulate(clean(first, epsilon), epsilon, work, depth + 1)?; + out.extend(triangulate(clean(second, epsilon), epsilon, work, depth + 1)?); + return Ok(out); + } + } + } + let mut out = vec![]; + while points.len() > 3 { + let mut ear = None; + for (i, b) in points.iter().enumerate() { + let Some(a) = points.get((i + points.len() - 1) % points.len()) else { continue }; + let Some(c) = points.get((i + 1) % points.len()) else { continue }; + if (*b - *a).cross(*c - *b) <= epsilon * epsilon { + continue; + } + let mut blocked = false; + for p in &points { + *work += 1; + if *work > MAX_WORK { + return Err("Revolve visibility exceeds its geometry work limit".into()); + } + if p != a + && p != b + && p != c + && (*b - *a).cross(*p - *a) >= -epsilon * epsilon + && (*c - *b).cross(*p - *b) >= -epsilon * epsilon + && (*a - *c).cross(*p - *c) >= -epsilon * epsilon + { + blocked = true; + break; + } + } + if !blocked { + ear = Some((i, vec![*a, *b, *c])); + break; + } + } + let Some((i, triangle)) = ear else { return Err("Revolve visibility cannot split the projected boundary".into()) }; + out.push(triangle); + points.remove(i); + } + if area(&points).abs() > epsilon * epsilon { + out.push(points); + } + Ok(out) +} + +pub(super) fn pieces(face: &Face, epsilon: f64, work: &mut usize) -> Result>, String> { + if let Some(strips) = &face.visibility_pieces { + let mut out = vec![]; + for strip in strips { + out.extend(triangulate(clean(strip.clone(), epsilon), epsilon, work, 0)?); + } + return Ok(out); + } + if !face.cap { + return triangulate(clean(face.points.clone(), epsilon), epsilon, work, 0); + } + let Some((center, ring)) = face.points.split_first() else { return Ok(vec![]) }; + let mut out = vec![]; + // At least 512 samples per full circle makes 128 edges at most a quarter + // turn. These sectors are convex even when the complete sweep exceeds 180°. + for start in (0..ring.len().saturating_sub(1)).step_by(128) { + let end = (start + 128).min(ring.len().saturating_sub(1)); + let Some(arc) = ring.get(start..=end) else { continue }; + let points = clean(std::iter::once(*center).chain(arc.iter().copied()).collect(), epsilon); + if !convex(&points, epsilon) { + return Err("Revolve visibility requires convex cap sectors".into()); + } + if !points.is_empty() { + out.push(points); + } + } + Ok(out) +} diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 86ed59cf6..813c66aff 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -90,6 +90,18 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } }); number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit)); + ui.horizontal(|ui| { + let mut cap = d.bool("capEnds"); + let tip = tl!("Close the circular ends of open profiles. Partial sweeps keep their cut sides open."); + if widgets::check_tip(ui, c3("Cap"), cap, true, tip) { + cap = !cap; + d.fields.insert("capEnds".into(), json!(cap)); + } + ui.add_space((ui.available_width() - 24.0).max(0.0)); + if reset_dot(ui, "capEnds", !cap, "true") { + d.fields.insert("capEnds".into(), json!(true)); + } + }); ui.add_space(10.0); heading(ui, tl!("View")); for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] { diff --git a/crates/ui-egui/src/dialogs/tests_revolve.rs b/crates/ui-egui/src/dialogs/tests_revolve.rs index 29c14b5bf..7360704ff 100644 --- a/crates/ui-egui/src/dialogs/tests_revolve.rs +++ b/crates/ui-egui/src/dialogs/tests_revolve.rs @@ -91,7 +91,8 @@ fn values_labels_sliders_and_reset_dots_change_the_live_effect_in_one_undo_step( h.settle(); assert!(!h.rect("Advanced").is_negative(), "Advanced is visible without scrolling in a normal window"); let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); - assert!(panel.width() < 420.0 && panel.height() < 540.0, "compact controls: {panel:?}"); + // Cap adds one 28-point row to the shared dialog's previous 540-point bound. + assert!(panel.width() < 420.0 && panel.height() < 570.0, "compact controls with Cap: {panel:?}"); let label = h.rect("Rotation X"); h.drag(pos2(label.left() + LABEL_W + 6.0 + VALUE_W / 2.0, label.center().y), vec2(40.0, 0.0)); assert!(h.value("rotationX") > 0.0, "the value itself drags"); @@ -166,6 +167,54 @@ fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() { assert!(!h.rect("Rotation X").is_negative()); } +#[test] +fn automatic_smooth_shading_previews_saves_and_respects_cancel_and_one_step_undo() { + for confirm in [false, true] { + let mut h = Harness::new(vec2(1280.0, 900.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + h.click(h.rect("Lighting").center()); + assert!(!h.texts.iter().any(|(text, _)| text == "Smooth shading")); + assert!(!h.app.ui.dialog.as_ref().unwrap().fields.contains_key("smoothShading")); + let art = |h: &Harness| { + let state = h.app.session.doc().unwrap(); + vectorcraft_effects::revolve_art(state.doc.node(state.selection.objects[0]).unwrap()).unwrap() + }; + let gradients = |n: &vectorcraft_doc::Node| { + let mut count = 0; + n.walk(&mut |face| { + count += face.appearance.items.iter().filter(|item| { + matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_))) + }).count(); + }); + count + }; + assert!(gradients(&art(&h)) > 0, "default rendering uses vector gradients"); + h.click(h.rect("Shading").center()); + assert_eq!(gradients(&art(&h)), 0, "Shading still controls all lighting"); + h.click(h.response(("revolve-reset", "shade")).center()); + assert!(gradients(&art(&h)) > 0); + h.drag(h.rect("Height").center(), vec2(24.0, 0.0)); + h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false)); + h.settle(); + assert_eq!(h.app.session.doc().unwrap().doc, before); + h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + h.settle(); + if confirm { + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap(); + assert!(!node.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading")); + assert_eq!(vectorcraft_effects::revolve_art(loaded.node(node.id).unwrap()), vectorcraft_effects::revolve_art(node)); + h.app.run("edit.undo", json!({})).unwrap(); + } else { + dialogs::cancel(&mut h.app); + } + assert_eq!(h.app.session.doc().unwrap().doc, before); + } +} + #[test] fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_complete() { let mut h = Harness::new(vec2(1280.0, 1000.0)); @@ -257,3 +306,43 @@ fn extrude_bevel_options_enable_with_a_bevel_shape() { assert!(h.ctx.read_response(Id::new(("revolve-reset", "bevelWidth"))).unwrap().enabled()); assert_eq!(h.response(("revolve-reset", "bevelWidth")), width); } + +#[test] +fn cap_checkbox_previews_resets_and_respects_cancel_and_one_step_undo() { + for confirm in [false, true] { + let mut h = Harness::new(vec2(1280.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + let dialog = h.app.ui.dialog.as_mut().unwrap(); + dialog.fields.insert("offset".into(), json!(20)); + dialog.fields.insert("rotationX".into(), json!(-35)); + h.settle(); + let art = |h: &Harness| { + let state = h.app.session.doc().unwrap(); + vectorcraft_effects::revolve_art(state.doc.node(state.selection.objects[0]).unwrap()).unwrap() + }; + assert!(h.app.ui.dialog.as_ref().unwrap().bool("capEnds"), "Cap starts on"); + let capped = art(&h); + h.click(h.rect("Cap").center()); + assert!(!h.app.ui.dialog.as_ref().unwrap().bool("capEnds")); + assert_ne!(art(&h), capped); + h.click(h.response(("revolve-reset", "capEnds")).center()); + assert!(h.app.ui.dialog.as_ref().unwrap().bool("capEnds")); + assert_eq!(art(&h), capped); + h.click(h.rect("Cap").center()); + h.click(h.response("revolve-reset-all").center()); + assert!(h.app.ui.dialog.as_ref().unwrap().bool("capEnds")); + h.click(h.rect("Cap").center()); + if confirm { + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + assert_eq!(state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["capEnds"], json!(false)); + let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap(); + assert_eq!(loaded.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["capEnds"], json!(false)); + h.app.run("edit.undo", json!({})).unwrap(); + } else { + dialogs::cancel(&mut h.app); + } + assert_eq!(h.app.session.doc().unwrap().doc, before); + assert!(!h.app.session.in_interaction()); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index be90eb470..019b7294d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4808,3 +4808,6 @@ mark-style Japanese Japonské Softness Měkkost Ambient Light Okolní světlo Light behind the object Světlo za objektem + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Uzavře kruhové konce otevřených profilů. Boční řezy částečného otočení zůstávají otevřené. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e99f2e4b4..9233e1a71 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4335,3 +4335,6 @@ theme Appearance Erscheinungsbild Softness Weichheit Ambient Light Umgebungslicht Light behind the object Licht hinter dem Objekt + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Schließt die kreisförmigen Enden offener Profile. Bei einer Teildrehung bleiben die Schnittseiten offen. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 375f464a8..3878554fc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4749,3 +4749,6 @@ mark-style Japanese Japonesas Softness Suavidad Ambient Light Luz ambiental Light behind the object Luz detrás del objeto + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Cierra los extremos circulares de los perfiles abiertos. Los giros parciales mantienen abiertos sus lados de corte. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e7b53bd2b..f7b03083e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4808,3 +4808,6 @@ theme Appearance Apparence Softness Douceur Ambient Light Lumière ambiante Light behind the object Lumière derrière l'objet + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Ferme les extrémités circulaires des profils ouverts. Les rotations partielles conservent leurs faces de coupe ouvertes. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4a22f544c..d3c5c046a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4775,3 +4775,6 @@ imageTrace Outline Körvonal Softness Lágyság Ambient Light Környezeti fény Light behind the object Fény az objektum mögött + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Nyitott profilok kör alakú végeinek lezárása. Részleges körforgatás esetén a vágott oldalak nyitva maradnak. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 815a59418..0815f179c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4380,3 +4380,6 @@ lighting Light Luce Softness Morbidezza Ambient Light Luce ambientale Light behind the object Luce dietro l'oggetto + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Chiude le estremità circolari dei profili aperti. Le rotazioni parziali mantengono aperti i lati di taglio. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b32e5be32..eae3a98b4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4374,3 +4374,6 @@ lighting Light ライト Softness 柔らかさ Ambient Light 環境光 Light behind the object オブジェクトの背後のライト + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 開いたプロファイルの円形の端を閉じます。部分的な回転では切断面は開いたままです。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8d7262a08..0cdf26eca 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4344,3 +4344,6 @@ lighting Light 광원 Softness 부드러움 Ambient Light 주변광 Light behind the object 개체 뒤의 조명 + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 열린 프로파일의 원형 끝을 닫습니다. 부분 회전에서는 절단 측면이 열린 상태로 유지됩니다. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 05ccaf1ff..94b26c614 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4651,3 +4651,6 @@ mark-style Japanese Japońskie Softness Miękkość Ambient Light Światło otoczenia Light behind the object Światło za obiektem + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Zamknij okrągłe końce otwartych profili. Przy częściowym obrocie boki przekroju pozostają otwarte. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c39b61c7b..df7236bb2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3763,3 +3763,6 @@ axis Both Ambos Softness Suavidade Ambient Light Luz ambiente Light behind the object Luz atrás do objeto + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Fecha as extremidades circulares dos perfis abertos. Giros parciais mantêm as faces de corte abertas. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b531a6b9e..c2169243b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4376,3 +4376,6 @@ lighting Light Свет Softness Мягкость Ambient Light Окружающий свет Light behind the object Свет позади объекта + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Закрывает круглые торцы открытых профилей. При неполном вращении боковые срезы остаются открытыми. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c1fe4d1e1..98c660baf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4360,3 +4360,6 @@ lighting Light Світло Softness М'якість Ambient Light Навколишнє світло Light behind the object Світло позаду об'єкта + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Закриває круглі торці відкритих профілів. За неповного обертання бічні зрізи залишаються відкритими. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0a8e6e63c..f69845065 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4362,3 +4362,6 @@ theme Appearance Giao diện Softness Độ mềm Ambient Light Ánh sáng môi trường Light behind the object Ánh sáng phía sau đối tượng + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Đóng các đầu tròn của biên dạng mở. Khi xoay một phần, các mặt cắt bên vẫn mở. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5467f4979..5eedc8b56 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3957,3 +3957,6 @@ lighting Light 光源 Softness 柔和度 Ambient Light 环境光 Light behind the object 光源位于对象后方 + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封闭开放轮廓的圆形端部。部分旋转的切割侧面保持开放。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 814a0b84e..d305d8162 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3741,3 +3741,6 @@ lighting Light 光源 Softness 柔和度 Ambient Light 環境光 Light behind the object 光源位於物件後方 + +# Revolve cap description + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封閉開放輪廓的圓形端部。部分旋轉的切割側面保持開放。 diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 7b367af95..3670f615d 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -84,21 +84,22 @@ not an accuracy guarantee for arbitrary artwork. Tests: `pixel::tests::film_grai | Item | Ours | |---|---| -| Revolve | initial: live, editable profile, rotation and light gizmos, flat shading, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | +| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | -| Materials panel, lighting, ray-traced render | one directional light with the documented Intensity, Rotation, Height, Softness and Ambient Light, flat shading; no materials, several lights, light colour or presets, shadows or ray tracing | +| Materials panel, lighting, ray-traced render | one directional light with the documented Intensity, Rotation, Height, Softness and Ambient Light, Revolve uses smooth vector shading, the other types use flat facets; no materials, several lights, light colour or presets, shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | none | -The `three-d` crate (depends only on `geom`) builds, rotates, projects, shades and depth-sorts the surfaces; +The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials, several lights and shadows, ray-traced rendering, -Revolve caps, 3D Classic, Turntable, 3D export) is 25–45 h. +angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 h. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | | 2026-10-14 | minor | Wide drop shadows and glows at high zoom render within the raster budget (#1706); stacked Transform effects are bounded (#1705); 3D effects keep the area a nonzero compound path fills (#1724); pattern tiles redraw when a symbol, pattern or image their art shows changes (#1723) (all #1864). Scores unchanged (estimated) | | 2026-10-14 | minor | Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); scores unchanged (estimated) | | 2026-10-13 | minor | Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592); scores unchanged (estimated) | diff --git a/docs/gaps.md b/docs/gaps.md index b59be1491..8100c524d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -214,10 +214,10 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G3 3D and Materials -- **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605); +- **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605), with automatic smooth vector shading, default-on circular caps and spike-free continuous rims; Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). - **Missing:** materials (base, Substance, graphics), several lights, light colour and presets, shadows, - ray-traced rendering, Revolve caps, exact visibility for intersecting and concave surfaces, 3D Classic, + ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, 3D Classic, Turntable, 3D export (OBJ, USDA, glTF). - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. @@ -285,6 +285,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | | 2026-10-14 | minor | No entry closed. G8 and G11 record the later merges of 2026-10-14 (#1852, #1862–#1865, #1871–#1873, #1839, #1840, #1856); #1683 (Dim Images) and #1685 (failed-save dialog) are done by #1864 and #1865. Gaps noted for reporters: G1 Effect › Warp through Warp Options (#1760), Color Picker Tab order (#1692), aligning to a key anchor (#1636; the public Align page does not say how the key anchor is chosen); G7 custom toolbars (#1759), workspace panel widths (#1753); G11 Shape Builder regions between closed shapes (#1691), deleting an interior anchor of an open path; G13 Intertwine (#1763), gradient mesh from an embedded image (#1676), Image Trace radial gradients (draft #1849). Average and Join with a corner or smooth choice was already listed (G1). G12: a line two merges had left twice is there once, without Appearance of Black for output, which acts since #1750. Estimates unchanged | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 5ea21cc75..7641467b2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1028,8 +1028,11 @@ Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, `lightRotation` (degrees, -180..180, default 125.26; 0 on the object's right, counterclockwise), `lightHeight` (degrees, 0..90, default 30; 90 straight in front), `lightBehind` (default false), `lightSoftness` (0..100%, default 0), `ambient` (Ambient Light, 0..200%, default 25; an earlier `lightAzimuth` and `lightElevation` are read -as the same light), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius. -Open profiles have no end caps. Visibility of intersecting surfaces is approximate. +as the same light), `capEnds` (boolean, default true), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius. +`capEnds` closes each circular endpoint of an open profile with one curved, uniformly shaded +vector path. Body faces share that curved rim. Partial sweeps use sectors and leave their angular +cut sides open. Closed or coincident-end profiles are unchanged. +Missing `capEnds` enables caps; set it to false for open endpoints. Shaded Revolve surfaces automatically interpolate curved-surface lighting with vector gradients while preserving profile corners and flat caps. There is no separate smoothing parameter. `shade: false` disables all lighting. Visibility of intersecting surfaces is approximate. The dialog's Lighting tab manipulates the same `lightRotation` and `lightHeight` parameters; its guide visibility and tab choice are UI state, excluded from saved effect parameters. `expandVisibleOnly` is saved with the effect. It trims covered portions of opaque solid surfaces only @@ -1040,7 +1043,7 @@ See [Revolve](revolve.md) for the scope and limitations. ```json {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{"angle":360,"offset":20,"edge":"left","rotationX":0,"rotationY":0,"rotationZ":0}}}} -{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightRotation":30}}}} +{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightRotation":30,"capEnds":true}}}} {"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"expandVisibleOnly":true}}}} {"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{}}} ``` diff --git a/docs/revolve.md b/docs/revolve.md index 53edcf6b7..f3b8eb24c 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -14,6 +14,16 @@ Lighting is one directional light with the documented Light Settings (see [three **Intensity**, **Rotation**, **Height**, **Softness** and **Ambient Light**, and **Light behind the object**. Turn **Shading** off to keep the source paint without lighting. +Shaded Revolve surfaces automatically use smooth lighting from the profile's tangents, +represented by linear gradients on vector triangles. There is no separate smoothing control. +Segments still controls the body silhouette; smoothing does not increase radial resolution. +Capped endpoint rings use curved vector boundaries. Original profile corners remain sharp, +and circular end caps keep uniform flat lighting. Turning Shading off disables lighting. +Smooth lighting follows the same light gizmo, rotation, perspective, preview, Cancel and undo behavior. +SVG and PDF export retain vector paths and gradients; Expand Appearance keeps +those gradients at their original positions when trimming visible surfaces. Non-solid +source paints retain their paint with a gradient shading overlay. + The dialog has **Rotation** and **Lighting** tabs. Rotation groups the profile and view controls. Drag a value or its label to adjust it, use its slider, or double-click the value to type. Angles show degrees and lighting values show percentages. **Segments** is in the **Advanced** section. Lighting controls are disabled @@ -47,6 +57,19 @@ The source anchors remain editable. Appearance lists the effect for reopening it SVG/PDF export evaluates filled vector faces; native saves retain the profile and effect parameters. **Object > Expand Appearance** replaces the live effect with ordinary editable vector faces. +**Cap** in the shape controls closes the circular rings at the endpoints of an open profile. +It is on by default. Its saved parameter is `capEnds` (boolean, default true); missing parameters +also use caps. Set it to false to keep the ends open. The reset dot and Reset all restore Cap to on. Each cap is one closed curved vector path from the endpoint ring to the rotation axis, +with uniform flat shading. A full turn produces a projected circular disk (an ellipse in an +orthographic view); a partial turn produces a sector. Body faces share the same curved rim, +so no triangle patches interrupt the cap edge. Caps share the surface's rotation, perspective and paint, and appear in preview, +SVG/PDF export and Expand Appearance. The checkbox has a reset dot and is included in Reset all. + +An endpoint on the axis needs no cap. Closed profiles and open profiles whose endpoints coincide +keep their existing geometry. Each open subpath of a compound profile is capped independently. +A partial revolve adds only the swept sector at each endpoint; the angular cut sides remain open. +This option does not make intersecting or compound profiles into a watertight solid. + In **Advanced**, **Keep visible surfaces only when expanding** is on by default. Expansion removes completely covered faces and trims partially covered ones using the current rotation and perspective. Visible interior surfaces through open ends remain. Uncheck it to retain every projected face. @@ -56,27 +79,34 @@ The checkbox has its own reset dot and is included in **Reset all**. Its saved c Trimming applies to opaque solid paint. Transparent objects, gradients, patterns, non-normal blending, later geometry effects and surfaces exceeding bounded visibility limits keep their complete geometry to preserve their appearance. Visibility follows the same painter order as the live effect, including -its approximation for intersecting surfaces. Expansion changes are undoable with the standard command. +its approximation for intersecting surfaces. Uncut faces keep their curved outlines. Visibility calculations use bounded polygon approximations +of curved rims; clipped portions become vector polygons. Expansion changes are undoable +with the standard command. ## Initial limits - Paths and compound paths only. Type, images and groups must be converted to paths first. -- Open profiles produce uncapped surfaces. Closed profile boundaries are swept as provided. +- Open profiles have circular end caps by default; turning Cap off keeps them open. Partial-sweep angular cut sides remain open. Closed profile boundaries are swept as provided. - One Revolve per source object. Edit its options rather than applying another. - Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. -- Flat shading, one directional light, no textures, cast shadows, end caps or physical materials. -- Faces use back-to-front depth sorting. Intersecting profiles can have incorrect visibility. -- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. +- Smooth diffuse shading, one directional light, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights. +- Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility. +- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. Smooth shaded side quads become two triangles and share that same limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. - Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. -- Extrude & Bevel, Inflate, Rotate and Materials remain unimplemented. +- Extrude & Bevel, Inflate and Rotate are described in [three-d.md](three-d.md). Materials remain unimplemented. ## Architecture -`vectorcraft-three-d` depends only on `vectorcraft-geom`. It creates a surface of revolution, +Revolve geometry in `vectorcraft-three-d` uses `vectorcraft-geom`; the crate also uses `vectorcraft-pathops` +for the other 3D types. It creates a surface of revolution, applies Euler view rotation, projects it, shades faces and sorts their depth. It has no UI or GPU dependency. `vectorcraft-effects` turns those faces into document art. The renderer, export baking and Expand Appearance use the same evaluator. The standard `effect.apply`, `effect.setParams`, `effect.remove` and `effect.expandAppearance` commands expose it to the CLI, control channel and MCP. -Behaviour reference: [public 3D effects documentation](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html). +Generated faces include narrow matching strokes to cover antialiasing seams. Their round +joins bound the overlap to half the stroke width, preventing acute triangle corners from +creating spikes along the silhouette in the canvas, SVG/PDF and expanded artwork. + +Behaviour references: [public 3D and Materials options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html) and [public 3D effects documentation](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html). This implementation uses original mathematics and source code, with no Adobe software, assets or presets. diff --git a/docs/roadmap.md b/docs/roadmap.md index af182556b..e095dfb16 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -57,7 +57,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; auto/slider/advanced UI remains open); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | -| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Film Grain done (29/57 Photoshop filters); initial Revolve, Extrude & Bevel, Inflate and Rotate; SVG Filters, remaining raster effects and 3D materials, lights and shadows pending | 81–130 | +| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Film Grain done (29/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate; SVG Filters, remaining raster effects and 3D materials, lights and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | @@ -80,6 +80,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | | 2026-10-14 | minor | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682); With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630); Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836); The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824); With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter); Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631); The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751); An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629); Redo during a drag keeps the drag as a step (#1856, #1719); Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter); Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809); The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634); Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738); Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735); macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855); Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767); Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744); Export for Screens and the Asset Export panel offer TIFF (#1632); SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870); DXF export writes the VPORT view direction once (#1789); Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695); Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717); Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683); A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694); krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875); milestone rows and estimates unchanged | | 2026-10-14 | minor | M12 row: each documented Image Trace preset has an enhanced version (#1847) | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7634539..71893fdb4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -75,7 +75,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | | Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated | | Raster effects (Effect Gallery) | 4 | 49% | 28 Photoshop-style filters (14 Artistic, 14 Sketch), the Effect Gallery dialog, Load Texture | 23–39 | measured count 29/57; texture redraw distances optimized (#1257), pixels verified; depth estimated | -| 3D and Materials (and Turntable) | 4 | ~25% | initial live Revolve, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (flat shading, one light, painter visibility); materials, several lights, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (other types use flat shading; one light, painter visibility); materials, several lights, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) | @@ -480,7 +480,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map retains its 4,096-segment cap and filled-area fallback; other Pathfinder operations are unchanged. - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). + - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). - **Simplify feedback and component selection (#1272):** the existing tolerance dialog previews from the original interaction snapshot and shows original/new anchor counts. Preview off and Cancel restore the document and selection; OK is one Undo. A refit or Add Anchor Points clears component indexes only on paths whose geometry changes, keeping their objects selected whole and unchanged paths' components intact. Engine and real-egui frame regressions cover document/selection restoration and grouped paths. Public references: [Simplify benefits](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-path-benefits.html) and [advanced options](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-paths-advanced-options-overview.html). @@ -580,6 +580,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | | 2026-10-14 | minor | Evidence for the later merges of 2026-10-14 that did not write their own: anchor X/Y/W/H (#1839), Snap to Grid over Snap to Pixel (#1840), guides in a marquee (#1836), the toolbar and All Tools drawer (#1751), the Actions panel selection (#1853), redo during a drag (#1856), preferences written on OK (#1857), character styles from a selected range (#1634), type in symbols after reopening (#1833), swatches in opacity masks (#1830), macOS save sheets (#1855, the Format menu withdrawn), shared stroke outlines (#1667), SVG and DXF fixes (#1799, #1834, #1870, #1789), untrusted files (#1852, #1862, #1863, #1871–#1873), failed saves (#1865), effects and Dim Images (#1864), krilla (#1874) and the wasm gate (#1875). #1630, #1631, #1632, #1633, #1682, #1687–#1690, #1744, #1750, #1755, #1767, #1782, #1784, #1792, #1809, #1814 and #1850 wrote their own evidence and are not repeated. A note above the 2026-10-05 table says its duplicated "Selection, transform & align tools" and "Drawing tools" rows need the owner's judgement. Scores and hours unchanged (estimated) | | 2026-10-14 | minor | Image Trace, measured in `image-trace-quality.md`: Shapes rework (#1845), square stroke caps (#1846), enhanced presets (#1847), gradients grown over noise and soft rims (#1848). Score and hours unchanged (estimated) | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | From b5b9264d711917a2beadef265292379e7d68952b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 01:09:24 +0700 Subject: [PATCH 2/2] Revolve: the Cap option is saved as cap, as Extrude & Bevel and Inflate save it --- crates/effects/src/lib.rs | 4 ++-- crates/effects/src/revolve.rs | 16 ++++++++-------- crates/engine/src/cmd/effectcmd.rs | 12 ++++++------ crates/pdf/src/tests.rs | 2 +- crates/render/src/tests_objectfx.rs | 8 ++++---- crates/svg/tests/effects_export.rs | 6 +++--- crates/ui-egui/src/dialogs/revolve.rs | 8 ++++---- crates/ui-egui/src/dialogs/tests_revolve.rs | 14 +++++++------- docs/mcp.md | 8 ++++---- docs/revolve.md | 2 +- 10 files changed, 40 insertions(+), 40 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 61230ba7e..c32558fad 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -200,8 +200,8 @@ pub fn effect_catalog() -> Vec { REVOLVE, "Revolve…", &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), capEnds: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"capEnds":true,"expandVisibleOnly":true}), + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}), ), g( EXTRUDE, diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index f10958e13..79540333f 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -39,7 +39,7 @@ pub fn revolve_options(p: &Value) -> Revolve { ambient: num(p, "ambient", 25.0) / 100.0, shade: flag(p, "shade", true), smooth_shading: true, - cap_ends: flag(p, "capEnds", true), + cap_ends: flag(p, "cap", true), } } @@ -54,8 +54,8 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) { return Err("Revolve edge must be left or right".into()); } - if p.get("capEnds").is_some_and(|v| v.as_bool().is_none()) { - return Err("Revolve capEnds must be a boolean".into()); + if p.get("cap").is_some_and(|v| v.as_bool().is_none()) { + return Err("Revolve cap must be a boolean".into()); } vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) } @@ -257,7 +257,7 @@ mod tests { crate::new_effect( REVOLVE, &json!({ - "offset":20,"rotationX":-18,"segments":24,"capEnds":true, + "offset":20,"rotationX":-18,"segments":24,"cap":true, "shade":shade,"angle":angle }), ) @@ -315,15 +315,15 @@ mod tests { let mut effect = fx(); effect.params["offset"] = json!(20); effect.params["rotationX"] = json!(-35); - effect.params.as_object_mut().unwrap().remove("capEnds"); + effect.params.as_object_mut().unwrap().remove("cap"); n.appearance.effects.push(effect); let capped = revolve_art(&n).unwrap(); - n.appearance.effects[0].params["capEnds"] = json!(false); + n.appearance.effects[0].params["cap"] = json!(false); let uncapped = revolve_art(&n).unwrap(); assert_ne!(uncapped, capped); - n.appearance.effects[0].params["capEnds"] = json!(true); + n.appearance.effects[0].params["cap"] = json!(true); assert_eq!(revolve_art(&n), Some(capped)); - n.appearance.effects[0].params["capEnds"] = json!(false); + n.appearance.effects[0].params["cap"] = json!(false); assert_eq!(revolve_art(&n), Some(uncapped)); } #[test] diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 5f065453b..801491671 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -661,7 +661,7 @@ mod tests { let initial = node(&s, id); let source = initial.path_data().unwrap().clone(); assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading")); - assert!(initial.appearance.effects[0].params["capEnds"].as_bool().unwrap()); + assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap()); let mut gradients = 0; effects_for_test(&s, id).walk(&mut |face| { gradients += face.appearance.items.iter().filter(|item| { @@ -693,22 +693,22 @@ mod tests { s.execute( "effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null, - "params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"capEnds":false}}), + "params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}), ) .unwrap(); let uncapped = node(&s, id); - assert_eq!(uncapped.appearance.effects[0].params["capEnds"], json!(false)); + assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false)); let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); let open_count = count(&effects_for_test(&s, id)); - s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"capEnds":true}})).unwrap(); + s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap(); let capped = node(&s, id); let art = effects_for_test(&s, id); assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap"); assert_eq!(capped.path_data(), Some(&source)); - assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"capEnds":"yes"}})).is_err()); + assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err()); assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document"); let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); - assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["capEnds"], json!(true)); + assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true)); assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone())); let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF")); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 335c2eaf4..c9ea15851 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -293,7 +293,7 @@ fn smooth_revolve_exports_axial_vector_shading_without_images_and_keeps_the_sour n.appearance.effects.push(vectorcraft_doc::Effect { id: "threeD.revolve".into(), visible: true, - params: serde_json::json!({"offset":20,"rotationX":-25,"capEnds":true,"segments":16}), + params: serde_json::json!({"offset":20,"rotationX":-25,"cap":true,"segments":16}), }); add(&mut d, n); let before = d.clone(); diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index f4028302f..6a57fd1ad 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -49,10 +49,10 @@ fn revolve_caps_cover_the_opening_and_match_export_and_visible_only_expansion() n.appearance.effects = vec![fx( "threeD.revolve", json!({"offset":20,"rotationX":-35,"lightAzimuth":0, - "angle":angle,"segments":32,"capEnds":false,"expandVisibleOnly":true}), + "angle":angle,"segments":32,"cap":false,"expandVisibleOnly":true}), )]; let open = render(&doc_with(Document::new(100.0, 100.0), n.clone())); - n.appearance.effects[0].params["capEnds"] = json!(true); + n.appearance.effects[0].params["cap"] = json!(true); let d = doc_with(Document::new(100.0, 100.0), n.clone()); let live = render(&d); assert_ne!(live.pixels, open.pixels, "caps change the opening: {angle}, {opacity}"); @@ -158,7 +158,7 @@ fn revolve_rim_has_no_spikes_at_high_zoom_and_keeps_seams_covered() { use vectorcraft_doc::AppearanceItem; let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap()); let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0)); - n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":-18,"segments":24,"capEnds":true}))); + n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":-18,"segments":24,"cap":true}))); let d = doc_with(Document::new(100.0, 100.0), n); let baked = vectorcraft_effects::bake_document(&d).unwrap(); let mut fills = baked.clone(); @@ -197,7 +197,7 @@ fn continuous_revolve_caps_keep_one_colour_along_the_curved_rim_at_high_zoom() { for rotation in [-18.0f64, 18.0] { for shade in [false, true] { let mut n = Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0)); - n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":rotation,"segments":8,"capEnds":true,"shade":shade}))); + n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":rotation,"segments":8,"cap":true,"shade":shade}))); let d = doc_with(Document::new(100.0, 100.0), n); let center_y = 50.0 + if rotation < 0.0 { -20.0 } else { 20.0 } * rotation.to_radians().cos(); let zoom = 24.0; diff --git a/crates/svg/tests/effects_export.rs b/crates/svg/tests/effects_export.rs index c994b8651..800d00e1d 100644 --- a/crates/svg/tests/effects_export.rs +++ b/crates/svg/tests/effects_export.rs @@ -25,11 +25,11 @@ fn fx(id: &str) -> Effect { fn revolve_caps_export_as_additional_vector_faces_and_leave_source_editable() { let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M100 30 L100 160").unwrap()); let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0)); - n.appearance.effects.push(Effect { params: json!({"offset":30,"rotationX":-35,"segments":16,"capEnds":false}), ..fx("threeD.revolve") }); + n.appearance.effects.push(Effect { params: json!({"offset":30,"rotationX":-35,"segments":16,"cap":false}), ..fx("threeD.revolve") }); let mut d = doc_with(n); let open = export(&d, &ExportOptions::default()); let id = d.layers[0].children().unwrap()[0].id; - d.node_mut(id).unwrap().appearance.effects[0].params["capEnds"] = json!(true); + d.node_mut(id).unwrap().appearance.effects[0].params["cap"] = json!(true); let before = d.clone(); let capped = export(&d, &ExportOptions::default()); assert_eq!(capped.matches(" Expand Appearance** replaces the live effect with ordinary editable vector faces. **Cap** in the shape controls closes the circular rings at the endpoints of an open profile. -It is on by default. Its saved parameter is `capEnds` (boolean, default true); missing parameters +It is on by default. Its saved parameter is `cap` (boolean, default true); missing parameters also use caps. Set it to false to keep the ends open. The reset dot and Reset all restore Cap to on. Each cap is one closed curved vector path from the endpoint ring to the rotation axis, with uniform flat shading. A full turn produces a projected circular disk (an ellipse in an orthographic view); a partial turn produces a sector. Body faces share the same curved rim,