Files
vectorcraft/crates/three-d/src/visibility.rs
T
dotCooCoo 1517c81262 3D and Materials: wrap graphics around the side of Revolve objects
The Materials tab and threeD.mapGraphic now map graphics onto
Revolve's swept side, unrolled flat: x runs around the axis (the
angle times the profile's distance from the axis at that point) and
y along the profile. A graphic keeps its size at every distance from
the axis, so it reaches farther around where the profile comes nearer
the axis. Each face of the side draws the part that falls on it: a
graphic narrows toward the silhouette, follows the profile along its
length, is shaded with the surface (several lights included) and is
hidden where the side faces away. A part that runs past the end of
the profile reaches the curved rim and stops there. The middle of the
side faces the viewer with the axis on either edge. On a compound
profile or a group the graphics go on the longest contour.

- three-d: with Revolve::map_surface set, side faces carry a
  SurfaceCell (corners on the page and on the unrolled side, whether
  the outside faces the viewer) and a closed profile off the axis
  takes an anchor where its side is cut; without it the faces are as
  before. revolve_surface_size gives the side's size for a new
  graphic's fit.
- effects: Revolve takes `graphics` (front only); surface_parts cuts
  and maps them per face; curves are cut into straight pieces one at
  a time, and commands refuse graphics past the point limits;
  switching between Extrude, Plane and Revolve keeps the front's
  graphics.
- The Materials tab hides Face for Revolve; params text, docs/mcp.md,
  docs/three-d.md and the progress docs describe the change.
- i18n: the complete-message catalogs translate the three new
  refusals in place of the planar-faces refusal they replace, and ja,
  es, zh-hans and zh-hant translate the Materials tab's label.
2026-11-13 05:06:03 -08:00

395 lines
17 KiB
Rust

