From 3e9e58afb1626cf6ebfaf2a7e399ff2ac9c1c5c9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 18:59:02 -0800 Subject: [PATCH] 3D: mapped graphics keep paint order and hide handles on hidden fills A solid now shares its mapped graphics only with the run of solids just before it that have the same opacity and blending mode. Before, it joined any earlier solid with the same settings, so with members A, B and C where A and C match, A's graphic was drawn after C and over B. The Materials tab shows no artboard handles for graphics on a hidden fill or stroke, which draws no solid and no graphics. docs/three-d.md now says that pattern tiles in a graphic keep their own colors instead of taking the face's light. --- crates/effects/src/solids.rs | 8 +++-- crates/effects/src/solids/tests_graphics.rs | 35 ++++++++++++++++++++ crates/ui-egui/src/panels/tests_three_d.rs | 36 +++++++++++++++++++++ crates/ui-egui/src/panels/three_d.rs | 4 +++ docs/three-d.md | 3 +- 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 93679ca71d..91f73049f1 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -866,7 +866,8 @@ fn masks_surface(paint: &Paint, colored: bool) -> bool { } /// The graphics of the solids `filled` ([`graphic_art`]), each with the index of the surface it is -/// drawn right after (the last of those solids holding its face) and its face. With `per_member` +/// drawn right after (the last of those solids holding its face) and its face. Solids in a run +/// with the same opacity and blending mode share their graphics. 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 @@ -883,11 +884,12 @@ fn placed_graphics( for f in filled { // Solids whose fills or strokes differ in opacity or blending mode get graphics of their // own, each drawn as its fill or stroke is. A solid whose surface carries an opacity mask - // gets its own too, which takes that mask ([`with_graphics`]). + // gets its own too, which takes that mask ([`with_graphics`]). A solid joins only the + // group of the solid before it, so a graphic is never drawn past a solid painted between. let joins = |first: &Filled| { (!per_member || first.member == f.member) && first.opacity == f.opacity && first.blend == f.blend && !first.masked && !f.masked }; - match groups.iter_mut().find(|g| g.first().is_some_and(|first| joins(first))) { + match groups.last_mut().filter(|g| g.first().is_some_and(|first| joins(first))) { Some(g) => g.push(f), None => groups.push(vec![f]), } diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index 64c947a9df..aa73535fc5 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -365,6 +365,41 @@ fn members_with_their_own_blending_get_graphics_of_their_own() { assert_eq!(blended, 1); } +#[test] +fn a_graphic_stays_under_a_solid_painted_after_its_own() { + let d = document(); + let member = |id: u64, r: Rect, color: Color, opacity: f32| { + let mut n = square(id, r, Paint::solid(color)); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.opacity = opacity; + } + Arc::new(n) + }; + let gray = Color::rgb(0.5, 0.5, 0.5); + // A and C share their fill's opacity; B, painted between them, has its own and covers part of A. + let a = member(1, Rect::new(0.0, 0.0, 60.0, 100.0), gray, 1.0); + let b = member(2, Rect::new(40.0, 0.0, 100.0, 100.0), Color::rgb(0.0, 0.0, 1.0), 0.5); + let c = member(4, Rect::new(200.0, 0.0, 260.0, 100.0), gray, 1.0); + let mut group = Node::group(NodeId(3), vec![a, b, c]); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false, "graphics": [{"symbol": "Mark", "scale": 3000.0}]}); + group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + let art = solid_art_in(Some(&d), &group).unwrap(); + // In drawing order: 'G' for a graphic, 'B' for a face of B's solid. + let mut order = String::new(); + art.walk(&mut |n| { + if matches!(n.kind, NodeKind::Group { clip: true, .. }) && !graphics_in(n).is_empty() { + order.push('G'); + } else if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() + && f.paint.color().is_some_and(|c| c.to_rgb()[2] > 0.9 && c.to_rgb()[0] < 0.1) + { + order.push('B'); + } + }); + // A's graphic is drawn right after A, under B; C's is drawn after B. + assert_eq!(graphics_in(&art).len(), 3, "{order}"); + assert!(order.starts_with('G'), "{order}"); +} + /// [`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(); diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 9cebea0ba6..08f0819b3a 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -612,6 +612,42 @@ fn a_geometry_effect_after_the_3d_effect_hides_the_graphics_handles() { handles_frame(&mut app, &ctx, vec![primary(handle, false)]); } +#[test] +fn a_hidden_fill_shows_no_handles_for_its_graphics() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + // The 3D effect, with its graphic, moves onto the square's fill. + let fill = { + let st = app.session.active_mut().unwrap(); + let id = st.selection.objects[0]; + let n = std::sync::Arc::make_mut(&mut st.doc).node_mut(id).unwrap(); + let fx = n.appearance.effects.remove(0); + let fill = n.appearance.items.iter().position(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(_))).unwrap(); + n.appearance.effects_mut(Some(fill)).unwrap().push(fx); + fill + }; + let shown = |app: &VectorcraftApp| { + let mut shown = false; + ctx.run_ui(egui::RawInput::default(), |ui| { + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + shown = super::graphic_handles(app, ui.ctx()).is_some_and(|(_, _, handles)| !handles.is_empty()); + }) + .textures_delta + .clear(); + shown + }; + assert!(shown(&app), "the visible fill's graphic has a handle"); + { + let st = app.session.active_mut().unwrap(); + let id = st.selection.objects[0]; + let n = std::sync::Arc::make_mut(&mut st.doc).node_mut(id).unwrap(); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = n.appearance.items.get_mut(fill) { + f.visible = false; + } + } + assert!(!shown(&app), "the hidden fill draws no graphic, so it has no handle"); +} + #[test] fn a_press_that_closes_the_context_menu_does_not_grab_a_graphics_handle() { let (mut app, ctx) = materials_app(); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 805abbd51f..1d829353f1 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -324,6 +324,10 @@ fn graphic_handles(app: &VectorcraftApp, ctx: &egui::Context) -> Option let st = app.session.active()?; let n = st.selection.objects.first().and_then(|id| st.doc.node(*id))?; let t = find(n)?; + // A hidden fill or stroke draws no solid and no graphics, so its graphics get no handles. + if t.item.is_some_and(|k| n.appearance.items.get(k).is_none_or(|it| !it.visible())) { + return None; + } // 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 diff --git a/docs/three-d.md b/docs/three-d.md index e4cd4fa76f..38b59023dc 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -326,7 +326,8 @@ These rules are VectorCraft's own: - A graphic is drawn on the visible face of each of the object's solids (a fill's, and a stroke's outline), turned, foreshortened by the perspective and twisted and tapered with the face, and clipped to it. It is shaded - with the face's light: each color is multiplied by the light that falls on the face, and drawn with the + with the face's light: each color is multiplied by the light that falls on the face, except in pattern tiles, + which keep their own colors. It is drawn with the opacity and blending mode of the fill or stroke the face is painted with. A graphic on a face turned away from the viewer is not drawn. - On the back cap, X runs to the right as the back is seen from behind, so the graphic reads the right way round