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.
This commit is contained in:
dotCooCoo
2026-11-12 12:42:59 -08:00
parent 5f7d4d3922
commit c31af9898e
9 changed files with 216 additions and 21 deletions
+8 -3
View File
@@ -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<Node>)> {
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));
+10 -7
View File
@@ -316,22 +316,24 @@ fn cubic_to(out: &mut BezPath, c: CubicBez, to: &dyn Fn(Point) -> Option<Point>)
/// 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<Node> {
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;
@@ -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();
@@ -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
@@ -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");
+13 -6
View File
@@ -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<Point> {
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<Point> {
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
+19 -1
View File
@@ -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();
@@ -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<egui::Event>) -> 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"});
+24 -4
View File
@@ -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<u64>),
}
/// The active document's uid and its selected objects' ids: what a handle drag moves graphics for.
fn drag_owner(app: &VectorcraftApp) -> (u64, Vec<u64>) {
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::<HandleDrag>(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::<HandleDrag>(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
}