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.
This commit is contained in:
dotCooCoo
2026-11-12 13:32:37 -08:00
parent 35a03aeda3
commit 367ec9998e
3 changed files with 155 additions and 28 deletions
+81 -28
View File
@@ -271,16 +271,20 @@ fn pieces_of(doc: &Document, symbol: &Symbol) -> Result<Vec<Piece>, 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<Affine>) -> Option<BezPath> {
/// 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<Affine>, room: usize) -> Result<BezPath, NotMapped> {
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<Affine>) -> Option<Be
at = p;
}
PathEl::QuadTo(c, p) => {
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<Point>) -> 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<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. `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.
@@ -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();
+23
View File
@@ -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.