From a0e0f09d79fac9e60865f1e23f283132067abbdc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:42:26 -0800 Subject: [PATCH] 3D and Materials: bound Revolve graphics' work and paint copies, and close the gap at the back - Each contour of a graphic is cut only at the faces its own bounds reach, and the points cut at the faces count toward a limit of 64,000,000. - The paint copies of a graphic's parts count toward a budget of their own, one copy for a part's fill and one for its seam stroke when it takes one. - A part filled with a pattern, or whose gradient has a transparent stop or point, takes no seam stroke. - Under several lights an opaque solid part also covers its seams, with its lit art clipped to a band of the seam stroke's width. - A line of a graphic that reaches farther than any surface is refused, as a curve already was. - A full turn places each segment by its own middle, so the cut at the back falls on the segment edge nearest half a turn from the middle. - A group's members are measured for graphics only when it has some. - docs/three-d.md: Revolve shows no Face control, and the new limits. --- crates/effects/src/revolve.rs | 86 ++++++--- crates/effects/src/revolve/tests_graphics.rs | 183 +++++++++++++++++++ crates/effects/src/solids/graphics.rs | 90 +++++++-- crates/effects/src/solids/tests_graphics.rs | 36 ++++ crates/three-d/src/lib.rs | 7 +- docs/three-d.md | 13 +- 6 files changed, 366 insertions(+), 49 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 95c643697c..18b6d57a68 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -29,6 +29,15 @@ pub fn is_revolve(id: &str) -> bool { /// thousands of faces took gigabytes; past this the source path is drawn. const MAX_PAINT_COPIES: usize = 256 << 20; +/// The bytes of an object's paint copies so far: those of its surfaces' faces, and those of the parts +/// of its graphics, each within [`MAX_PAINT_COPIES`] of its own. Graphics past their budget are left +/// out without taking room the surfaces need. +#[derive(Default)] +struct Copies { + surfaces: usize, + graphics: usize, +} + /// The most face and box tests a group's transparent members' faces take together to rejoin their /// members' last runs; past them each starts a run of its own. pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22; @@ -145,11 +154,12 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let options = shared_options(&shapes, options_of(id, p))?; let mut copies = 0; shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; - match graphics_member(&shapes, b, options).and_then(|k| shapes.get(k)) { - Some(s) => check_graphics(doc, id, &s.path, b, options, p)?, - // With no member to carry them, the graphics' art must still fit its limits. - None => { - if let Some(doc) = doc.filter(|_| id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty()) { + // Without graphics, or without the document they draw from, no member is measured for them. + if let Some(doc) = doc.filter(|_| id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty()) { + match graphics_member(&shapes, b, options).and_then(|k| shapes.get(k)) { + Some(s) => check_graphics(Some(doc), id, &s.path, b, options, p, &mut 0)?, + // With no member to carry them, the graphics' art must still fit its limits. + None => { crate::solids::graphics::validate_symbols(doc, p)?; crate::solids::graphics::validate_surface_symbols(doc, p)?; } @@ -166,22 +176,28 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?; - check_graphics(doc, id, &path, b, options_of(id, p), p)?; + check_graphics(doc, id, &path, b, options_of(id, p), p, &mut 0)?; check_shadow(p, [&path], b, options_of(id, p)) } /// An error when the graphics in Revolve (`id`) parameters `p`, with the symbols of `doc`, are past /// their limits on the surface of `path` about the axis of `b`: their art, or the parts they draw -/// on its faces ([`crate::solids::graphics::surface_parts`]). Without `doc` nothing is drawn, and -/// nothing is checked. -fn check_graphics(doc: Option<&Document>, id: &str, path: &PathData, b: Rect, options: Revolve, p: &Value) -> Result<(), String> { +/// on its faces ([`crate::solids::graphics::surface_parts`]), whose paint copies count after +/// `copies`, the bytes of the copies of the object's graphics so far ([`Copies`]). The parts' +/// copies are added to `copies` when they fit, as drawing adds them. Without `doc` nothing is +/// drawn, and nothing is checked. +fn check_graphics(doc: Option<&Document>, id: &str, path: &PathData, b: Rect, options: Revolve, p: &Value, copies: &mut usize) -> Result<(), String> { let graphics = crate::solids::mapped_graphics(p); let Some(doc) = doc.filter(|_| id == REVOLVE && !graphics.is_empty()) else { return Ok(()) }; crate::solids::graphics::validate_symbols(doc, p)?; crate::solids::graphics::validate_surface_symbols(doc, p)?; let faces = vectorcraft_three_d::revolve(path, b, options)?; - match crate::solids::graphics::surface_parts(Some(doc), NodeId(0), &graphics, &faces, &|_, c| Paint::solid(c)) { - Some(_) => Ok(()), + let mut used = *copies; + match crate::solids::graphics::surface_parts(Some(doc), NodeId(0), &graphics, &faces, &|_, c| Paint::solid(c), &mut used) { + Some(_) => { + *copies = used; + Ok(()) + } None => Err("The graphics are too complex for this surface: simplify the symbols' art or reduce Segments".into()), } } @@ -232,7 +248,7 @@ pub(crate) fn validate_item_of(doc: Option<&Document>, n: &Node, id: &str, p: &V } let ctx = GeomContext::of(n); let path = geometry(&path, &n.appearance.effects, &ctx); - let mut copies = 0; + let (mut copies, mut graphic_copies) = (0, 0); for (i, item) in n.appearance.items.iter().enumerate() { let effects = item.effects(); let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && is_revolve(&e.id)) }; @@ -245,8 +261,11 @@ pub(crate) fn validate_item_of(doc: Option<&Document>, n: &Node, id: &str, p: &V let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + // The graphics of every item add their paints' copies to the graphics' budget, as they + // are drawn; only the edited item's graphics are refused here. + let graphics = check_graphics(doc, kind, &path, b, options, ¶ms, &mut graphic_copies); if i == target { - check_graphics(doc, kind, &path, b, options, ¶ms)?; + graphics?; } check_shadow(¶ms, [&path], b, options)?; } @@ -352,7 +371,7 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R } /// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts -/// the bytes of the paint copies of every surface of the object. Revolve (Classic) draws every face +/// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face /// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the /// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]): /// a face with a part of a graphic is a group of the face and that part. @@ -368,14 +387,14 @@ fn surface( opacity: f32, blend: vectorcraft_doc::color::BlendMode, expand: bool, - copies: &mut usize, + copies: &mut Copies, ) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. if fx.id == CLASSIC_REVOLVE { let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?; - *copies = copies.saturating_add(paint_copies(facets.len(), &paint)); - if *copies > MAX_PAINT_COPIES { + copies.surfaces = copies.surfaces.saturating_add(paint_copies(facets.len(), &paint)); + if copies.surfaces > MAX_PAINT_COPIES { return None; } let depths = facets.iter().map(|f| f.depth).collect(); @@ -384,15 +403,26 @@ fn surface( } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; - *copies = copies.saturating_add(paint_copies(faces.len(), &paint)); - if *copies > MAX_PAINT_COPIES { + copies.surfaces = copies.surfaces.saturating_add(paint_copies(faces.len(), &paint)); + if copies.surfaces > MAX_PAINT_COPIES { return None; } let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)); let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored); - // Graphics past the limits are left out, and the surface draws without them. - let mut parts = - if graphics.is_empty() { vec![] } else { crate::solids::graphics::surface_parts(doc, n.id, &graphics, &faces, &shade).unwrap_or_default() }; + // Graphics past the limits are left out, and the surface draws without them and their paints' + // copies. + let mut used = copies.graphics; + let mut parts = if graphics.is_empty() { + vec![] + } else { + match crate::solids::graphics::surface_parts(doc, n.id, &graphics, &faces, &shade, &mut used) { + Some(parts) => { + copies.graphics = used; + parts + } + None => vec![], + } + }; let drawn = parts.iter().any(|p| !p.is_empty()); // Visibility is an expansion option, never a change to live geometry or export baking. // Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility @@ -499,7 +529,7 @@ fn object_surface( fx: &Effect, item: &AppearanceItem, expand: bool, - copies: &mut usize, + copies: &mut Copies, ) -> Option { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); @@ -688,7 +718,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); let mut children = vec![]; - let mut copies = 0; + let mut copies = Copies::default(); let mut remaining = n.appearance.effects.clone(); if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = n.appearance.effects.get(index)?; @@ -873,11 +903,13 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let b = union_bounds(&shapes)?; let options = shared_options(&shapes, fx_options(fx)).ok()?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut copies = 0; + let mut copies = Copies::default(); let mut surfaces = vec![]; let mut opaque = true; - // The graphics go once, on the side of the member with the longest profile. - let longest = graphics_member(&shapes, b, options); + // The graphics go once, on the side of the member with the longest profile. Without graphics + // no member is measured for them. + let mapped = fx.id == REVOLVE && !crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)).is_empty(); + let longest = if mapped { graphics_member(&shapes, b, options) } else { None }; for (k, s) in shapes.iter().enumerate() { let Some(item) = surface_item(&s.items) else { continue }; // The opacity and blending of the member and the groups it is in apply through its scopes. diff --git a/crates/effects/src/revolve/tests_graphics.rs b/crates/effects/src/revolve/tests_graphics.rs index 2261ab4ca7..5d9500628b 100644 --- a/crates/effects/src/revolve/tests_graphics.rs +++ b/crates/effects/src/revolve/tests_graphics.rs @@ -261,6 +261,22 @@ fn under_several_lights_a_graphic_takes_each_faces_own_lit_art() { count }; assert!(lit(&mapped) > lit(&plain), "{} {}", lit(&mapped), lit(&plain)); + // An opaque part also draws its lit art clipped to the band of its seam stroke; a translucent + // part, whose overlap would show, does not. + let mut d = d.clone(); + let mut clear = bar(Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(1.0, 0.0, 0.0)); + if let Some(AppearanceItem::Fill(f)) = clear.appearance.items.first_mut() { + f.opacity = 0.5; + } + d.symbols.push(Symbol { dynamic: false, name: "Clear".into(), art: Arc::new(clear) }); + let clips = |n: &Node| { + let mut count = 0; + revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(matches!(c.kind, NodeKind::Group { clip: true, .. }))); + count + }; + let translucent = revolved(&CYLINDER, json!({"shade": true, "lights": lights, "graphics": [{"symbol": "Clear"}]})); + let (base, opaque, translucent) = (clips(&plain), clips(&mapped), clips(&translucent)); + assert!(translucent > base && opaque - base == 2 * (translucent - base), "{base} {opaque} {translucent}"); } #[test] @@ -391,3 +407,170 @@ fn a_group_with_no_member_to_carry_graphics_still_checks_their_art() { assert!(e.contains("too complex"), "{e}"); assert!(validate_in(Some(&d), &group, REVOLVE, &json!({"graphics": [{"symbol": "Red"}]})).is_ok()); } + +#[test] +fn a_full_turn_draws_a_graphic_on_either_side_of_the_cut_at_the_back() { + let mut d = document(); + d.symbols.push(Symbol { dynamic: false, name: "Thin".into(), art: Arc::new(bar(Rect::new(0.0, 0.0, 2.0, 20.0), Color::rgb(1.0, 0.0, 0.0))) }); + // Half a turn around a radius of 50 is about 157 to each side of the middle: turned to face + // the viewer, a graphic just inside either end is drawn. + for x in [155.0, -155.0] { + let n = revolved(&CYLINDER, json!({"rotationY": 180.0, "graphics": [{"symbol": "Thin", "x": x}]})); + assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty(), "x {x}"); + } +} + +/// A gray cylinder of radius 50 whose side is 200 edges along the profile, from y 0 to 200. +fn ribbed() -> Vec<(f64, f64)> { + let mut points = vec![(0.0, 0.0)]; + points.extend((0..=200).map(|k| (50.0, f64::from(k)))); + points.push((0.0, 200.0)); + points +} + +/// A symbol `name` of one red path whose contours are `contours`, each a polygon through its +/// points. +fn contours_symbol(name: &str, contours: &[Vec]) -> Symbol { + let mut b = BezPath::new(); + for contour in contours { + b.move_to(contour[0]); + contour[1..].iter().for_each(|p| b.line_to(*p)); + b.close_path(); + } + let art = Node::path(NodeId(0), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0)); + Symbol { dynamic: false, name: name.into(), art: Arc::new(art) } +} + +#[test] +fn contours_away_from_a_face_are_not_cut_at_it() { + // 2,000 small squares in two clusters on the back, at opposite corners of the side: the path's + // bounds reach across every face, but no square reaches a face that is drawn. + let squares: Vec> = (0..2000) + .map(|i| { + let (x, y) = if i < 1000 { (0.0, 0.0) } else { (290.0, 190.0) }; + let (x, y) = (x + f64::from(i % 10) * 0.1, y + f64::from(i % 1000 / 10) * 0.1); + vec![Point::new(x, y), Point::new(x + 0.05, y), Point::new(x + 0.05, y + 0.05), Point::new(x, y + 0.05)] + }) + .collect(); + let mut d = document(); + d.symbols.push(contours_symbol("Clusters", &squares)); + let n = revolved(&ribbed(), json!({})); + let p = json!({"graphics": [{"symbol": "Clusters"}]}); + assert_eq!(validate_revolve_in(Some(&d), &n, &p), Ok(())); +} + +#[test] +fn a_contour_that_reaches_every_face_and_draws_on_none_is_refused() { + // One contour of 10,000 points: a thin frame around the whole side, past its ends and on the + // back, so it is cut at every face and keeps nothing. + let edge = |from: Point, to: Point| (0..2500).map(move |k| from.lerp(to, f64::from(k) / 2500.0)); + let (outer, inner) = ([(0.0, 0.0), (400.0, 0.0), (400.0, 400.0), (0.0, 400.0)], [(1.0, 1.0), (1.0, 399.0), (399.0, 399.0), (399.0, 1.0)]); + let ring = |corners: [(f64, f64); 4]| (0..4).flat_map(move |k| edge(corners[k].into(), corners[(k + 1) % 4].into())).collect::>(); + let mut frame = ring(outer); + frame.extend(ring(inner)); + let mut d = document(); + d.symbols.push(contours_symbol("Frame", &[frame])); + let n = revolved(&ribbed(), json!({})); + let e = validate_revolve_in(Some(&d), &n, &json!({"graphics": [{"symbol": "Frame"}]})).unwrap_err(); + assert!(e.contains("too complex for this surface"), "{e}"); + assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); +} + +/// A red linear gradient of `stops` stops, each with `opacity`. +fn ramp(stops: usize, opacity: f32) -> Paint { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = (0..stops) + .map(|i| { + let mut stop = GradientStop::new(i as f32 / stops as f32, Color::rgb(1.0, 0.0, 0.0)); + stop.opacity = opacity; + stop + }) + .collect(); + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))) +} + +#[test] +fn the_copies_of_a_graphics_paint_have_a_budget_of_their_own() { + let mut d = document(); + for (name, opacity) in [("Ramp", 1.0), ("Clear ramp", 0.5)] { + let art = + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 10.0)), Appearance::basic(ramp(4096, opacity), Paint::None, 0.0)); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + let p = json!({"graphics": [{"symbol": "Ramp"}]}); + let n = revolved(&CYLINDER, json!({})); + let NodeKind::Path { path, .. } = &n.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(path, path.bounds().unwrap(), options_of(REVOLVE, &p)).unwrap(); + let parts_of = |symbol: &str, copies: &mut usize| { + let graphics = crate::solids::mapped_graphics(&json!({"graphics": [{"symbol": symbol}]})); + crate::solids::graphics::surface_parts(Some(&d), NodeId(0), &graphics, &faces, &|_, c| Paint::solid(c), copies).map(|p| p.concat().len()) + }; + // Each opaque part copies the paint into its fill and its seam stroke; a part with + // transparent stops takes no seam stroke and copies it once. + let (opaque, clear) = (paint_bytes(&ramp(4096, 1.0)), paint_bytes(&ramp(4096, 0.5))); + let mut used = 0; + let count = parts_of("Ramp", &mut used).unwrap(); + assert!(count >= 2, "the graphic draws on several faces"); + assert_eq!(used, 2 * count * opaque); + let mut used = 0; + assert_eq!(parts_of("Clear ramp", &mut used), Some(count)); + assert_eq!(used, count * clear); + // Past the budget the graphic is refused. + assert_eq!(parts_of("Ramp", &mut (MAX_PAINT_COPIES - opaque)), None); + // A surface whose own gradient takes nearly the whole budget for surfaces still takes the + // graphic, on the object, on one fill of two and on a member of a group. + let stop = paint_bytes(&ramp(2, 1.0)) - paint_bytes(&ramp(1, 1.0)); + let base = paint_bytes(&ramp(1, 1.0)) - stop; + let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let full = ramp(((MAX_PAINT_COPIES - paint_copies(faces.len(), &gray)) / (faces.len() * 4) - base) / stop, 1.0); + assert!(MAX_PAINT_COPIES - paint_copies(faces.len(), &gray) - paint_copies(faces.len(), &full) < opaque); + let mut object = n.clone(); + if let Some(AppearanceItem::Fill(f)) = object.appearance.items.first_mut() { + f.paint = ramp(((MAX_PAINT_COPIES - opaque) / (faces.len() * 4) - base) / stop, 1.0); + } + assert_eq!(validate_revolve_in(Some(&d), &object, &p), Ok(())); + let fill = |paint: Paint, params: Value| { + let mut fill = FillLayer::new(paint); + fill.effects.push(crate::new_effect(REVOLVE, ¶ms).unwrap()); + AppearanceItem::Fill(fill) + }; + let mut two = n.clone(); + two.appearance = Appearance { items: vec![fill(gray.clone(), p.clone()), fill(full.clone(), json!({}))], ..Default::default() }.into(); + assert_eq!(crate::solids::validate_three_d_at_in(Some(&d), &two, REVOLVE, &json!({}), (Some(1), 0)), Ok(())); + let mut member = n.clone(); + member.appearance.effects.clear(); + let mut second = member.clone(); + if let Some(AppearanceItem::Fill(f)) = second.appearance.items.first_mut() { + f.paint = full; + } + let mut group = Node::group(NodeId(9), vec![Arc::new(member), Arc::new(second)]); + group.appearance.effects = n.appearance.effects.clone(); + assert_eq!(validate_in(Some(&d), &group, REVOLVE, &p), Ok(())); +} + +/// Whether each part of `art` filled with a gradient has a stroke over its seams. +fn gradient_seams(art: &Node) -> Vec { + let mut out = vec![]; + art.walk(&mut |n| { + let items = &n.appearance.items; + let red = |p: &Paint| matches!(p, Paint::Gradient(_)) && shade_of(p, false).is_some(); + if n.path_data().is_some() && matches!(items.first(), Some(AppearanceItem::Fill(f)) if red(&f.paint)) { + out.push(items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))); + } + }); + out +} + +#[test] +fn a_gradient_with_transparent_stops_takes_no_stroke_over_its_seams() { + let mut d = document(); + for (name, opacity) in [("Opaque", 1.0), ("Clear", 0.5)] { + let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 10.0)), Appearance::basic(ramp(2, opacity), Paint::None, 0.0)); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + let seams = |symbol: &str| gradient_seams(&revolve_art_in(Some(&d), &revolved(&CYLINDER, json!({"graphics": [{"symbol": symbol}]}))).unwrap()); + let opaque = seams("Opaque"); + assert!(!opaque.is_empty() && opaque.iter().all(|s| *s), "{opaque:?}"); + let clear = seams("Clear"); + assert!(!clear.is_empty() && clear.iter().all(|s| !*s), "{clear:?}"); +} diff --git a/crates/effects/src/solids/graphics.rs b/crates/effects/src/solids/graphics.rs index 4957e20c56..7c0087e15b 100644 --- a/crates/effects/src/solids/graphics.rs +++ b/crates/effects/src/solids/graphics.rs @@ -65,6 +65,10 @@ const SURFACE_REACH: f64 = 1e8; /// at every face it crosses. Past it no graphic is drawn on that surface. const MAX_SURFACE_POINTS: usize = 2_000_000; +/// The most points of Revolve's graphics cut at its faces together, counted before each cut whether +/// or not it keeps any of them. Past it no graphic is drawn on that surface. +const MAX_SURFACE_CUTS: usize = 32 * MAX_SURFACE_POINTS; + /// The width of the stroke of the same paint around each opaque part of a graphic on Revolve's /// side, which covers the seams between the parts on neighboring faces. const SURFACE_SEAM: f64 = 0.3; @@ -518,6 +522,8 @@ pub(super) fn face_art( /// One area of a graphic placed on Revolve's unrolled side surface, cut into straight pieces. struct Unrolled { polygons: Vec>, + /// The bounds of each of `polygons`. + boxes: Vec, bounds: Rect, rule: FillRule, paint: Paint, @@ -543,8 +549,19 @@ fn unrolled(doc: &Document, graphics: &[MappedGraphic]) -> Option> let placed = place * piece.path.to_bezpath(); let mut polygons = straight_pieces(&placed, &mut points)?; polygons.retain(|p| p.len() >= 3 && p.iter().all(|q| q.is_finite())); - let Some(bounds) = polygons.iter().flatten().map(|p| Rect::from_points(*p, *p)).reduce(|a, b| a.union(b)) else { continue }; - pieces.push(Unrolled { polygons, bounds, rule: piece.rule, paint: piece.paint, opacity: piece.opacity, blend: piece.blend, place }); + let boxes: Vec = + polygons.iter().filter_map(|polygon| polygon.iter().map(|p| Rect::from_points(*p, *p)).reduce(|a, b| a.union(b))).collect(); + let Some(bounds) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { continue }; + pieces.push(Unrolled { + polygons, + boxes, + bounds, + rule: piece.rule, + paint: piece.paint, + opacity: piece.opacity, + blend: piece.blend, + place, + }); } } Some(pieces) @@ -553,8 +570,8 @@ fn unrolled(doc: &Document, graphics: &[MappedGraphic]) -> Option> /// The contours of `path` cut into straight pieces within [`SURFACE_TOLERANCE`], adding the points /// made to `points`. Each curve is cut on its own and the count is checked before the next one, so /// the work stops at the first curve past [`MAX_ELEMENTS`] points: `None` then, and for a path with -/// a coordinate farther than [`SURFACE_REACH`] from the origin, which bounds the pieces of one -/// curve. +/// a coordinate of a line or a curve farther than [`SURFACE_REACH`] from the origin, which bounds +/// the pieces of one curve and the arithmetic of the cuts at the faces. fn straight_pieces(path: &BezPath, points: &mut usize) -> Option>> { let near = |p: &Point| p.x.abs() <= SURFACE_REACH && p.y.abs() <= SURFACE_REACH; let mut polygons: Vec> = vec![]; @@ -565,6 +582,7 @@ fn straight_pieces(path: &BezPath, points: &mut usize) -> Option> return None; } match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) if !near(&p) => return None, PathEl::MoveTo(p) => { polygons.push(std::mem::take(&mut current)); current.push(p); @@ -616,14 +634,17 @@ pub(crate) fn validate_surface_symbols(doc: &Document, p: &Value) -> Result<(), /// `shade` gives the paint of a solid color on a face, as the surface under it is shaded; the /// colors of gradients and patterns are multiplied by the face's light. A graphic whose symbol /// `doc` does not have, or that is past the limits of its art, draws nothing. `None` past -/// [`MAX_ELEMENTS`] points together before they are cut at the faces, or [`MAX_SURFACE_POINTS`] -/// after: commands refuse such graphics, and a loaded file draws the surface without them. +/// [`MAX_ELEMENTS`] points together before they are cut at the faces, [`MAX_SURFACE_POINTS`] +/// after, [`MAX_SURFACE_CUTS`] points cut, or when the copies of their paints in the parts take +/// `copies`, the bytes of the copies of the object's graphics so far, past Revolve's budget for +/// them: commands refuse such graphics, and a loaded file draws the surface without them. pub(crate) fn surface_parts( doc: Option<&Document>, id: NodeId, graphics: &[MappedGraphic], faces: &[vectorcraft_three_d::Face], shade: &dyn Fn(&vectorcraft_three_d::Face, vectorcraft_doc::color::Color) -> Paint, + copies: &mut usize, ) -> Option>>> { let mut out: Vec>> = vec![vec![]; faces.len()]; let Some(doc) = doc else { return Some(out) }; @@ -640,6 +661,7 @@ pub(crate) fn surface_parts( } let mut seen = vec![usize::MAX; pieces.len()]; let mut drawn = 0usize; + let mut cut_points = 0usize; let mut triangle_id = 0usize; for (face, parts) in faces.iter().zip(out.iter_mut()) { let Some(cell) = face.surface.as_ref().filter(|c| c.outward && !face.cap) else { continue }; @@ -695,7 +717,15 @@ pub(crate) fn surface_parts( for k in near { let Some(piece) = pieces.get(k) else { continue }; let mut path = BezPath::new(); - for polygon in &piece.polygons { + // Each contour is cut only where its own bounds reach the triangle. + for (polygon, b) in piece.polygons.iter().zip(&piece.boxes) { + if b.x1 < reach.x0 || b.x0 > reach.x1 || b.y1 < reach.y0 || b.y0 > reach.y1 { + continue; + } + cut_points = cut_points.saturating_add(polygon.len()); + if cut_points > MAX_SURFACE_CUTS { + return None; + } let cut = clip_to(polygon, &window); let Some((first, rest)) = cut.split_first() else { continue }; path.move_to(map * *first); @@ -725,6 +755,11 @@ pub(crate) fn surface_parts( if path.elements().is_empty() { continue; } + // Each part copies its paint into its fill, and into the stroke over its seams when + // it takes one. + if !crate::revolve::paint_copies_add(copies, 1 + usize::from(takes_seam(piece)), &piece.paint) { + return None; + } parts.push(Arc::new(surface_piece(id, piece, (path, rule), map, face, shade))); } } @@ -744,14 +779,24 @@ fn surface_piece( ) -> Node { let gradient_map = map * piece.place; // Under several lights a face's light varies across it in each channel: a solid color draws - // as the face's own lit art in that color, clipped to the part. + // as the face's own lit art in that color, clipped to the part. An opaque part also draws that + // art clipped to a band of the seam stroke's width around its edges, as the other parts take + // a stroke over their seams. if let (Some(c), Some(light)) = (piece.paint.color().filter(|_| matches!(piece.paint, Paint::Solid { .. })), face.rgb_gradient) { - let lit = crate::lighting::rgb_gradient_art(id, &face.path(), &Paint::solid(c), light); - let mut clip = Node::path(id, PathData::from_bezpath(&path), Appearance::default()); - if let NodeKind::Path { rule: r, .. } = &mut clip.kind { - *r = rule; + let lit = Arc::new(crate::lighting::rgb_gradient_art(id, &face.path(), &Paint::solid(c), light)); + let clipped = |area: &BezPath, rule: FillRule| { + let mut clip = Node::path(id, PathData::from_bezpath(area), Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut clip.kind { + *r = rule; + } + Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), lit.clone()], clip: true }) + }; + let mut group = clipped(&path, rule); + if takes_seam(piece) { + let style = vectorcraft_geom::kurbo::Stroke::new(SURFACE_SEAM).with_join(vectorcraft_geom::kurbo::Join::Round); + let band = vectorcraft_geom::kurbo::stroke(path.iter(), &style, &vectorcraft_geom::kurbo::StrokeOpts::default(), SURFACE_TOLERANCE); + group = Node::group(id, vec![Arc::new(group), Arc::new(clipped(&band, FillRule::NonZero))]); } - let mut group = Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(lit)], clip: true }); group.opacity = piece.opacity; group.blend = piece.blend; return group; @@ -777,9 +822,8 @@ fn surface_piece( } } // Parts on neighboring faces meet edge to edge: an opaque part takes a narrow stroke of its - // paint over the seam, as the faces do. A transparent one would show the overlap. - if piece.opacity >= 1.0 - && piece.blend == BlendMode::Normal + // paint over the seam, as the faces do. + if takes_seam(piece) && let Some(AppearanceItem::Fill(f)) = appearance.items.first() { let mut seam = vectorcraft_doc::StrokeLayer::new(f.paint.clone(), SURFACE_SEAM); @@ -793,6 +837,20 @@ fn surface_piece( node } +/// Does each part of `piece` take a stroke of its paint over its seams? Only an opaque part does: +/// the overlap of a transparent one, of one whose gradient has a stop or a freeform point that is +/// not fully opaque, or of a pattern, whose tile may hold transparent art, would show. +fn takes_seam(piece: &Unrolled) -> bool { + let clear = match &piece.paint { + Paint::Gradient(g) => { + g.gradient.stops.iter().any(|s| s.opacity < 1.0) || g.freeform.as_ref().is_some_and(|f| f.points.iter().any(|p| p.opacity < 1.0)) + } + Paint::Pattern { .. } => true, + _ => false, + }; + piece.opacity >= 1.0 && piece.blend == BlendMode::Normal && !clear +} + /// A region far larger than the face on the side of the line through `a` and `b` that `toward` is /// on: a rectangle with one edge along the line, reaching a thousand times the line's length past /// its ends and away from it. diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index f336dca4b2..b4ded98e8f 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -728,3 +728,39 @@ fn curves_are_cut_one_at_a_time_and_the_work_stops_at_the_limit() { assert!(points <= 1, "{scale}: {points}"); } } + +#[test] +fn a_line_that_reaches_farther_than_any_surface_is_not_cut() { + let triangle = |far: f64| { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((far, 0.0)); + b.line_to((0.0, 1.0)); + b.close_path(); + b + }; + assert!(straight_pieces(&triangle(1e7), &mut 0).is_some()); + for far in [1e12, 1e300] { + assert!(straight_pieces(&triangle(far), &mut 0).is_none(), "{far}"); + assert!(straight_pieces(&(Affine::translate((far, 0.0)) * triangle(1.0)), &mut 0).is_none(), "{far}"); + } +} + +#[test] +fn only_an_opaque_part_on_revolve_takes_a_seam_stroke() { + let piece = |paint: Paint, opacity: f32, blend: BlendMode| Unrolled { + polygons: vec![], + boxes: vec![], + bounds: Rect::ZERO, + rule: FillRule::NonZero, + paint, + opacity, + blend, + place: Affine::IDENTITY, + }; + assert!(takes_seam(&piece(Paint::solid(red()), 1.0, BlendMode::Normal))); + assert!(!takes_seam(&piece(Paint::solid(red()), 0.5, BlendMode::Normal))); + assert!(!takes_seam(&piece(Paint::solid(red()), 1.0, BlendMode::Multiply))); + // A pattern's tile may hold transparent art, which the overlap would show. + assert!(!takes_seam(&piece(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, 1.0, BlendMode::Normal))); +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 0007f22049..48065d62c6 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -983,14 +983,17 @@ fn revolve_faces( // that faces the viewer before the rotation, and at most the angle that faces the viewer // straight on: 90° with the axis on the profile's left, 270° with it on the right. A sweep // with no part facing the viewer has its middle at half the sweep. A full turn is cut at the - // back, half a turn from the middle each way. + // back, at the edge between two steps nearest half a turn from the middle: each step is placed + // by its own middle, within half a turn of the surface's middle, so the steps meet edge to + // edge on either side of the cut. let middle = surface_middle(sweep, o.edge); let full = o.angle >= 360.0; let surface_angle = |j: usize| { let (from, to) = (sweep * j as f64 / steps as f64, sweep * (j + 1) as f64 / steps as f64); + let half = (to - from) / 2.0; let mut start = middle - from; if full { - start = (start + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + start = (start - half + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI + half; } [start, start - (to - from)] }; diff --git a/docs/three-d.md b/docs/three-d.md index 1d43351960..e85986b816 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -319,7 +319,8 @@ public page's example of a label on a bottle describes. - Clicking a thumbnail maps that graphic onto the front of the 3D effect of each selected object (`threeD.mapGraphic`), in one undo step. A graphic larger than the object starts scaled down to fit inside it. - Below the thumbnails the tab lists the graphics mapped onto the first selected object. The selected one shows - its Face (Front or Back; Plane has only a front), X and Y (the offset of its center from the center of the + its Face (Front or Back; Plane and Revolve show no Face, as a plane has only a front and Revolve's graphics go + on its side), X and Y (the offset of its center from the center of the object's bounds, in the face's plane), Scale (a percentage of the symbol's size) and Rotation (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 @@ -349,11 +350,13 @@ These rules are VectorCraft's own: viewer). On a compound profile the graphics go on its longest contour. Each face of the side draws the part of the graphic that falls on it, shaded as the face is shaded, so the graphic narrows toward the silhouette and is not drawn where the side's outside faces away or past the ends of the sweep; nearer - faces drawn after it cover it. A full turn cuts the graphic at the back, and the end caps take no graphic; a part + faces drawn after it cover it. A full turn cuts the graphic at the back, on the edge between two segments nearest + half a turn from the middle, and the end caps take no graphic; a part that runs past the end of the profile reaches the curved rim of the cap and stops there. On a group or layer the graphics go once, on the side of the member with the longest profile. Curves are cut into straight pieces within 0.05 pt before they are mapped, and an opaque part takes a 0.3 pt stroke of - its paint over the seams between faces. A graphic larger than the half of the side seen from the front starts + its paint over the seams between faces; a part filled with a pattern, or whose gradient has a transparent stop + or point, takes none. A graphic larger than the half of the side seen from the front starts scaled to fit it. The panel shows no handle for a Revolve's graphics; X and Y move them. Expand Appearance keeps every face of a surface with graphics. - The symbol's fills and strokes are drawn as filled areas, a stroke as its outline, and their gradients and @@ -365,7 +368,9 @@ These rules are VectorCraft's own: cut into 8 cubic pieces before it is mapped. The part of a graphic at or behind the eye is left out. - An object takes up to 64 graphics. One graphic draws up to 4,096 fills and strokes, and the graphics of one 3D effect draw up to 400,000 path elements together (on Revolve, 400,000 points after the curves are cut, and up to - 2,000,000 once they are cut at the faces). Commands refuse a graphic past either limit. A loaded file + 2,000,000 once they are cut at the faces, with up to 64,000,000 points cut together, and the copies of their + paints in the parts take up to 256 MiB per object, apart from those of the surfaces). Commands refuse a graphic + past either limit. A loaded file draws the object without a graphic past the first limit, and without all of its graphics past the second. - A hollow Extrude & Bevel has no caps, so its graphics are not drawn until Cap is turned on again, and `threeD.mapGraphic` refuses to map a new one onto it. Changing the type to Inflate drops the graphics, and