From c31af9898ed07f87935d042af1bfd4927e417862 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:42:59 -0800 Subject: [PATCH] 3D and Materials: one element limit per effect, pasted graphics keep their symbols, safer handle drags - The graphics of one 3D effect share one limit on drawn path elements across every member and opacity group, so a group effect with many members no longer multiplies the limit. - Copy and paste follow the symbols that mapped graphics name, in object and graphic style appearances: a document without the symbol gets it, and one whose symbol of the same name is other art gets the copied symbol under a free name, which the graphic then names. - A face's inverse map refuses the points its page map refuses, so a handle drag cannot save an offset whose handle is not shown. - A press on a graphic's handle with Space held, or with the Hand, Zoom or Rotate View tool, pans, zooms or turns the view. - A handle drag ends, keeping its preview, when the selection or the document changes while the pointer is down. --- crates/effects/src/solids.rs | 11 ++- crates/effects/src/solids/graphics.rs | 17 ++-- crates/effects/src/solids/tests_graphics.rs | 23 ++++++ crates/engine/src/cmd/clipboard/resources.rs | 15 ++++ .../src/cmd/effectcmd/graphics_tests.rs | 27 +++++++ crates/three-d/src/solid/face_map.rs | 19 +++-- crates/three-d/src/solid/tests_face_map.rs | 20 ++++- crates/ui-egui/src/panels/tests_three_d.rs | 77 +++++++++++++++++++ crates/ui-egui/src/panels/three_d.rs | 28 ++++++- 9 files changed, 216 insertions(+), 21 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 94067c3932..ae5472a83c 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -812,7 +812,8 @@ type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, /// for each planar face with a graphic, the art and the face. `caps` are the faces the viewer sees /// of the ends of the object's solids, each with its end; `bounds` are the bounds the solids were /// built with; `opacity` is the opacity of the fill or stroke those solids are painted with, which -/// the graphics take too. +/// the graphics take too. `elements` is the effect's count of drawn path elements, shared by every +/// call for one effect ([`graphics::face_art`]). fn graphic_art( doc: Option<&Document>, id: NodeId, @@ -820,6 +821,7 @@ fn graphic_art( bounds: Rect, caps: &[&Facet], opacity: f32, + elements: &mut usize, ) -> Vec<(PlanarFace, Arc)> { let list = graphics::mapped_graphics(p); if list.is_empty() || !takes_graphics(effect) { @@ -835,7 +837,7 @@ fn graphic_art( } else { vectorcraft_three_d::extrude_face_map(bounds, extrude_options(p), view, face) }?; - graphics::face_art(doc, id, &list, face, &map, bounds.center(), &on).map(|mut art| { + graphics::face_art(doc, id, &list, (face, &map), bounds.center(), &on, elements).map(|mut art| { art.opacity *= opacity; (face, Arc::new(art)) }) @@ -864,6 +866,8 @@ fn masks_surface(paint: &Paint, colored: bool) -> bool { /// drawn right after (the last of those solids holding its face) and its face. With `per_member` /// (a member's opacity mask or blending mode applies to its surfaces) each member's solids get /// graphics of their own, clipped to that member's caps, so they stay in that member's scopes. +/// All the groups share one limit on the path elements drawn: once it is reached, later groups +/// draw no graphics. fn placed_graphics( doc: Option<&Document>, id: NodeId, @@ -886,11 +890,12 @@ fn placed_graphics( } } let mut out = vec![]; + let mut elements = 0usize; for group in groups { let caps: Vec<&Facet> = group.iter().flat_map(|f| &f.caps).collect(); let opacity = group.first().map_or(1.0, |f| f.opacity); let blend = group.first().map_or(BlendMode::Normal, |f| f.blend); - for (face, mut art) in graphic_art(doc, id, effect, bounds, &caps, opacity) { + for (face, mut art) in graphic_art(doc, id, effect, bounds, &caps, opacity, &mut elements) { if let Some(f) = group.iter().rev().find(|f| f.caps.iter().any(|c| c.end == Some(face))) { Arc::make_mut(&mut art).blend = blend; out.push((f.at, face, art)); diff --git a/crates/effects/src/solids/graphics.rs b/crates/effects/src/solids/graphics.rs index bc176c665c..6eab0111f6 100644 --- a/crates/effects/src/solids/graphics.rs +++ b/crates/effects/src/solids/graphics.rs @@ -316,22 +316,24 @@ fn cubic_to(out: &mut BezPath, c: CubicBez, to: &dyn Fn(Point) -> Option) /// The graphics `graphics` lists for `face`, drawn on caps `caps` (the faces of that end of the /// object's solids that the viewer sees) through `map`, for an object whose bounds have /// center `center`: one clipping group of every such graphic, shaded with the first cap's light. -/// A graphic whose symbol `doc` does not have draws nothing. `None` when there is nothing to draw -/// or the art is past the limits. +/// A graphic whose symbol `doc` does not have draws nothing. `elements` counts the path elements +/// the effect's graphics have drawn so far, after a perspective cuts their curves, and is shared by +/// every call for one effect. `None` when there is nothing to draw or that count passes +/// [`MAX_ELEMENTS`]. pub(super) fn face_art( doc: Option<&Document>, id: NodeId, graphics: &[MappedGraphic], - face: PlanarFace, - map: &FaceMap, + (face, map): (PlanarFace, &FaceMap), center: Point, caps: &[&Facet], + elements: &mut usize, ) -> Option { 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![]; - let mut elements = 0usize; for g in graphics.iter().filter(|g| g.face == face) { let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue }; // A symbol past the limits is left out; the other graphics still draw. @@ -351,8 +353,9 @@ pub(super) fn face_art( let gradient_map = local * place; for piece in pieces { let placed = piece.path.transformed(place); - elements = elements.saturating_add(placed.subpaths.iter().map(|s| s.anchors.len() + 1).sum()); - if elements.saturating_mul(if affine.is_some() { 1 } else { PERSPECTIVE_PIECES }) > MAX_ELEMENTS { + let added: usize = placed.subpaths.iter().map(|s| s.anchors.len() + 1).sum(); + *elements = elements.saturating_add(added.saturating_mul(cuts)); + if *elements > MAX_ELEMENTS { return None; } let mut rule = piece.rule; diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index 641c047abb..fa1c11c706 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -314,6 +314,29 @@ 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. + let mut d = document(); + let mut poly = BezPath::new(); + 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) } + } + 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) }); + 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()); + // 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); +} + #[test] fn a_plane_and_a_hollow_solid() { let d = document(); diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index b1377d2fef..243c69f671 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -86,6 +86,10 @@ fn paint_refs(p: &Paint, f: Visit) { } fn appearance_refs(a: &Appearance, f: Visit) { + // The graphics mapped onto the faces of its 3D effects name symbols. + for s in vectorcraft_render::effects::appearance_graphic_symbols(a) { + f(Ref::Name(Res::Symbol, &s)); + } for it in &a.items { paint_refs(it.paint(), f); if let AppearanceItem::Stroke(l) = it @@ -620,6 +624,17 @@ impl Relink { } } } + // A 3D effect's mapped graphics follow their symbols' names in the document. Each name is + // looked up once, so renames that chain (A to B, B to C) do not apply twice. + let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in a.effects.iter_mut().chain(items).filter(|e| vectorcraft_render::effects::takes_graphics(&e.id)) { + let list = e.params.get_mut(vectorcraft_render::effects::GRAPHICS).and_then(Value::as_array_mut); + for g in list.into_iter().flatten() { + if let Some(Value::String(s)) = g.get_mut("symbol") { + self.name(Res::Symbol, s); + } + } + } for it in &mut a.items { self.paint(it.paint_mut()); if let AppearanceItem::Stroke(l) = it diff --git a/crates/engine/src/cmd/effectcmd/graphics_tests.rs b/crates/engine/src/cmd/effectcmd/graphics_tests.rs index 5741ed8c63..125ba82514 100644 --- a/crates/engine/src/cmd/effectcmd/graphics_tests.rs +++ b/crates/engine/src/cmd/effectcmd/graphics_tests.rs @@ -196,6 +196,33 @@ fn a_rename_reaches_graphics_in_graph_designs_and_opacity_masks() { assert_eq!(mask, Some(vec!["Label".to_string()])); } +#[test] +fn pasted_into_another_document_a_graphic_brings_its_symbol() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + // The symbol the pasted object's graphic names, and the size of that symbol's art. + let pasted = |s: &mut Session| { + s.execute("edit.paste", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let n = st.doc.node(st.selection.objects[0]).unwrap(); + let name = n.appearance.effects[0].params["graphics"][0]["symbol"].as_str().unwrap().to_string(); + let size = vectorcraft_render::effects::graphic_bounds(&st.doc, &name).map(|r| (r.width(), r.height())); + (name, size) + }; + // A document without the symbol gets it. + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + assert_eq!(pasted(&mut s), ("Mark".to_string(), Some((11.0, 21.0)))); + // A document whose symbol "Mark" is other art gets the copied symbol under a free name. + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let other = s.execute("path.create", &json!({"d":"M0 0L50 0L50 50L0 50Z"})).unwrap()["id"].clone(); + s.execute("threeD.addGraphic", &json!({"name": "Mark", "ids": [other]})).unwrap(); + let (name, size) = pasted(&mut s); + assert_ne!(name, "Mark"); + assert_eq!(size, Some((11.0, 21.0))); +} + #[test] fn a_symbol_only_a_graphic_style_uses_is_used() { let (mut s, id) = setup("threeD.extrude"); diff --git a/crates/three-d/src/solid/face_map.rs b/crates/three-d/src/solid/face_map.rs index 2a0ecaed4b..e3fde7478a 100644 --- a/crates/three-d/src/solid/face_map.rs +++ b/crates/three-d/src/solid/face_map.rs @@ -40,20 +40,26 @@ impl FaceMap { self.rows.map(|r| r[0] * p.x + r[1] * p.y + r[2]) } + /// The homogeneous depth a mapped point must lie beyond: the eye's distance times [`NEAREST`] + /// with perspective, 0 without. + fn nearest(&self) -> f64 { + self.eye.map_or(0.0, |e| e * NEAREST) + } + /// Where art point `p` lands on the page. `None` when it lies at or behind the eye, or the /// result is not finite. pub fn to_page(&self, p: Point) -> Option { let [u, v, w] = self.homogeneous(p); - let limit = self.eye.map_or(0.0, |e| e * NEAREST); - if w.is_nan() || w <= limit { + if w.is_nan() || w <= self.nearest() { return None; } let q = Point::new(self.origin.x + u / w, self.origin.y + v / w); (q.x.is_finite() && q.y.is_finite()).then_some(q) } - /// The art point that lands on page point `q`. `None` when the face is seen edge-on there or - /// the point lies beyond the face's horizon. + /// The art point that lands on page point `q`. `None` when the face is seen edge-on there, the + /// point lies beyond the face's horizon, or it lies nearer the eye than [`FaceMap::to_page`] + /// maps. pub fn to_art(&self, q: Point) -> Option { let (u, v) = (q.x - self.origin.x, q.y - self.origin.y); let [r0, r1, r2] = self.rows; @@ -65,8 +71,9 @@ impl FaceMap { return None; } let p = Point::new((c * e - b * f) / det, (a * f - c * d) / det); - // A point of the plane behind the eye projects to the same page point as one in front. - (p.x.is_finite() && p.y.is_finite() && self.homogeneous(p)[2] > 0.0).then_some(p) + // A point of the plane behind the eye projects to the same page point as one in front, and + // `to_page` maps no point nearer the eye than its limit. + (p.x.is_finite() && p.y.is_finite() && self.homogeneous(p)[2] > self.nearest()).then_some(p) } /// The part of rectangle `r` of the face's plane whose points [`FaceMap::to_page`] maps, kept diff --git a/crates/three-d/src/solid/tests_face_map.rs b/crates/three-d/src/solid/tests_face_map.rs index 5f9d3c3d4c..31644beda5 100644 --- a/crates/three-d/src/solid/tests_face_map.rs +++ b/crates/three-d/src/solid/tests_face_map.rs @@ -1,7 +1,7 @@ use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; use super::super::{Extrude, Facet, PlanarFace, View, extrude, plane}; -use super::{FaceMap, extrude_face_map, plane_face_map}; +use super::{FaceMap, NEAREST, extrude_face_map, plane_face_map}; fn square() -> (PathData, Rect) { let mut b = BezPath::new(); @@ -125,6 +125,24 @@ fn a_point_behind_the_eye_has_no_page_point() { assert!(map.to_page(Point::new(40.0, -1e6)).is_none() || map.to_page(Point::new(40.0, 1e6)).is_none()); } +#[test] +fn a_page_point_nearer_the_eye_than_the_page_map_allows_has_no_art_point() { + let (_, bounds) = square(); + let view = View { rotation_x: -89.0, perspective: 160.0, ..turned(0.0) }; + let map = plane_face_map(bounds, view).unwrap(); + let limit = map.eye.unwrap() * NEAREST; + // An art point in front of the eye, but at half the nearest depth `to_page` maps. + let [a, b, c] = map.rows[2]; + let p0 = bounds.center(); + let t = (limit / 2.0 - (a * p0.x + b * p0.y + c)) / (a * a + b * b); + let p = Point::new(p0.x + t * a, p0.y + t * b); + let [u, v, w] = map.homogeneous(p); + assert!(w > 0.0 && w < limit, "{w}"); + assert_eq!(map.to_page(p), None); + let q = Point::new(map.origin.x + u / w, map.origin.y + v / w); + assert_eq!(map.to_art(q), None, "the inverse map takes {q:?} back to an art point the page map has no point for"); +} + #[test] fn the_mapped_part_of_a_rectangle_stops_before_the_eye() { let (_, bounds) = square(); diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index b3ddb4dc46..0616b0fee3 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -527,6 +527,83 @@ fn a_graphics_handle_drags_it_over_its_face_in_one_undo_step() { assert!(!frame(&mut app, vec![button(target, true)])); } +/// One frame of the artboard's graphic handles over an 800 × 600 canvas with `events`: whether the +/// handles took the pointer. +fn handles_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> bool { + let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)); + let mut taken = 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.allocate_rect(rect, egui::Sense::click_and_drag()); + taken = super::graphic_handles_input(app, ui, &resp, rect); + }) + .textures_delta + .clear(); + taken +} + +/// Where document point (`x`, `y`) is on the 800 × 600 canvas of [`handles_frame`]. +fn handles_screen(app: &VectorcraftApp, x: f64, y: f64) -> egui::Pos2 { + let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)); + crate::canvas::Xf::new(rect, &app.view().cloned().unwrap_or_default()).to_screen(vectorcraft_geom::Point::new(x, y)) +} + +fn primary(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } +} + +#[test] +fn a_view_gesture_on_a_graphics_handle_is_left_to_the_canvas() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + let handle = handles_screen(&app, 150.0, 150.0); + let space = |pressed| egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + handles_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(handle)]); + // With Space held, the press pans the view. + handles_frame(&mut app, &ctx, vec![space(true)]); + assert!(!handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false), space(false)]); + // With the Hand, Zoom or Rotate View tool, the press is the tool's. + for tool in ["hand", "zoom", "rotateView"] { + app.select_tool(tool); + assert!(!handles_frame(&mut app, &ctx, vec![primary(handle, true)]), "{tool}"); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); + } + // With another tool and Space up, the press grabs the handle. + app.select_tool("selection"); + assert!(handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); +} + +#[test] +fn a_selection_change_during_a_handle_drag_ends_it() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + let square = app.session.active().unwrap().selection.objects[0].0; + // A second object with the same graphic. + 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]})).unwrap(); + let graphic_of = |app: &VectorcraftApp, id: u64| { + let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + n.appearance.effects[0].params["graphics"][0].clone() + }; + let before = graphic_of(&app, other); + let (handle, target, further) = (handles_screen(&app, 150.0, 150.0), handles_screen(&app, 170.0, 140.0), handles_screen(&app, 190.0, 130.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![egui::Event::PointerMoved(target)]); + // The selection changes to the other object while the pointer is down: the drag ends there. + app.run("select.set", json!({"ids": [other]})).unwrap(); + handles_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(further)]); + handles_frame(&mut app, &ctx, vec![primary(target, false)]); + assert_eq!(graphic_of(&app, other), before, "the other object's graphic moved"); + let moved = graphic_of(&app, square); + assert!((moved["x"].as_f64().unwrap() - 20.0).abs() < 0.11, "{moved}"); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index db3f2e1709..a18ac447b5 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -327,13 +327,19 @@ fn handle_drag_id() -> egui::Id { egui::Id::unique("three-d-graphic-handle-drag") } -/// A drag of graphic `index`'s handle: its face, and the page offset from the handle to the point -/// where it was grabbed. -#[derive(Clone, Copy, Debug)] +/// A drag of graphic `index`'s handle: its face, the page offset from the handle to the point +/// where it was grabbed, and the document and selected objects it was grabbed for. +#[derive(Clone, Debug)] struct HandleDrag { index: usize, face: vectorcraft_effects::PlanarFace, grab: vectorcraft_geom::Vec2, + owner: (u64, Vec), +} + +/// The active document's uid and its selected objects' ids: what a handle drag moves graphics for. +fn drag_owner(app: &VectorcraftApp) -> (u64, Vec) { + app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())).unwrap_or_default() } /// How near a press must be to a graphic's handle to grab it, in screen points. @@ -347,6 +353,13 @@ pub(crate) fn graphic_handles_input(app: &mut VectorcraftApp, ui: &egui::Ui, res let xf = &crate::canvas::Xf::at(rect, app.view().unwrap_or(&crate::state::View::default()), app.canvas_ui_zoom); let pointer = ui.input(|i| i.pointer.interact_pos()); if let Some(drag) = ctx.data(|m| m.get_temp::(handle_drag_id())) { + if drag_owner(app) != drag.owner { + // The selection or the document changed during the drag: commit the preview and end + // the drag, so its grab offset does not move another object's graphic. + send(app, &ctx, Map::new(), false); + ctx.data_mut(|m| m.remove::(handle_drag_id())); + return true; + } let down = ui.input(|i| i.pointer.primary_down()); if let (Some(p), Some((frame, mut list, _))) = (pointer, graphic_handles(app, &ctx)) && let Some((x, y)) = frame.offset_at(drag.face, xf.to_doc(p) - drag.grab) @@ -369,6 +382,12 @@ pub(crate) fn graphic_handles_input(app: &mut VectorcraftApp, ui: &egui::Ui, res if !(resp.hovered() && ui.input(|i| i.pointer.primary_pressed())) { return false; } + // A press with Space held, or with the Hand, Zoom or Rotate View tool, pans, zooms or turns the + // view, as on any other part of the canvas. + let space = ui.input(|i| i.key_down(egui::Key::Space)); + if space || matches!(app.session.tool_id(), "hand" | "zoom" | "rotateView") { + return false; + } let (Some(p), Some((_, list, handles))) = (pointer, graphic_handles(app, &ctx)) else { return false }; let nearest = handles .iter() @@ -377,7 +396,8 @@ pub(crate) fn graphic_handles_input(app: &mut VectorcraftApp, ui: &egui::Ui, res .min_by(|a, b| a.2.total_cmp(&b.2)); let Some((index, at, _)) = nearest else { return false }; let Some(face) = list.get(index).map(|g| g.face) else { return false }; - ctx.data_mut(|m| m.insert_temp(handle_drag_id(), HandleDrag { index, face, grab: xf.to_doc(p) - at })); + let owner = drag_owner(app); + ctx.data_mut(|m| m.insert_temp(handle_drag_id(), HandleDrag { index, face, grab: xf.to_doc(p) - at, owner })); set_selected_graphic(&ctx, index); true }