//! Vector visibility in the evaluator's paint order. Convex projected faces are cut by the
//! nearer faces covering them, irrespective of their winding or which side faces the camera.
//! This preserves interiors visible through open ends and partial sweeps.
use vectorcraft_geom::{PathData, Point, Rect, SubPath};
use crate::{Face, MAX_FACES, ShadeGradient};
#[path = "visibility_parts.rs"]
mod parts;
const GRID: usize = 32;
const MAX_GRID_REFS: usize = 2_000_000;
const MAX_WORK: usize = 16_000_000;
// Curved boundaries may begin with several convex pieces. Their total work and
// anchors still share the global limits used for ordinary projected faces.
const MAX_FRAGMENTS: usize = 1024;
const MAX_OUTPUT_POINTS: usize = 262_144;
/// The visible portions of one source face, with its original shading and paint order.
#[derive(Clone, Debug, PartialEq)]
pub struct VisibleFace {
pub path: PathData,
pub brightness: f64,
pub depth: f64,
pub gradient: Option<ShadeGradient>,
pub illumination: [f64; 3],
pub light_gradient: Option<crate::LightGradient>,
pub rgb_gradient: Option<crate::RgbGradient>,
}
struct Polygon {
points: Vec<Point>,
bounds: Rect,
}
fn bounds(points: &[Point]) -> Rect {
let Some(first) = points.first() else { return Rect::ZERO };
points.iter().fold(Rect::from_points(*first, *first), |b, p| b.union_pt(*p))
}
fn area(points: &[Point]) -> f64 {
let Some(origin) = points.first() else { return 0.0 };
points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).map(|(a, b)| (*a - *origin).cross(*b - *origin)).sum::<f64>() / 2.0
}
fn clean(mut points: Vec<Point>, epsilon: f64) -> Vec<Point> {
points.dedup_by(|a, b| a.distance(*b) <= epsilon);
if points.len() > 1 && points.first().zip(points.last()).is_some_and(|(a, b)| a.distance(*b) <= epsilon) {
points.pop();
}
if points.len() < 3 || area(&points).abs() <= epsilon * epsilon {
return vec![];
}
if area(&points) < 0.0 {
points.reverse();
}
points
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
}
/// Split a convex polygon at an occluder's edge. Boundary vertices belong to both halves,
/// so independently clipped neighbours share precisely the same intersection positions.
fn split(points: &[Point], a: Point, b: Point, epsilon: f64) -> (Vec<Point>, Vec<Point>) {
let edge = b - a;
let mut inside = vec![];
let mut outside = vec![];
for (p, q) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) {
let dp = edge.cross(*p - a);
let dq = edge.cross(*q - a);
if dp >= 0.0 {
inside.push(*p);
}
if dp <= 0.0 {
outside.push(*p);
}
if (dp > 0.0 && dq < 0.0) || (dp < 0.0 && dq > 0.0) {
let intersection = p.lerp(*q, dp / (dp - dq));
inside.push(intersection);
outside.push(intersection);
}
}
(clean(inside, epsilon), clean(outside, epsilon))
}
fn subtract(points: Vec<Point>, cover: &Polygon, epsilon: f64, work: &mut usize) -> Result<Vec<Vec<Point>>, String> {
if !overlaps(bounds(&points), cover.bounds) {
return Ok(vec![points]);
}
// Bounding boxes of neighbouring curved pieces overlap even when the pieces
// only share an edge. Reject separated convex polygons before cutting them,
// so irrelevant covers cannot subdivide a face into hundreds of fragments.
let mut separated = false;
'edges: for (polygon, other) in [(&cover.points, &points), (&points, &cover.points)] {
for (a, b) in polygon.iter().zip(polygon.iter().cycle().skip(1)).take(polygon.len()) {
*work += other.len();
if *work > MAX_WORK {
return Err("Revolve visibility exceeds its geometry work limit".into());
}
if other.iter().all(|p| (*b - *a).cross(*p - *a) <= epsilon * b.distance(*a)) {
separated = true;
break 'edges;
}
}
}
if separated {
return Ok(vec![points]);
}
if cover
.points
.iter()
.zip(cover.points.iter().cycle().skip(1))
.take(cover.points.len())
.all(|(a, b)| points.iter().all(|p| (*b - *a).cross(*p - *a) >= -epsilon * b.distance(*a)))
{
return Ok(vec![]);
}
let mut remaining = points;
let mut visible = vec![];
for (a, b) in cover.points.iter().zip(cover.points.iter().cycle().skip(1)).take(cover.points.len()) {
*work += remaining.len();
if *work > MAX_WORK {
return Err("Revolve visibility exceeds its geometry work limit".into());
}
let (inside, outside) = split(&remaining, *a, *b, epsilon);
if !outside.is_empty() {
visible.push(outside);
}
remaining = inside;
if remaining.is_empty() {
break;
}
}
Ok(visible)
}
struct Index {
cells: Vec<Vec<usize>>,
bounds: Rect,
refs: usize,
}
impl Index {
fn tiles(&self, b: Rect) -> impl Iterator<Item = usize> {
let cell = |v: f64, lo: f64, size: f64| (((v - lo) / size * GRID as f64).floor() as usize).min(GRID - 1);
let x0 = cell(b.x0, self.bounds.x0, self.bounds.width());
let x1 = cell(b.x1, self.bounds.x0, self.bounds.width());
let y0 = cell(b.y0, self.bounds.y0, self.bounds.height());
let y1 = cell(b.y1, self.bounds.y0, self.bounds.height());
(y0..=y1).flat_map(move |y| (x0..=x1).map(move |x| y * GRID + x))
}
fn insert(&mut self, b: Rect, face: usize) -> Result<(), String> {
let tiles: Vec<_> = self.tiles(b).collect();
self.refs += tiles.len();
if self.refs > MAX_GRID_REFS {
return Err("Revolve visibility exceeds its spatial index limit".into());
}
for tile in tiles {
if let Some(cell) = self.cells.get_mut(tile) {
cell.push(face);
}
}
Ok(())
}
}
/// Remove covered portions of opaque faces in back-to-front paint order. This follows the
/// live evaluator's painter visibility, including its approximation for intersecting surfaces.
/// Resource limits return an error; callers can retain the original complete surface safely.
pub fn visible_faces(faces: &[Face]) -> Result<Vec<VisibleFace>, String> {
if faces.len() > MAX_FACES {
return Err("Too many Revolve faces for visibility evaluation".into());
}
let count = |n: usize, len: usize| n.checked_add(len).filter(|&n| n <= MAX_OUTPUT_POINTS).ok_or("Revolve visibility has too many input anchors");
faces.iter().map(|f| f.points.len()).try_fold(0, count)?;
faces.iter().filter_map(|f| f.visibility_pieces.as_ref()).flatten().map(Vec::len).try_fold(0, count)?;
faces.iter().filter_map(|f| f.outline.as_ref()).flat_map(|p| &p.subpaths).map(|p| p.anchors.len()).try_fold(0, count)?;
if faces.iter().any(|f| {
f.points.len() > 1026
|| !f.brightness.is_finite()
|| f.illumination.iter().any(|v| !v.is_finite())
|| f.light_gradient.is_some_and(|g| !g.is_finite())
|| f.rgb_gradient.is_some_and(|g| !g.is_finite())
|| !f.depth.is_finite()
|| f.gradient.is_some_and(|g| [g.start.x, g.start.y, g.end.x, g.end.y, g.brightness[0], g.brightness[1]].iter().any(|v| !v.is_finite()))
|| f.points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8)
|| f.visibility_pieces.as_ref().is_some_and(|pieces| {
pieces.iter().any(|p| p.len() > 1026 || p.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8))
})
|| f.outline.as_ref().is_some_and(|p| {
p.subpaths
.iter()
.flat_map(|s| &s.anchors)
.any(|a| [a.p, a.h_in, a.h_out].iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8))
})
}) {
return Err("Revolve visibility requires finite projected faces".into());
}
let all = faces.iter().flat_map(|f| &f.points).copied().collect::<Vec<_>>();
let total = bounds(&all);
if total.width() <= 0.0 || total.height() <= 0.0 {
return Ok(vec![]);
}
let epsilon = (total.width().max(total.height()) * 1e-10).clamp(1e-8, 1e-3);
let mut work = 0;
let polygons: Vec<Vec<Polygon>> = faces
.iter()
.map(|f| Ok(parts::pieces(f, epsilon, &mut work)?.into_iter().map(|points| Polygon { bounds: bounds(&points), points }).collect()))
.collect::<Result<_, String>>()?;
let covers: Vec<_> = polygons.iter().flatten().collect();
let mut index = Index { cells: vec![vec![]; GRID * GRID], bounds: total, refs: 0 };
let mut seen = vec![usize::MAX; covers.len()];
let (mut out, mut output_points) = (vec![], 0);
let mut cover_end = covers.len();
for (source, (face, pieces)) in faces.iter().zip(&polygons).enumerate().rev() {
let cover_start = cover_end.saturating_sub(pieces.len());
let source_covers = cover_start..cover_end;
cover_end = cover_start;
if pieces.is_empty() {
continue;
}
let source_bounds = bounds(&face.points);
let mut fragments: Vec<_> = pieces.iter().map(|p| p.points.clone()).collect();
let mut clipped = false;
'covers: for tile in index.tiles(source_bounds) {
for &candidate in index.cells.get(tile).into_iter().flatten() {
work += 1;
if work > MAX_WORK {
return Err("Revolve visibility exceeds its geometry work limit".into());
}
if let Some(mark) = seen.get_mut(candidate)
&& *mark != source
{
*mark = source;
if let Some(cover) = covers.get(candidate)
&& overlaps(source_bounds, cover.bounds)
{
let mut remaining = vec![];
for fragment in &fragments {
remaining.extend(subtract(fragment.clone(), cover, epsilon, &mut work)?);
if remaining.len() > MAX_FRAGMENTS {
return Err("Revolve visibility creates too many fragments".into());
}
}
clipped |= remaining != fragments;
fragments = remaining;
if fragments.is_empty() {
break 'covers;
}
}
}
}
}
if fragments.is_empty() {
continue;
}
output_points += fragments.iter().map(Vec::len).sum::<usize>();
if output_points > MAX_OUTPUT_POINTS {
return Err("Revolve visibility creates too many vector anchors".into());
}
let path = if !clipped { face.path() } else { PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect()) };
out.push(VisibleFace {
path,
brightness: face.brightness,
depth: face.depth,
gradient: face.gradient,
illumination: face.illumination,
light_gradient: face.light_gradient,
rgb_gradient: face.rgb_gradient,
});
// A completely covered face need not occupy the grid. Its coverage already belongs
// to nearer faces; this keeps repeated overlapping surfaces bounded in practice.
for candidate in source_covers {
if let Some(cover) = covers.get(candidate) {
index.insert(cover.bounds, candidate)?;
}
}
}
out.reverse();
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edge, Revolve, revolve};
use vectorcraft_geom::{BezPath, Shape};
fn face(rect: Rect, brightness: f64) -> Face {
Face {
points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)],
depth: brightness,
normal: [0.0, 0.0, 1.0],
brightness,
gradient: None,
illumination: [brightness; 3],
light_gradient: None,
rgb_gradient: None,
outline: None,
cap: false,
visibility_pieces: None,
surface: None,
}
}
#[test]
fn covered_faces_disappear_and_partial_faces_are_trimmed_in_paint_order() {
let faces =
vec![face(Rect::new(2.0, 2.0, 8.0, 8.0), 0.1), face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.2), face(Rect::new(5.0, 0.0, 15.0, 10.0), 0.8)];
let visible = visible_faces(&faces).unwrap();
assert_eq!(visible.len(), 2);
assert_eq!(visible[0].brightness, 0.2);
assert_eq!(visible[0].path.bounds(), Some(Rect::new(0.0, 0.0, 5.0, 10.0)));
assert!((visible[0].path.to_bezpath().area() - 50.0).abs() < 1e-8);
assert!((visible[1].path.to_bezpath().area() - 100.0).abs() < 1e-8);
// Winding and equal depth do not determine visibility; actual paint order does.
let mut flipped = faces.clone();
for f in &mut flipped {
f.points.reverse();
f.depth = 0.0;
}
let other = visible_faces(&flipped).unwrap();
assert_eq!(visible.iter().map(|f| f.brightness).collect::<Vec<_>>(), other.iter().map(|f| f.brightness).collect::<Vec<_>>());
assert_eq!(visible[0].path.bounds(), other[0].path.bounds());
}
#[test]
fn an_interior_surface_remains_visible_through_an_opening() {
let faces = vec![
face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.1),
face(Rect::new(0.0, 0.0, 10.0, 3.0), 0.2),
face(Rect::new(0.0, 7.0, 10.0, 10.0), 0.3),
face(Rect::new(0.0, 3.0, 3.0, 7.0), 0.4),
face(Rect::new(7.0, 3.0, 10.0, 7.0), 0.5),
];
let visible = visible_faces(&faces).unwrap();
assert_eq!(visible[0].brightness, 0.1);
assert_eq!(visible[0].path.bounds(), Some(Rect::new(3.0, 3.0, 7.0, 7.0)));
assert_eq!(visible[0].path.to_bezpath().winding(Point::new(5.0, 5.0)), 1);
assert_eq!(visible[0].path.to_bezpath().winding(Point::new(1.0, 1.0)), 0);
}
#[test]
fn revolved_open_profiles_match_visible_paint_for_both_axis_sides_and_perspective() {
let mut bp = BezPath::new();
bp.move_to((40.0, 10.0));
bp.curve_to((75.0, 20.0), (75.0, 80.0), (40.0, 90.0));
let path = PathData::from_bezpath(&bp);
for edge in [Edge::Left, Edge::Right] {
for angle in [155.0, 360.0] {
let options = Revolve {
edge,
angle,
offset: 8.0,
segments: 32,
rotation_x: -32.0,
rotation_y: 24.0,
rotation_z: 19.0,
perspective: crate::perspective_from_strength(0.7),
..Default::default()
};
let faces = revolve(&path, path.bounds().unwrap(), options).unwrap();
let visible = visible_faces(&faces).unwrap();
assert!(visible.len() <= faces.len());
let all: Vec<_> = faces.iter().map(|f| (f.path().to_bezpath(), f.brightness)).collect();
let cut: Vec<_> = visible.iter().map(|f| (f.path.to_bezpath(), f.brightness)).collect();
let b = bounds(&faces.iter().flat_map(|f| &f.points).copied().collect::<Vec<_>>());
for x in 0..48 {
for y in 0..48 {
let p = Point::new(b.x0 + b.width() * (x as f64 + 0.337) / 48.0, b.y0 + b.height() * (y as f64 + 0.413) / 48.0);
let expected = all.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value);
let actual = cut.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value);
assert_eq!(actual, expected, "{edge:?}, {angle}, {p:?}");
assert!(cut.iter().filter(|(bp, _)| bp.winding(p) != 0).count() <= 1, "expanded surfaces do not cover hidden regions");
}
}
}
}
}
#[test]
fn visibility_is_bounded_for_repeated_faces_and_rejects_invalid_input() {
let faces = vec![face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.5); MAX_FACES];
assert_eq!(visible_faces(&faces).unwrap().len(), 1);
assert!(visible_faces(&vec![faces[0].clone(); MAX_FACES + 1]).is_err());
let mut bad = faces[0].clone();
bad.points[0].x = f64::NAN;
assert!(visible_faces(&[bad]).is_err());
assert!(visible_faces(&[]).unwrap().is_empty());
}
}