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:
dotCooCoo
2026-11-12 15:02:34 -08:00
parent 554d86765a
commit 505d11dae5
8 changed files with 219 additions and 27 deletions
+3 -1
View File
@@ -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;
+8
View File
@@ -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;
+32 -14
View File
@@ -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.
+83 -5
View File
@@ -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}");
}
+5 -5
View File
@@ -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, &params).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();
+9 -1
View File
@@ -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
View File
@@ -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: