mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 21:17:48 +00:00
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.
This commit is contained in:
@@ -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<f64>, 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<Node> {
|
||||
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<Node> {
|
||||
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<Node> {
|
||||
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.
|
||||
|
||||
@@ -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<Point>]) -> 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<Vec<Point>> = (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::<Vec<Point>>();
|
||||
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<bool> {
|
||||
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:?}");
|
||||
}
|
||||
|
||||
@@ -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<Vec<Point>>,
|
||||
/// The bounds of each of `polygons`.
|
||||
boxes: Vec<Rect>,
|
||||
bounds: Rect,
|
||||
rule: FillRule,
|
||||
paint: Paint,
|
||||
@@ -543,8 +549,19 @@ fn unrolled(doc: &Document, graphics: &[MappedGraphic]) -> Option<Vec<Unrolled>>
|
||||
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<Rect> =
|
||||
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<Vec<Unrolled>>
|
||||
/// 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<Vec<Vec<Point>>> {
|
||||
let near = |p: &Point| p.x.abs() <= SURFACE_REACH && p.y.abs() <= SURFACE_REACH;
|
||||
let mut polygons: Vec<Vec<Point>> = vec![];
|
||||
@@ -565,6 +582,7 @@ fn straight_pieces(path: &BezPath, points: &mut usize) -> Option<Vec<Vec<Point>>
|
||||
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<Vec<Vec<Arc<Node>>>> {
|
||||
let mut out: Vec<Vec<Arc<Node>>> = 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.
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
};
|
||||
|
||||
+9
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user