From 367ec9998e0ab8f3751547614af01f39ccbe5820 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 13:32:37 -0800 Subject: [PATCH] 3D and Materials: graphic curves whose control points pass behind the eye still draw Under a perspective, a piece of a graphic's curve that lies in front of the eye while one of its control points does not is halved until its control points map, instead of dropping the piece. A curve that dips behind the eye between the ends of its pieces is still clipped. The extra pieces count toward the effect's element limit, and a path that passes the limit is not sent to the clipping. --- crates/effects/src/solids/graphics.rs | 109 +++++++++++++++----- crates/effects/src/solids/tests_graphics.rs | 51 +++++++++ crates/three-d/src/solid/face_map.rs | 23 +++++ 3 files changed, 155 insertions(+), 28 deletions(-) diff --git a/crates/effects/src/solids/graphics.rs b/crates/effects/src/solids/graphics.rs index 6eab0111f6..1ad6079dca 100644 --- a/crates/effects/src/solids/graphics.rs +++ b/crates/effects/src/solids/graphics.rs @@ -271,16 +271,20 @@ fn pieces_of(doc: &Document, symbol: &Symbol) -> Result, String> { /// `path` through `map`: exactly for an affine map, otherwise with each curve cut into /// [`PERSPECTIVE_PIECES`] pieces whose control points go through it (straight lines stay straight -/// under a perspective). `None` when part of it has no page point. -fn map_path(path: &PathData, map: &FaceMap, affine: Option) -> Option { +/// under a perspective). Fails when part of it has no page point, or when it takes more than `room` +/// elements. +fn map_path(path: &PathData, map: &FaceMap, affine: Option, room: usize) -> Result { let bp = path.to_bezpath(); if let Some(a) = affine { - return Some(a * bp); + return if bp.elements().len() <= room { Ok(a * bp) } else { Err(NotMapped::Full) }; } - let to = |p: Point| map.to_page(p); + let to = |p: Point| map.to_page(p).ok_or(NotMapped::Behind); let mut out = BezPath::new(); let mut at = Point::ZERO; for el in bp.elements() { + if out.elements().len() > room { + return Err(NotMapped::Full); + } match *el { PathEl::MoveTo(p) => { out.move_to(to(p)?); @@ -291,24 +295,58 @@ fn map_path(path: &PathData, map: &FaceMap, affine: Option) -> Option { - cubic_to(&mut out, vectorcraft_geom::kurbo::QuadBez::new(at, c, p).raise(), &to)?; + cubic_to(&mut out, vectorcraft_geom::kurbo::QuadBez::new(at, c, p).raise(), map).ok_or(NotMapped::Behind)?; at = p; } PathEl::CurveTo(c1, c2, p) => { - cubic_to(&mut out, CubicBez::new(at, c1, c2, p), &to)?; + cubic_to(&mut out, CubicBez::new(at, c1, c2, p), map).ok_or(NotMapped::Behind)?; at = p; } PathEl::ClosePath => out.close_path(), } } - Some(out) + if out.elements().len() <= room { Ok(out) } else { Err(NotMapped::Full) } } -fn cubic_to(out: &mut BezPath, c: CubicBez, to: &dyn Fn(Point) -> Option) -> Option<()> { +/// Why [`map_path`] drew nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NotMapped { + /// Part of the path has no page point: it is at or behind the eye. + Behind, + /// The path takes more elements than it has room for. + Full, +} + +/// Curve `c` through `map`, cut into [`PERSPECTIVE_PIECES`] pieces. A piece that lies in front of +/// the eye while a control point does not is split further ([`piece_to`]). `None` when part of a +/// piece has no page point. +fn cubic_to(out: &mut BezPath, c: CubicBez, map: &FaceMap) -> Option<()> { for k in 0..PERSPECTIVE_PIECES { let (t0, t1) = (k as f64 / PERSPECTIVE_PIECES as f64, (k + 1) as f64 / PERSPECTIVE_PIECES as f64); let s = c.subsegment(t0..t1); - out.curve_to(to(s.p1)?, to(s.p2)?, to(s.p3)?); + if !map.maps_curve([s.p0, s.p1, s.p2, s.p3]) { + return None; + } + piece_to(out, s, map, MAX_SPLITS)?; + } + Some(()) +} + +/// How many times [`piece_to`] halves a piece whose control points are behind the eye. +const MAX_SPLITS: usize = 4; + +/// Piece `s`, which lies in front of the eye, through `map`: halved until the control points of +/// each half have page points, at most `splits` times, and drawn straight past that. +fn piece_to(out: &mut BezPath, s: CubicBez, map: &FaceMap, splits: usize) -> Option<()> { + let end = map.to_page(s.p3)?; + match (map.to_page(s.p1), map.to_page(s.p2)) { + (Some(c1), Some(c2)) => out.curve_to(c1, c2, end), + _ if splits == 0 => out.line_to(end), + _ => { + let (a, b) = s.subdivide(); + piece_to(out, a, map, splits - 1)?; + piece_to(out, b, map, splits - 1)?; + } } Some(()) } @@ -316,7 +354,8 @@ fn cubic_to(out: &mut BezPath, c: CubicBez, to: &dyn Fn(Point) -> Option) /// 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. `elements` counts the path elements +/// Pattern tiles keep their colors: the document's pattern draws them. 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`]. @@ -354,29 +393,43 @@ pub(super) fn face_art( for piece in pieces { let placed = piece.path.transformed(place); let added: usize = placed.subpaths.iter().map(|s| s.anchors.len() + 1).sum(); + let before = *elements; *elements = elements.saturating_add(added.saturating_mul(cuts)); if *elements > 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 = map_path(&placed, map, affine).or_else(|| { - // Part of it is at or behind the eye: the part in front is drawn. - let part = map.mapped_part(placed.bounds()?)?; - let mut front = BezPath::new(); - front.move_to(*part.first()?); - part.iter().skip(1).for_each(|p| front.line_to(*p)); - front.close_path(); - let cut = vectorcraft_pathops::boolean( - &placed, - piece.rule, - &PathData::from_bezpath(&front), - FillRule::NonZero, - vectorcraft_pathops::BoolOp::Intersect, - ); - rule = FillRule::NonZero; - map_path(&cut, map, affine) - }); - let Some(mapped) = mapped else { continue }; + let mapped = match map_path(&placed, map, affine, room) { + Err(NotMapped::Behind) => { + // Part of it is at or behind the eye: the part in front is drawn. + let Some(part) = placed.bounds().and_then(|b| map.mapped_part(b)) else { continue }; + let Some(&first) = part.first() else { continue }; + let mut front = BezPath::new(); + front.move_to(first); + part.iter().skip(1).for_each(|p| front.line_to(*p)); + front.close_path(); + let cut = vectorcraft_pathops::boolean( + &placed, + piece.rule, + &PathData::from_bezpath(&front), + FillRule::NonZero, + vectorcraft_pathops::BoolOp::Intersect, + ); + rule = FillRule::NonZero; + map_path(&cut, map, affine, room) + } + done => done, + }; + let mapped = match mapped { + Ok(mapped) => mapped, + Err(NotMapped::Full) => return None, + Err(NotMapped::Behind) => continue, + }; + *elements = (*elements).max(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. diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index fa1c11c706..160f57b16f 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -292,6 +292,57 @@ fn a_symbols_pattern_follows_the_graphic() { assert!((xf * Point::new(10.0, 10.0)).distance(Point::new(70.0, 70.0)) < 1e-6, "{xf:?}"); } +#[test] +fn a_curve_in_front_of_the_eye_maps_though_its_control_points_do_not() { + let bounds = Rect::new(10.0, 20.0, 70.0, 60.0); + let view = vectorcraft_three_d::View { rotation_x: -89.0, rotation_y: 40.0, rotation_z: 10.0, perspective: 160.0, ..Default::default() }; + let map = vectorcraft_three_d::plane_face_map(bounds, view).unwrap(); + // Where the page map stops along the line x = `x`, between the center and a point it does not map. + let edge = |x: f64| { + let inside = Point::new(x, bounds.center().y); + let far = [Point::new(x, -1e6), Point::new(x, 1e6)].into_iter().find(|p| map.to_page(*p).is_none()).unwrap(); + let (mut a, mut b) = (inside, far); + for _ in 0..200 { + let m = a.midpoint(b); + if map.to_page(m).is_some() { a = m } else { b = m } + } + a + }; + let (b1, b2) = (edge(40.0), edge(60.0)); + let u = (b2 - b1).normalize(); + let mut n = vectorcraft_geom::Vec2::new(-u.y, u.x); + if map.to_page(b1 + n * 10.0).is_none() { + n = -n; + } + // Ends 219.17 in front of that line and control points 100 and 42 past it: the curve stays + // about 1 in front, while a control point of one of the pieces it is cut into falls behind. + let at = |s: f64, t: f64| b1 + n * s + u * t; + let mut curve = BezPath::new(); + curve.move_to(at(219.17, 0.0)); + curve.curve_to(at(-100.0, 5.0), at(-42.0, 15.0), at(219.17, 20.0)); + let c = CubicBez::new(at(219.17, 0.0), at(-100.0, 5.0), at(-42.0, 15.0), at(219.17, 20.0)); + assert!((0..=1000).all(|k| map.to_page(c.eval(k as f64 / 1000.0)).is_some()), "the curve itself is in front"); + let pieces = (0..PERSPECTIVE_PIECES).map(|k| c.subsegment(k as f64 / 8.0..(k + 1) as f64 / 8.0)); + assert!(pieces.flat_map(|s| [s.p1, s.p2]).any(|p| map.to_page(p).is_none()), "a piece's control point is behind"); + let mapped = map_path(&PathData::from_bezpath(&curve), &map, None, usize::MAX).expect("the curve maps"); + // The split pieces count toward the room the path is given: without room, the path is full, + // not behind the eye, so it is not clipped. + assert!(mapped.elements().len() > 1 + PERSPECTIVE_PIECES); + assert_eq!(map_path(&PathData::from_bezpath(&curve), &map, None, PERSPECTIVE_PIECES + 1).err(), Some(NotMapped::Full)); + assert!(mapped.elements().iter().all(|e| e.end_point().is_none_or(|p| p.x.is_finite() && p.y.is_finite()))); + // That piece is split until its halves' control points map, so it stays a curve. + assert!(mapped.elements().iter().all(|e| !matches!(e, PathEl::LineTo(_))), "{mapped:?}"); + // With its ends at 214.25 instead, the curve dips about 0.24 behind the line between the ends + // of two pieces, which still map: it has no page point there, and is left to the clipping. + let c = CubicBez::new(at(214.25, 0.0), at(-100.0, 5.0), at(-42.0, 15.0), at(214.25, 20.0)); + assert!((0..=PERSPECTIVE_PIECES).all(|k| map.to_page(c.eval(k as f64 / 8.0)).is_some())); + assert!((0..=1000).any(|k| map.to_page(c.eval(k as f64 / 1000.0)).is_none())); + let mut dips = BezPath::new(); + dips.move_to(c.p0); + dips.curve_to(c.p1, c.p2, c.p3); + assert_eq!(map_path(&PathData::from_bezpath(&dips), &map, None, usize::MAX).err(), Some(NotMapped::Behind)); +} + #[test] fn members_with_their_own_blending_get_graphics_of_their_own() { let d = document(); diff --git a/crates/three-d/src/solid/face_map.rs b/crates/three-d/src/solid/face_map.rs index e3fde7478a..d84dcc5ee6 100644 --- a/crates/three-d/src/solid/face_map.rs +++ b/crates/three-d/src/solid/face_map.rs @@ -76,6 +76,29 @@ impl FaceMap { (p.x.is_finite() && p.y.is_finite() && self.homogeneous(p)[2] > self.nearest()).then_some(p) } + /// Does every point of the cubic Bézier curve with control points `c` lie far enough in front + /// of the eye for [`FaceMap::to_page`] to map it? Its control points need not. + pub fn maps_curve(&self, c: [Point; 4]) -> bool { + let [w0, w1, w2, w3] = c.map(|p| self.homogeneous(p)[2]); + let depth = |t: f64| { + let s = 1.0 - t; + s * s * s * w0 + 3.0 * s * s * t * w1 + 3.0 * s * t * t * w2 + t * t * t * w3 + }; + // The depth is least at an end or where its derivative, a t² + b t + k (times 3), is 0. + let (a, b, k) = (w3 - 3.0 * w2 + 3.0 * w1 - w0, 2.0 * (w2 - 2.0 * w1 + w0), w1 - w0); + // A missing root is NaN, which the range check leaves out. + let mut at = [0.0, 1.0, f64::NAN, f64::NAN]; + if a.abs() > 1e-12 { + let root = (b * b - 4.0 * a * k).sqrt(); + at[2] = (-b + root) / (2.0 * a); + at[3] = (-b - root) / (2.0 * a); + } else if b.abs() > 1e-12 { + at[2] = -k / b; + } + let limit = self.nearest(); + at.into_iter().filter(|t| (0.0..=1.0).contains(t)).all(|t| depth(t) > limit) + } + /// The part of rectangle `r` of the face's plane whose points [`FaceMap::to_page`] maps, kept /// back from the eye by twice its nearest distance, as a convex polygon: all of `r` without /// perspective. `None` when no part of `r` maps.