mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
3D and Materials: straight graphic sides count once, and graphic handles stay with the drawn art
- Under a perspective, the shared 400,000-element limit of a 3D effect's graphics now counts what each path draws once mapped: a straight side stays one element, and only curves count as their 8 pieces. Two copies of a 30,000-sided polygon now both draw. The symbol check counts the same way, and without a perspective it counts each curve once. - The Materials tab's handles on the artboard are hidden while a geometry effect (such as Transform) comes after the 3D effect of a selected object, since that effect draws the graphics away from the handles. - A press on the canvas while its context menu is open only closes the menu, and no longer grabs a graphic's handle. - A symbol instance inside a graphic's art shows its changes to a dynamic symbol, and a symbol with 9-slice guides is placed through them, as the canvas draws the instance. Paths that cross a guide are cut at it.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1232,6 +1232,14 @@ pub fn graphic_frame_at(doc: Option<&Document>, n: &Node, item: Option<usize>, 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<usize>, 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;
|
||||
|
||||
|
||||
@@ -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::<usize>()).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::<usize>();
|
||||
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<Vec<Piece>, String> {
|
||||
fn walk(doc: &Document, n: &Node, outer: f32, xf: Affine, (depth, level): (usize, usize), out: &mut Vec<Piece>) -> Result<(), String> {
|
||||
@@ -224,7 +233,18 @@ fn pieces_of(doc: &Document, symbol: &Symbol) -> Result<Vec<Piece>, 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.
|
||||
|
||||
@@ -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::<Vec<_>>(), [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<f64> = 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}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<egui::Event>| {
|
||||
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::HandleDrag>(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();
|
||||
|
||||
@@ -301,7 +301,8 @@ type Handles = (vectorcraft_effects::GraphicFrame, Vec<vectorcraft_effects::Mapp
|
||||
|
||||
/// The handles of the graphics mapped onto the first selected object, while the Materials tab is
|
||||
/// open: each graphic's index, its face and where its center lands on the page, for the graphics
|
||||
/// whose face the viewer sees. With them, the object's [`vectorcraft_effects::GraphicFrame`] and the
|
||||
/// whose face the viewer sees, and none while a geometry effect follows a selected object's 3D effect.
|
||||
/// With them, the object's [`vectorcraft_effects::GraphicFrame`] and the
|
||||
/// graphics.
|
||||
fn graphic_handles(app: &VectorcraftApp, ctx: &egui::Context) -> Option<Handles> {
|
||||
if !materials_open(ctx) {
|
||||
@@ -312,6 +313,13 @@ fn graphic_handles(app: &VectorcraftApp, ctx: &egui::Context) -> Option<Handles>
|
||||
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));
|
||||
|
||||
+3
-1
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user