diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a2fd2c4469..4cae702daa 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -94,7 +94,9 @@ pub use solids::{ three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic, }; -pub use solids::{GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, graphic_frame_at, rename_appearance_graphic_symbol}; +pub use solids::{ + GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, rename_appearance_graphic_symbol, +}; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; pub use trap::TRAP; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 4de5b2812f..fc1d37af03 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1232,6 +1232,14 @@ pub fn graphic_frame_at(doc: Option<&Document>, n: &Node, item: Option, i frame_of(doc, n, item, &|fx: &[Effect]| fx.get(index).filter(|e| e.visible && SOLIDS.contains(&e.id.as_str())).map(|e| (index, e))) } +/// Does a visible geometry effect come after the 3D effect at `index` in the effects of `n` (on the +/// object itself with `item` `None`, else on its fill or stroke `item`)? Such an effect moves or +/// bends the drawn art, graphics included, while [`graphic_frame_at`] places the graphics on the +/// art before it. +pub fn graphics_reshaped_after(n: &Node, item: Option, index: usize) -> bool { + n.appearance.effects_at(item).and_then(|fx| fx.get(index + 1..)).is_some_and(crate::has_geometry) +} + /// Finds a 3D effect in a list of effects, with its index there. type Pick<'p> = dyn for<'e> Fn(&'e [Effect]) -> Option<(usize, &'e Effect)> + 'p; diff --git a/crates/effects/src/solids/graphics.rs b/crates/effects/src/solids/graphics.rs index 1ad6079dca..6c5d8686a7 100644 --- a/crates/effects/src/solids/graphics.rs +++ b/crates/effects/src/solids/graphics.rs @@ -15,7 +15,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol}; use vectorcraft_geom::kurbo::{CubicBez, ParamCurve, PathEl, Shape}; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath}; use vectorcraft_three_d::{FaceMap, Facet, PlanarFace}; /// The effect parameter that holds the graphics. @@ -169,20 +169,29 @@ pub fn missing_symbol(doc: &Document, p: &Value, old: Option<&Value>) -> Option< } /// Does the art of the symbols the graphics in `p` name fit the limits? A graphic whose symbol -/// `doc` does not have draws nothing and is not counted. +/// `doc` does not have draws nothing and is not counted. With a perspective, each curve counts as +/// the [`PERSPECTIVE_PIECES`] pieces it is cut into; without one, it counts once. pub fn validate_symbols(doc: &Document, p: &Value) -> Result<(), String> { + let curve = if super::view_options(p).perspective > 0.0 { PERSPECTIVE_PIECES } else { 1 }; let mut elements = 0usize; for g in mapped_graphics(p) { let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue }; let pieces = pieces_of(doc, symbol)?; - elements = elements.saturating_add(pieces.iter().map(|p| p.path.subpaths.iter().map(|s| s.anchors.len() + 1).sum::()).sum()); - if elements.saturating_mul(PERSPECTIVE_PIECES) > MAX_ELEMENTS { + elements = elements.saturating_add(pieces.iter().map(|p| drawn_elements(&p.path, curve)).sum()); + if elements > MAX_ELEMENTS { return Err("The graphics are too complex: simplify the symbols' art".into()); } } Ok(()) } +/// The path elements `path` draws when each curve draws `curve` elements, before curves near the +/// eye are split further: one for each straight line, move and close. +fn drawn_elements(path: &PathData, curve: usize) -> usize { + let segments = |s: &SubPath| (0..s.segment_count()).map(|i| if s.segment_is_line(i) { 1 } else { curve }).sum::(); + path.subpaths.iter().map(|s| 1 + usize::from(s.closed) + segments(s)).sum() +} + /// One area of a graphic's art, drawn with one paint: a fill, or a stroke's outline. struct Piece { path: PathData, @@ -194,7 +203,7 @@ struct Piece { /// The areas the art of `symbol` paints, back to front, at the symbol's natural size: the fills of /// its paths, compound paths and type (as outlines), and its strokes' outlines, through the symbol -/// instances it holds. Hidden objects, guides, clipping paths and images are left out, and clipping +/// instances it holds (with their changes to dynamic symbols, and through 9-slice guides). Hidden objects, guides, clipping paths and images are left out, and clipping /// groups draw their art unclipped. fn pieces_of(doc: &Document, symbol: &Symbol) -> Result, String> { fn walk(doc: &Document, n: &Node, outer: f32, xf: Affine, (depth, level): (usize, usize), out: &mut Vec) -> Result<(), String> { @@ -224,7 +233,18 @@ fn pieces_of(doc: &Document, symbol: &Symbol) -> Result, String> { if depth >= MAX_NESTING { return Err(format!("A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art")); } - walk(doc, &s.art, opacity, xf * *at, (depth + 1, level + 1), out)?; + // The instance shows its own changes to a dynamic symbol's art, and a symbol + // with 9-slice guides is placed through them, as the canvas draws it. The + // pieces are not clipped, so paths that cross a guide are cut at it. + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n); + if doc.symbol_slice_map(symbol, *at).is_some() { + let Some(placed) = doc.place_symbol_art_unclipped(symbol, &art, *at) else { + return Err("A graphic's art holds too many objects: simplify the symbol's art".into()); + }; + walk(doc, &placed, opacity, xf, (depth + 1, level + 1), out)?; + } else { + walk(doc, &art, opacity, xf * *at, (depth + 1, level + 1), out)?; + } } None } @@ -371,7 +391,6 @@ pub(super) fn face_art( let doc = doc?; let light = caps.first()?.illumination; let affine = map.local_affine(center).filter(|_| map.is_affine()); - let cuts = if affine.is_some() { 1 } else { PERSPECTIVE_PIECES }; let mut children = vec![]; for g in graphics.iter().filter(|g| g.face == face) { let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue }; @@ -392,15 +411,14 @@ pub(super) fn face_art( let gradient_map = local * place; for piece in pieces { let placed = piece.path.transformed(place); - let added: usize = placed.subpaths.iter().map(|s| s.anchors.len() + 1).sum(); + // Each anchor draws at least one element. A perspective cuts curves into more, and + // straight lines stay one each: the path may take what is left of the limit, and is + // counted as it is drawn. Past the limit, the face draws nothing. + let least: usize = placed.subpaths.iter().map(|s| s.anchors.len()).sum(); let before = *elements; - *elements = elements.saturating_add(added.saturating_mul(cuts)); - if *elements > MAX_ELEMENTS { + if before.saturating_add(least) > MAX_ELEMENTS { return None; } - // Curves split near the eye, and the cut at it, can draw more elements than were - // counted: the path may take what is left of the limit, and is counted as drawn. Past - // the limit, the face draws nothing. let room = MAX_ELEMENTS - before; let mut rule = piece.rule; let mapped = match map_path(&placed, map, affine, room) { @@ -429,7 +447,7 @@ pub(super) fn face_art( Err(NotMapped::Full) => return None, Err(NotMapped::Behind) => continue, }; - *elements = (*elements).max(before.saturating_add(mapped.elements().len())); + *elements = before.saturating_add(mapped.elements().len()); let mut paint = piece.paint; vectorcraft_doc::recolor::map_paint(&mut paint, &|c| crate::lighting::shade(c, light)); // A pattern's tiles follow the graphic onto the face, as its gradients do below. diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index 160f57b16f..64c947a9df 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -365,27 +365,67 @@ fn members_with_their_own_blending_get_graphics_of_their_own() { assert_eq!(blended, 1); } -#[test] -fn members_with_graphics_of_their_own_share_one_element_limit() { - // A red polygon of 30,000 sides: cut by a perspective, one copy fits the limit, two do not. +/// [`document`] with symbol "Dense" too: a red polygon of 30,000 sides, straight or `curved`. +fn dense_document(curved: bool) -> Document { let mut d = document(); let mut poly = BezPath::new(); + let mut last = Point::ZERO; for k in 0..30_000 { let a = k as f64 / 30_000.0 * std::f64::consts::TAU; let p = Point::new(5.0 + 5.0 * a.cos(), 5.0 + 5.0 * a.sin()); - if k == 0 { poly.move_to(p) } else { poly.line_to(p) } + if k == 0 { + poly.move_to(p); + } else if curved { + poly.curve_to(last.lerp(p, 1.0 / 3.0), last.lerp(p, 2.0 / 3.0), p); + } else { + poly.line_to(p); + } + last = p; } poly.close_path(); let art = Node::path(NodeId(0), PathData::from_bezpath(&poly), Appearance::basic(Paint::solid(red()), Paint::None, 0.0)); d.symbols.push(Symbol { dynamic: false, name: "Dense".into(), art: Arc::new(art) }); + d +} + +/// The graphics drawn on a group of two members, each with its own graphic of symbol "Dense" +/// ([`dense_document`]), seen under a perspective. +fn dense_graphics_drawn(curved: bool) -> usize { + let d = dense_document(curved); let a = square(1, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); let mut b = square(2, Rect::new(50.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); b.blend = BlendMode::Multiply; let mut group = Node::group(NodeId(3), vec![Arc::new(a), Arc::new(b)]); let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "perspectiveAngle": 60.0, "shade": false, "graphics": [{"symbol": "Dense", "scale": 1000.0}]}); group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + graphics_in(&solid_art_in(Some(&d), &group).unwrap()).len() +} + +#[test] +fn members_with_graphics_of_their_own_share_one_element_limit() { + // A perspective cuts each of the 30,000 curves into 8: one copy fits the limit, two do not. // The first member's graphic is drawn; the second would pass the limit, so it draws none. - assert_eq!(graphics_in(&solid_art_in(Some(&d), &group).unwrap()).len(), 1); + assert_eq!(dense_graphics_drawn(true), 1); +} + +#[test] +fn straight_sides_count_once_under_a_perspective() { + // A perspective keeps each of the 30,000 straight sides one element: both copies fit the limit. + assert_eq!(dense_graphics_drawn(false), 2); +} + +#[test] +fn the_symbol_check_counts_what_the_view_draws() { + // Two graphics of the 30,000-sided polygon under a perspective: straight, they fit the limit; + // curved, they do not. + let two = json!({"perspectiveAngle": 60.0, "graphics": [{"symbol": "Dense"}, {"symbol": "Dense", "face": "back"}]}); + let n = object(EXTRUDE, two.clone()); + assert!(validate_three_d_in(Some(&dense_document(false)), &n, EXTRUDE, &two).is_ok()); + let e = validate_three_d_in(Some(&dense_document(true)), &n, EXTRUDE, &two).unwrap_err(); + assert!(e.contains("too complex"), "{e}"); + // Without a perspective each curve draws once: curved, they fit too. + let flat = json!({"perspectiveAngle": 0.0, "graphics": two["graphics"].clone()}); + assert!(validate_three_d_in(Some(&dense_document(true)), &n, EXTRUDE, &flat).is_ok()); } #[test] @@ -579,3 +619,41 @@ fn a_graphic_starts_inside_its_object() { assert!((frame.fitting_scale(Rect::new(0.0, 0.0, 400.0, 200.0)) - 25.0).abs() < 1e-9); assert_eq!(super::super::graphic_bounds(&d, "Mark"), Some(Rect::new(500.0, 500.0, 510.0, 510.0))); } + +#[test] +fn a_symbol_instance_in_a_graphic_draws_as_the_canvas_shows_it() { + let mut d = document(); + let blue = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + // A dynamic symbol whose instance in "Changed" fills its square blue instead of red. + d.symbols.push(Symbol { dynamic: true, name: "Dynamic".into(), art: Arc::new(square(0, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red()))) }); + let mut changed = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "Dynamic".into(), xf: Affine::IDENTITY }); + changed.extras_mut().symbol_overrides = + vec![vectorcraft_doc::symbol_overrides::SymbolOverride { fill: Some(blue.clone()), ..Default::default() }]; + d.symbols.push(Symbol { dynamic: false, name: "Changed".into(), art: Arc::new(changed) }); + let pieces = pieces_of(&d, d.symbols.iter().find(|s| s.name == "Changed").unwrap()).unwrap(); + assert_eq!(pieces.iter().map(|p| p.paint.clone()).collect::>(), [blue]); + // A symbol with 9-slice guides, a 100 × 100 square with a 10 × 10 one in its corner, placed + // three times as wide: the corner keeps its width while the rest stretches. + let corner = square(2, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red())); + let whole = square(3, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(red())); + d.symbols.push(Symbol { dynamic: false, name: "Sliced".into(), art: Arc::new(Node::group(NodeId(4), vec![Arc::new(whole), Arc::new(corner)])) }); + d.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = Node::new(NodeId(5), NodeKind::SymbolInstance { symbol: "Sliced".into(), xf: Affine::scale_non_uniform(3.0, 1.0) }); + d.symbols.push(Symbol { dynamic: false, name: "Wide".into(), art: Arc::new(wide) }); + let pieces = pieces_of(&d, d.symbols.iter().find(|s| s.name == "Wide").unwrap()).unwrap(); + let widths: Vec = pieces.iter().map(|p| p.path.bounds().unwrap().width()).collect(); + assert_eq!(widths.len(), 2); + assert!((widths[1] / widths[0] - 1.0 / 30.0).abs() < 1e-6, "{widths:?}"); + // With a gradient, the square crossing the guides is cut at them: its pieces cover the + // placed 300 × 100 once, not one whole copy for each cell. + use vectorcraft_doc::color::{Gradient, GradientPaint}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let shaded = square(6, Rect::new(0.0, 0.0, 100.0, 100.0), gradient); + d.symbols.push(Symbol { dynamic: false, name: "Shaded".into(), art: Arc::new(Node::group(NodeId(7), vec![Arc::new(shaded)])) }); + d.set_symbol_nine_slice("Shaded", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = Node::new(NodeId(8), NodeKind::SymbolInstance { symbol: "Shaded".into(), xf: Affine::scale_non_uniform(3.0, 1.0) }); + d.symbols.push(Symbol { dynamic: false, name: "WideShaded".into(), art: Arc::new(wide) }); + let pieces = pieces_of(&d, d.symbols.iter().find(|s| s.name == "WideShaded").unwrap()).unwrap(); + let area: f64 = pieces.iter().map(|p| p.path.to_bezpath().area().abs()).sum(); + assert!((area - 30_000.0).abs() < 1e-3, "{area}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eccb4f50e0..d75eae537f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -445,17 +445,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } /// The pointer on the canvas: the tool's input, unless the context menu or the 3D gizmo has the press. -fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { - // A press on the canvas while the context menu is open only closes it (a drag too, which egui - // alone would leave open); the tool doesn't get it. +pub(crate) fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { // The artboard's 3D gizmo owns these gestures while its options are open. // Do not let a ring drag also move or deselect the editable source path. + } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { + // A press on the canvas while the context menu is open only closes it (a drag too, which + // egui alone would leave open); neither the tool nor a graphic's handle gets it. + egui::Popup::close_all(ui.ctx()); } else if crate::panels::three_d::graphic_handles_input(app, ui, resp, rect) { // A mapped graphic's handle, shown while the 3D and Materials panel's Materials tab is // open, has the press: the drag moves the graphic, not the object. - } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { - egui::Popup::close_all(ui.ctx()); } else { handle_input(app, ui, resp, rect); // `hover_doc` is the document point under the pointer in the view `handle_input` left. The diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index c98f9c3117..c9e3ccb874 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -575,6 +575,82 @@ fn a_view_gesture_on_a_graphics_handle_is_left_to_the_canvas() { handles_frame(&mut app, &ctx, vec![primary(handle, false)]); } +#[test] +fn a_geometry_effect_after_the_3d_effect_hides_the_graphics_handles() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + let handle = handles_screen(&app, 150.0, 150.0); + let push_effect = |app: &mut VectorcraftApp, object: usize, id: &str, params: Value| { + let st = app.session.active_mut().unwrap(); + let object = st.selection.objects[object]; + let n = std::sync::Arc::make_mut(&mut st.doc).node_mut(object).unwrap(); + n.appearance.effects.truncate(1); + n.appearance.effects.push(vectorcraft_effects::new_effect(id, ¶ms).unwrap()); + }; + // A Transform effect after the 3D effect draws the graphic 40 pt to the right of the frame's + // place for it: the artboard shows no handle there. + push_effect(&mut app, 0, "distort.transform", json!({"moveH": 40.0})); + handles_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(handle)]); + assert!(!handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); + // A Drop Shadow after it leaves the graphic where the frame places it: the handle shows. + push_effect(&mut app, 0, "stylize.dropShadow", json!({})); + assert!(handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); + // A second selected object with the same graphic and a Transform effect after its 3D effect: + // a drag would move its graphic too, so the artboard shows no handles. + let square = app.session.active().unwrap().selection.objects[0].0; + let other = app.run("shape.rectangle", json!({"x": 400, "y": 400, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0}})) + .unwrap(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + app.run("select.set", json!({"ids": [square, other]})).unwrap(); + assert!(handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); + push_effect(&mut app, 1, "distort.transform", json!({"moveH": 40.0})); + assert!(!handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); +} + +#[test] +fn a_press_that_closes_the_context_menu_does_not_grab_a_graphics_handle() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)); + // One frame of the canvas's input with a context menu: whether the menu is open after it. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut open = false; + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + let resp = ui.interact(rect, egui::Id::unique("three-d-test-canvas"), egui::Sense::click_and_drag()); + crate::canvas::canvas_input(app, ui, &resp, rect); + resp.context_menu(|ui| { + ui.label("Menu"); + }); + open = resp.context_menu_opened(); + }) + .textures_delta + .clear(); + open + }; + // A right-click below and to the right of the handle opens the menu there, clear of the handle. + let (handle, menu) = (handles_screen(&app, 150.0, 150.0), handles_screen(&app, 195.0, 195.0)); + let secondary = + |pressed| egui::Event::PointerButton { pos: menu, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(menu)]); + frame(&mut app, vec![secondary(true)]); + frame(&mut app, vec![secondary(false)]); + assert!(frame(&mut app, vec![egui::Event::PointerMoved(handle)])); + // The press on the handle only closes the menu: no drag of the graphic starts. + let (history, before) = (undo_len(&app), graphics(&app)); + assert!(!frame(&mut app, vec![primary(handle, true)])); + assert!(ctx.data(|m| m.get_temp::(super::handle_drag_id())).is_none()); + frame(&mut app, vec![egui::Event::PointerMoved(handle + egui::vec2(20.0, 0.0))]); + frame(&mut app, vec![primary(handle + egui::vec2(20.0, 0.0), false)]); + assert_eq!(undo_len(&app), history); + assert_eq!(graphics(&app), before); +} + #[test] fn a_click_on_a_graphics_handle_records_no_undo_step() { let (mut app, ctx) = materials_app(); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 2fc602b1fd..d9d6035ab1 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -301,7 +301,8 @@ type Handles = (vectorcraft_effects::GraphicFrame, Vec Option { if !materials_open(ctx) { @@ -312,6 +313,13 @@ fn graphic_handles(app: &VectorcraftApp, ctx: &egui::Context) -> Option let t = find(n)?; // The frame of the effect whose graphics are listed: none while that effect is hidden. let frame = vectorcraft_effects::graphic_frame_at(Some(&st.doc), n, t.item, t.index)?; + // A geometry effect after the 3D effect of any selected object moves or bends its drawn + // graphics away from their places in the frame: no handles, so a drag does not put a graphic + // away from the pointer. + let reshaped = |n: &Node| find(n).is_some_and(|t| vectorcraft_effects::graphics_reshaped_after(n, t.item, t.index)); + if st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).any(reshaped) { + return None; + } let list = vectorcraft_effects::mapped_graphics(&vectorcraft_effects::merged_params(&t.effect, &t.params)); // Selected objects with different graphics show no handles: a drag sends the whole list. let graphics_of = |t: Target| vectorcraft_effects::mapped_graphics(&vectorcraft_effects::merged_params(&t.effect, &t.params)); diff --git a/docs/three-d.md b/docs/three-d.md index 5607ac8abe..2e6e3fb314 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -301,7 +301,9 @@ Extrude & Bevel's front and back caps, and the plane of the Plane type (Rotate). (counterclockwise), and **Remove Graphic** takes it off. Each change is one undo step. - While the tab is open, a blue handle sits on the center of each mapped graphic whose face is turned toward the viewer. Dragging a handle moves that graphic over its face, previewed while the pointer is down and recorded as - one undo step when it is released. The handles are hidden while the selected objects have different graphics. + one undo step when it is released. The handles are hidden while the selected objects have different graphics, + and while a geometry effect (such as Transform) comes after the 3D effect. A press on the canvas while its + context menu is open only closes the menu and does not grab a handle. These rules are VectorCraft's own: