Merge pull request #1864 from storytold/fix/audit-render-effects
Fix #1706, #1705, #1724, #1723, #1683: shadow raster budget, Transform copies, 3D fill rule, pattern tiles, template Dim Images
This commit is contained in:
commit
886b33c584
|
|
@ -108,6 +108,10 @@ const MAX_SCALE: f64 = 100_000.0;
|
||||||
const MAX_MOVE: f64 = 1.0e7;
|
const MAX_MOVE: f64 = 1.0e7;
|
||||||
/// The Transform effect's copies stop before one reaches further than this (pt) from the origin.
|
/// The Transform effect's copies stop before one reaches further than this (pt) from the origin.
|
||||||
const MAX_REACH: f64 = 1.0e9;
|
const MAX_REACH: f64 = 1.0e9;
|
||||||
|
/// Most anchors a Transform effect's copies make of a path, the original included: 1000 copies of
|
||||||
|
/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make
|
||||||
|
/// a thousand million of one square), so the limit is on the result, not on each effect's count.
|
||||||
|
pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000;
|
||||||
|
|
||||||
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
|
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
|
||||||
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
|
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
|
||||||
|
|
@ -115,6 +119,11 @@ const MAX_REACH: f64 = 1.0e9;
|
||||||
/// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result
|
/// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result
|
||||||
/// on every redraw while each object varies its own way.
|
/// on every redraw while each object varies its own way.
|
||||||
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||||
|
transform_within(path, b, p, seed, MAX_COPY_ANCHORS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`transform`] whose copies make at most `max` anchors of the path, the original included.
|
||||||
|
pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData {
|
||||||
let random = flag(p, "random", false);
|
let random = flag(p, "random", false);
|
||||||
// The share (0..1) of value `k` applied.
|
// The share (0..1) of value `k` applied.
|
||||||
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
|
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
|
||||||
|
|
@ -132,9 +141,17 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||||
return path.transformed(m);
|
return path.transformed(m);
|
||||||
}
|
}
|
||||||
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
|
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
|
||||||
|
// Each copy adds the path once more, and a Transform after this one copies the lot again:
|
||||||
|
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
|
||||||
|
let each = path.anchor_count();
|
||||||
|
let mut anchors = each;
|
||||||
let mut out = path.clone();
|
let mut out = path.clone();
|
||||||
let mut cur = path.clone();
|
let mut cur = path.clone();
|
||||||
for _ in 0..copies {
|
for _ in 0..copies {
|
||||||
|
anchors = anchors.saturating_add(each);
|
||||||
|
if anchors > max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
cur = cur.transformed(m);
|
cur = cur.transformed(m);
|
||||||
if !cur.bounds().is_some_and(within) {
|
if !cur.bounds().is_some_and(within) {
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -113,19 +113,19 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||||
let view = view_options(p);
|
let view = view_options(p);
|
||||||
match id {
|
match id {
|
||||||
EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view),
|
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
|
||||||
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
|
INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view),
|
||||||
_ => vectorcraft_three_d::plane(path, bounds, view),
|
_ => vectorcraft_three_d::plane(path, rule, bounds, view),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
||||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||||
let (path, _) =
|
let (path, rule) =
|
||||||
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
||||||
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||||
if let Some(v) = p.get("bevel")
|
if let Some(v) = p.get("bevel")
|
||||||
|
|
@ -134,7 +134,7 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||||
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
|
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
|
||||||
return Err(format!("bevel must be none or one of {}", names.join(", ")));
|
return Err(format!("bevel must be none or one of {}", names.join(", ")));
|
||||||
}
|
}
|
||||||
facets(id, &path, b, p).map(|_| ())
|
facets(id, &path, rule, b, p).map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
||||||
|
|
@ -185,11 +185,12 @@ fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
|
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
|
||||||
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
|
/// stroke's outline (whose contours nest as its holes do) for a stroke.
|
||||||
|
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) {
|
||||||
match item {
|
match item {
|
||||||
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s),
|
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd),
|
||||||
_ => path.clone(),
|
_ => (path.clone(), rule),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,7 +205,8 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
for item in items {
|
for item in items {
|
||||||
let (paint, opacity, blend) = paint_of(item);
|
let (paint, opacity, blend) = paint_of(item);
|
||||||
let f = facets(ROTATE, ®ion(path, rule, item), b, &p).ok()?;
|
let (area, rule) = region(path, rule, item);
|
||||||
|
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
|
||||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
||||||
}
|
}
|
||||||
return Some(out);
|
return Some(out);
|
||||||
|
|
@ -213,7 +215,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||||
let (paint, opacity, blend) = paint_of(item);
|
let (paint, opacity, blend) = paint_of(item);
|
||||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||||
// failing the frame; commands validate the same evaluator and report its errors.
|
// failing the frame; commands validate the same evaluator and report its errors.
|
||||||
let f = facets(&fx.id, path, b, &p).ok()?;
|
let f = facets(&fx.id, path, rule, b, &p).ok()?;
|
||||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,24 @@ fn random_transform_is_stable_per_object_and_within_the_values() {
|
||||||
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}");
|
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #1705: stacked Transform effects multiply their copies; the anchors they make are bounded,
|
||||||
|
/// whatever each one's count.
|
||||||
|
#[test]
|
||||||
|
fn stacked_transform_copies_stay_within_the_anchor_budget() {
|
||||||
|
let p = json!({"copies": 1000});
|
||||||
|
let b = square().bounds().unwrap();
|
||||||
|
let mut path = square();
|
||||||
|
let mut counts = vec![];
|
||||||
|
for _ in 0..3 {
|
||||||
|
path = crate::distort::transform_within(&path, b, &p, 0, 100);
|
||||||
|
counts.push(path.anchor_count());
|
||||||
|
}
|
||||||
|
assert_eq!(counts, [100, 100, 100], "25 squares, then no room for a copy of them");
|
||||||
|
// Within the budget every copy is made, and the effect's own budget takes a thousand copies.
|
||||||
|
assert_eq!(crate::distort::transform_within(&square(), b, &json!({"copies": 24}), 0, 100).subpaths.len(), 25);
|
||||||
|
assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transform_copies_stop_before_they_overflow() {
|
fn transform_copies_stop_before_they_overflow() {
|
||||||
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
|
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,29 @@ fn linear_key(view: Affine) -> [u64; 4] {
|
||||||
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The raster a shadow or glow blurred by `blur` device pixels is filtered in, for content
|
||||||
|
/// reaching `reach` (device pixels) in a view of `screen` pixels: (left, top, width, height,
|
||||||
|
/// scale). It covers what of the reach can show in the view, the blur's spread around it
|
||||||
|
/// included, at `scale` raster pixels a device pixel: 1, or less to stay within
|
||||||
|
/// [`PIXEL_BUDGET`]. At a high zoom the spread of a wide blur is tens of thousands of pixels a
|
||||||
|
/// side, whatever the size of the view (#1706). `None`: nothing to draw.
|
||||||
|
fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64, u16, u16, f64)> {
|
||||||
|
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
|
||||||
|
let spread = blur.max(0.0) * 1.5 + 2.0;
|
||||||
|
let screen = Rect::new(0.0, 0.0, f64::from(screen.0), f64::from(screen.1)).inflate(spread, spread);
|
||||||
|
let r = reach.inflate(spread, spread).intersect(screen);
|
||||||
|
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||||
|
let (rw, rh) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
|
||||||
|
// (Also false for a reach or a blur that isn't a number.)
|
||||||
|
if !(rw >= 1.0 && rh >= 1.0 && rw.is_finite() && rh.is_finite()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let side = f64::from(u16::MAX);
|
||||||
|
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||||
|
let (w, h) = ((rw * k).floor().clamp(1.0, side), (rh * k).floor().clamp(1.0, side));
|
||||||
|
Some((x0, y0, w as u16, h as u16, k))
|
||||||
|
}
|
||||||
|
|
||||||
/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel.
|
/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel.
|
||||||
fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, (x, y): (f64, f64), k: f64) {
|
fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, (x, y): (f64, f64), k: f64) {
|
||||||
let (w, h) = (image.width() as f64, image.height() as f64);
|
let (w, h) = (image.width() as f64, image.height() as f64);
|
||||||
|
|
@ -509,7 +532,8 @@ impl Drawn {
|
||||||
/// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show.
|
/// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub(crate) struct Reads {
|
pub(crate) struct Reads {
|
||||||
/// Pattern paints: the pattern definitions.
|
/// Pattern paints: the pattern definitions. Their art can hold anything, so content painted
|
||||||
|
/// with a pattern reads everything else too.
|
||||||
patterns: bool,
|
patterns: bool,
|
||||||
/// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold
|
/// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold
|
||||||
/// anything, so content that shows a symbol reads everything else too.
|
/// anything, so content that shows a symbol reads everything else too.
|
||||||
|
|
@ -527,14 +551,25 @@ pub(crate) struct Reads {
|
||||||
impl Reads {
|
impl Reads {
|
||||||
/// What the content of `n` reads.
|
/// What the content of `n` reads.
|
||||||
pub(crate) fn of(n: &Node) -> Self {
|
pub(crate) fn of(n: &Node) -> Self {
|
||||||
|
Self::of_all(std::iter::once(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the content of `nodes` reads together. A pattern's art can hold anything, as a
|
||||||
|
/// symbol's can, so content painted with a pattern reads everything else too (#1723).
|
||||||
|
pub(crate) fn of_all<'a>(nodes: impl Iterator<Item = &'a Node>) -> Self {
|
||||||
let mut reads = Self::default();
|
let mut reads = Self::default();
|
||||||
reads.add(n);
|
nodes.for_each(|n| reads.add(n));
|
||||||
if reads.symbols {
|
if reads.symbols || reads.patterns {
|
||||||
return Self { patterns: true, symbols: true, images: true, brushes: true, text: true };
|
return Self { patterns: true, symbols: true, images: true, brushes: true, text: true };
|
||||||
}
|
}
|
||||||
reads
|
reads
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does the content read anything outside its own nodes?
|
||||||
|
pub(crate) fn any(self) -> bool {
|
||||||
|
self != Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
fn add(&mut self, n: &Node) {
|
fn add(&mut self, n: &Node) {
|
||||||
let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. });
|
let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. });
|
||||||
n.walk(&mut |c| {
|
n.walk(&mut |c| {
|
||||||
|
|
@ -1365,18 +1400,9 @@ impl Renderer {
|
||||||
if !f.mt {
|
if !f.mt {
|
||||||
return self.inside_layer(|r| draw(r, ctx, f, composite));
|
return self.inside_layer(|r| draw(r, ctx, f, composite));
|
||||||
}
|
}
|
||||||
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
|
let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return };
|
||||||
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
|
|
||||||
let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread);
|
|
||||||
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen);
|
|
||||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
|
||||||
let (w, h) = ((r.x1.ceil() - x0).min(u16::MAX as f64), (r.y1.ceil() - y0).min(u16::MAX as f64));
|
|
||||||
if w < 1.0 || h < 1.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (w, h) = (w as u16, h as u16);
|
|
||||||
let mut off = f.offscreen_context(w, h);
|
let mut off = f.offscreen_context(w, h);
|
||||||
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
|
let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f };
|
||||||
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
|
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
|
||||||
off.flush();
|
off.flush();
|
||||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||||
|
|
@ -1386,7 +1412,12 @@ impl Renderer {
|
||||||
ctx.set_transform(Affine::IDENTITY);
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
|
ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
|
||||||
}
|
}
|
||||||
crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0));
|
if k < 1.0 {
|
||||||
|
draw_scaled(ctx, std::sync::Arc::new(pm), (x0, y0), k);
|
||||||
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
|
} else {
|
||||||
|
crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0));
|
||||||
|
}
|
||||||
if layered {
|
if layered {
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
}
|
}
|
||||||
|
|
@ -1482,9 +1513,28 @@ mod tests {
|
||||||
use vectorcraft_doc::{Appearance, Document, Effect, Node};
|
use vectorcraft_doc::{Appearance, Document, Effect, Node};
|
||||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||||
|
|
||||||
use super::Drawn;
|
use super::{Drawn, PIXEL_BUDGET, filter_region};
|
||||||
use crate::{RenderOptions, Rendered, Renderer};
|
use crate::{RenderOptions, Rendered, Renderer};
|
||||||
|
|
||||||
|
/// #1706: the raster a shadow or glow is filtered in stays within the pixel budget however
|
||||||
|
/// far the blur spreads at the zoom (it was up to 65535 pixels a side: 16 GB).
|
||||||
|
#[test]
|
||||||
|
fn a_wide_blur_at_a_high_zoom_is_filtered_within_the_pixel_budget() {
|
||||||
|
// A 100 pt square at 3200 % and at 6400 %, blurred by 1000 pt, in a 1600 × 1000 view.
|
||||||
|
for zoom in [32.0, 64.0] {
|
||||||
|
let reach = Rect::new(0.0, 0.0, 100.0 * zoom, 100.0 * zoom);
|
||||||
|
let (x0, y0, w, h, k) = filter_region(reach, (1600, 1000), 1000.0 * zoom).unwrap();
|
||||||
|
assert!(f64::from(w) * f64::from(h) <= PIXEL_BUDGET, "{w} × {h}");
|
||||||
|
assert!(k < 1.0 && x0 < 0.0 && y0 < 0.0);
|
||||||
|
// It still covers the view and the blur's spread around it, at that scale.
|
||||||
|
assert!(f64::from(w) / k >= 1600.0 + 1500.0 * zoom && f64::from(h) / k >= 1000.0 + 1500.0 * zoom);
|
||||||
|
}
|
||||||
|
// A small blur is filtered at full size, cropped to what can show.
|
||||||
|
assert_eq!(filter_region(Rect::new(10.0, 20.0, 110.0, 70.0), (1600, 1000), 4.0), Some((2.0, 12.0, 116, 66, 1.0)));
|
||||||
|
assert_eq!(filter_region(Rect::new(5000.0, 0.0, 5100.0, 100.0), (1600, 1000), 4.0), None, "off the view");
|
||||||
|
assert_eq!(filter_region(Rect::new(0.0, 0.0, 100.0, 100.0), (1600, 1000), f64::NAN).map(|r| r.4), Some(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
|
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
|
||||||
let mut d = Document::new(100.0, 100.0);
|
let mut d = Document::new(100.0, 100.0);
|
||||||
let id = d.alloc_id();
|
let id = d.alloc_id();
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,8 @@ pub struct RenderOptions {
|
||||||
pub artboards: bool,
|
pub artboards: bool,
|
||||||
/// Objects not to draw (e.g. the object being edited by a live drag preview).
|
/// Objects not to draw (e.g. the object being edited by a live drag preview).
|
||||||
pub hidden: Vec<NodeId>,
|
pub hidden: Vec<NodeId>,
|
||||||
/// Draw template layers dimmed (50%).
|
/// Draw the layers as their Layer Options show them on screen: Preview off in outline, Dim
|
||||||
|
/// Images fading their images (a template layer's too), a template in Preview in Outline view.
|
||||||
pub dim_templates: bool,
|
pub dim_templates: bool,
|
||||||
/// Soft proof / separations preview (see [`proof`]).
|
/// Soft proof / separations preview (see [`proof`]).
|
||||||
pub proof: Option<proof::ProofSetup>,
|
pub proof: Option<proof::ProofSetup>,
|
||||||
|
|
@ -1088,8 +1089,8 @@ impl Renderer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images
|
// Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images
|
||||||
// fades its images (exports and thumbnails, which leave templates out, ignore both).
|
// fades its images, a template's too (exports and thumbnails, which leave templates out,
|
||||||
// (A template is dimmed as a whole already.)
|
// ignore both).
|
||||||
if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind
|
if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind
|
||||||
&& f.opts.dim_templates
|
&& f.opts.dim_templates
|
||||||
&& !f.opts.skip_templates
|
&& !f.opts.skip_templates
|
||||||
|
|
@ -1104,7 +1105,7 @@ impl Renderer {
|
||||||
let opts = RenderOptions { outline: true, ..f.opts.clone() };
|
let opts = RenderOptions { outline: true, ..f.opts.clone() };
|
||||||
return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force);
|
return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force);
|
||||||
}
|
}
|
||||||
if let Some(p) = dim_images.filter(|_| !template) {
|
if let Some(p) = *dim_images {
|
||||||
let outer = self.dim_images;
|
let outer = self.dim_images;
|
||||||
let dim = f32::from(p).clamp(0.0, 100.0) / 100.0;
|
let dim = f32::from(p).clamp(0.0, 100.0) / 100.0;
|
||||||
self.dim_images = Some(outer.map_or(dim, |o| o.min(dim)));
|
self.dim_images = Some(outer.map_or(dim, |o| o.min(dim)));
|
||||||
|
|
@ -1162,8 +1163,7 @@ impl Renderer {
|
||||||
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
|
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
|
||||||
}
|
}
|
||||||
let outline = f.opts.outline;
|
let outline = f.opts.outline;
|
||||||
let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates;
|
let opacity = self.opacity_of(n);
|
||||||
let opacity = if template_dim { self.opacity_of(n) * 0.5 } else { self.opacity_of(n) };
|
|
||||||
// Whether this container's children knock each other out.
|
// Whether this container's children knock each other out.
|
||||||
let knockout = !outline && n.knocks_out(self.knockout);
|
let knockout = !outline && n.knocks_out(self.knockout);
|
||||||
let enclosing = std::mem::replace(&mut self.knockout, knockout);
|
let enclosing = std::mem::replace(&mut self.knockout, knockout);
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,16 @@
|
||||||
//! with the paint transform mapping pixels → pattern space → document. Tiles are cached per
|
//! with the paint transform mapping pixels → pattern space → document. Tiles are cached per
|
||||||
//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within
|
//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within
|
||||||
//! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was
|
//! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was
|
||||||
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision".
|
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and,
|
||||||
|
//! when its art shows more than itself (a symbol, another pattern, an image, type), while what
|
||||||
|
//! the document holds of those is what it held then ([`Deps`]).
|
||||||
//!
|
//!
|
||||||
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
|
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
|
||||||
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
|
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Weak};
|
||||||
|
|
||||||
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
|
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
|
||||||
use vectorcraft_doc::{Document, Node};
|
use vectorcraft_doc::{Document, Node};
|
||||||
|
|
@ -32,8 +34,46 @@ const MAX_TILE_PX: f64 = 2048.0;
|
||||||
/// ([`raster_scale`]).
|
/// ([`raster_scale`]).
|
||||||
type TileKey = (String, u64);
|
type TileKey = (String, u64);
|
||||||
|
|
||||||
|
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
|
||||||
|
/// the art of every pattern and symbol and the pixels of every image in the document, and the
|
||||||
|
/// fonts. Editing a symbol leaves the art of a pattern that shows it as it was, so the art alone
|
||||||
|
/// kept the tile from before (#1723). Held weakly: a weak reference keeps the allocation, so no
|
||||||
|
/// other value gets one of these addresses while the tile is kept.
|
||||||
|
struct Deps {
|
||||||
|
art: Vec<Weak<Node>>,
|
||||||
|
pixels: Vec<Weak<Vec<u8>>>,
|
||||||
|
fonts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deps {
|
||||||
|
fn art(doc: &Document) -> impl Iterator<Item = &Arc<Node>> {
|
||||||
|
doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pixels(doc: &Document) -> impl Iterator<Item = &Arc<Vec<u8>>> {
|
||||||
|
doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn of(doc: &Document) -> Self {
|
||||||
|
Self {
|
||||||
|
art: Self::art(doc).map(Arc::downgrade).collect(),
|
||||||
|
pixels: Self::pixels(doc).map(Arc::downgrade).collect(),
|
||||||
|
fonts: vectorcraft_text::FontDb::global().generation(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does `doc` hold what the tile was drawn from?
|
||||||
|
fn current(&self, doc: &Document) -> bool {
|
||||||
|
self.fonts == vectorcraft_text::FontDb::global().generation()
|
||||||
|
&& self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr))
|
||||||
|
&& self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct TileEntry {
|
struct TileEntry {
|
||||||
art: Vec<Arc<Node>>,
|
art: Vec<Arc<Node>>,
|
||||||
|
/// `None` for art that shows only itself.
|
||||||
|
deps: Option<Deps>,
|
||||||
params: (Rect, TileType, Overlap),
|
params: (Rect, TileType, Overlap),
|
||||||
sx: f64,
|
sx: f64,
|
||||||
sy: f64,
|
sy: f64,
|
||||||
|
|
@ -163,7 +203,10 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||||
let mut c = c.borrow_mut();
|
let mut c = c.borrow_mut();
|
||||||
let tiles = &mut c.tiles[ink as usize];
|
let tiles = &mut c.tiles[ink as usize];
|
||||||
let t = tiles.tick();
|
let t = tiles.tick();
|
||||||
tiles.get(&key, t).filter(|e| e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
tiles
|
||||||
|
.get(&key, t)
|
||||||
|
.filter(|e| e.params == params && same_art(&e.art, &def.art) && e.deps.as_ref().is_none_or(|d| d.current(doc)))
|
||||||
|
.map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||||
});
|
});
|
||||||
let (pm, sx, sy) = match hit {
|
let (pm, sx, sy) = match hit {
|
||||||
Some(h) => h,
|
Some(h) => h,
|
||||||
|
|
@ -176,11 +219,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||||
};
|
};
|
||||||
let pm = Arc::new(pm);
|
let pm = Arc::new(pm);
|
||||||
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
|
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
|
||||||
|
let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc));
|
||||||
CACHE.with(|c| {
|
CACHE.with(|c| {
|
||||||
let mut c = c.borrow_mut();
|
let mut c = c.borrow_mut();
|
||||||
let tiles = &mut c.tiles[ink as usize];
|
let tiles = &mut c.tiles[ink as usize];
|
||||||
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
|
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
|
||||||
tiles.insert(key, TileEntry { art: def.art.clone(), params, sx, sy, pixmap: pm.clone() }, bytes, t);
|
tiles.insert(key, TileEntry { art: def.art.clone(), deps, params, sx, sy, pixmap: pm.clone() }, bytes, t);
|
||||||
});
|
});
|
||||||
(pm, sx, sy)
|
(pm, sx, sy)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,37 @@ fn dim_images_fades_only_the_images() {
|
||||||
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
|
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #1683: a template layer dims its images by its Dim Images percentage and nothing else: with
|
||||||
|
/// the option off they show at full strength, and the layer's paths are never faded.
|
||||||
|
#[test]
|
||||||
|
fn a_template_layer_dims_its_images_by_dim_images() {
|
||||||
|
let template = |dim: Option<u8>| {
|
||||||
|
let d = doc(|k| {
|
||||||
|
if let NodeKind::Layer { template, dim_images, .. } = k {
|
||||||
|
(*template, *dim_images) = (true, dim);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let img = render(&d, false);
|
||||||
|
(img.pixel(65, 25), img.pixel(25, 25))
|
||||||
|
};
|
||||||
|
let red = [255, 0, 0, 255];
|
||||||
|
assert_eq!(template(None), (red, red), "Dim Images off: as it is");
|
||||||
|
let (image, path) = template(Some(50));
|
||||||
|
assert!(image[0] == 255 && (120..=135).contains(&image[1]) && image[1] == image[2], "half way to white: {image:?}");
|
||||||
|
assert_eq!(path, red, "vector art stays");
|
||||||
|
let (image, _) = template(Some(20));
|
||||||
|
assert!((198..=210).contains(&image[1]) && image[1] == image[2], "a fifth of its strength: {image:?}");
|
||||||
|
assert_eq!(template(Some(100)), (red, red));
|
||||||
|
}
|
||||||
|
|
||||||
/// In Outline view a template layer still draws in Preview, dimmed, so its picture can be traced;
|
/// In Outline view a template layer still draws in Preview, dimmed, so its picture can be traced;
|
||||||
/// the layer above it draws in outline (#1486).
|
/// the layer above it draws in outline (#1486).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_template_layer_stays_in_preview_in_outline_view() {
|
fn a_template_layer_stays_in_preview_in_outline_view() {
|
||||||
let mut d = doc(|k| {
|
let mut d = doc(|k| {
|
||||||
if let NodeKind::Layer { template, preview, .. } = k {
|
if let NodeKind::Layer { template, preview, dim_images, .. } = k {
|
||||||
*template = true;
|
// As Layer Options makes a template: its images dimmed to 50 %.
|
||||||
|
(*template, *dim_images) = (true, Some(50));
|
||||||
// Preview off doesn't put a template in outline either.
|
// Preview off doesn't put a template in outline either.
|
||||||
*preview = false;
|
*preview = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -940,6 +940,49 @@ fn rasters_draw_pattern_edits() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #1723: a pattern's tile is drawn again when what its art shows changes while the art itself
|
||||||
|
/// doesn't: a symbol an instance in it shows, or another pattern it is painted with. So is a
|
||||||
|
/// raster effect on an object painted with such a pattern.
|
||||||
|
#[test]
|
||||||
|
fn pattern_tiles_draw_edits_to_what_their_art_shows() {
|
||||||
|
let _fonts = fonts_steady();
|
||||||
|
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
|
||||||
|
use vectorcraft_geom::shapes::rectangle;
|
||||||
|
let tile = |name: &str, art: Node| {
|
||||||
|
let mut def = PatternDef::new(name, vec![Arc::new(art)]);
|
||||||
|
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||||
|
def
|
||||||
|
};
|
||||||
|
// Pattern `Stamps` shows symbol `S`; pattern `Outer` is a square painted with pattern `Checks`.
|
||||||
|
let doc = |pattern: &str, effects: Vec<vectorcraft_doc::Effect>| {
|
||||||
|
let mut d = Document::new(200.0, 100.0);
|
||||||
|
let art = symbol_art(&mut d);
|
||||||
|
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||||
|
let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((10.0, 10.0)) });
|
||||||
|
d.patterns.push(tile("Stamps", shown));
|
||||||
|
d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0)));
|
||||||
|
let square = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), pattern_paint("Checks"), vec![]);
|
||||||
|
d.patterns.push(tile("Outer", square));
|
||||||
|
let painted = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), pattern_paint(pattern), effects);
|
||||||
|
with_art(d, vec![painted])
|
||||||
|
};
|
||||||
|
let new_symbol = |d: &mut Document| {
|
||||||
|
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||||
|
let bar = Node::path(d.alloc_id(), rectangle(Rect::new(-8.0, -2.0, 8.0, 2.0)), red);
|
||||||
|
d.symbols[0].art = Arc::new(bar);
|
||||||
|
};
|
||||||
|
let new_checks = |d: &mut Document| {
|
||||||
|
if let Some(def) = d.pattern_mut("Checks") {
|
||||||
|
def.art = checks(Color::rgb(0.0, 0.0, 1.0)).art;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let blur = || vec![effect("blur.gaussian", serde_json::json!({"radius": 2.0}))];
|
||||||
|
edit_draws_as_new(doc("Stamps", vec![]), new_symbol);
|
||||||
|
edit_draws_as_new(doc("Stamps", blur()), new_symbol);
|
||||||
|
edit_draws_as_new(doc("Outer", vec![]), new_checks);
|
||||||
|
edit_draws_as_new(doc("Outer", blur()), new_checks);
|
||||||
|
}
|
||||||
|
|
||||||
/// Raster effects are made again when a symbol shown inside another symbol changes (a group's drop
|
/// Raster effects are made again when a symbol shown inside another symbol changes (a group's drop
|
||||||
/// shadow around an instance of the other symbol), when a brush definition changes (a brushed
|
/// shadow around an instance of the other symbol), when a brush definition changes (a brushed
|
||||||
/// stroke's drop shadow), and when an image's pixels change under the same key, as when a linked
|
/// stroke's drop shadow), and when an image's pixels change under the same key, as when a linked
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//! Visibility is a painter's algorithm, as for Revolve: faces of a closed solid that face away
|
//! Visibility is a painter's algorithm, as for Revolve: faces of a closed solid that face away
|
||||||
//! from the viewer are left out, the rest are drawn back to front.
|
//! from the viewer are left out, the rest are drawn back to front.
|
||||||
|
|
||||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
|
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect};
|
||||||
|
|
||||||
use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale};
|
use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale};
|
||||||
|
|
||||||
|
|
@ -290,7 +290,11 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
|
||||||
/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves
|
/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves
|
||||||
/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take
|
/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take
|
||||||
/// few points and large ones more; more than `limit` points in all is refused.
|
/// few points and large ones more; more than `limit` points in all is refused.
|
||||||
fn rings(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
///
|
||||||
|
/// Under the nonzero `rule` an outline with filled art on both sides is no edge of the shape and
|
||||||
|
/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would
|
||||||
|
/// if nesting alone decided (#1724).
|
||||||
|
fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||||
let tolerance = (size / 2000.0).max(0.05);
|
let tolerance = (size / 2000.0).max(0.05);
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
|
|
@ -325,6 +329,18 @@ fn rings(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, St
|
||||||
out.push(ring);
|
out.push(ring);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if rule == FillRule::NonZero {
|
||||||
|
let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 };
|
||||||
|
let mut edge = Vec::with_capacity(out.len());
|
||||||
|
for (i, r) in out.iter().enumerate() {
|
||||||
|
let p = r.first().copied().unwrap_or_default();
|
||||||
|
// The winding number just outside the ring, and just inside it.
|
||||||
|
let outside: i32 = out.iter().enumerate().filter(|(j, o)| *j != i && contains(o, p)).map(|(_, o)| turn(o)).sum();
|
||||||
|
edge.push((outside != 0) != (outside + turn(r) != 0));
|
||||||
|
}
|
||||||
|
let mut edge = edge.into_iter();
|
||||||
|
out.retain(|_| edge.next().unwrap_or(true));
|
||||||
|
}
|
||||||
// A ring inside an odd number of others is a hole.
|
// A ring inside an odd number of others is a hole.
|
||||||
let holes: Vec<bool> = out
|
let holes: Vec<bool> = out
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -634,14 +650,14 @@ fn steps(o: &Extrude) -> Vec<Step> {
|
||||||
|
|
||||||
/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels
|
/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels
|
||||||
/// and caps.
|
/// and caps.
|
||||||
pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
|
pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
|
||||||
check_bounds(bounds)?;
|
check_bounds(bounds)?;
|
||||||
let o = options.sanitized();
|
let o = options.sanitized();
|
||||||
// No depth: the flat shape, seen from either side.
|
// No depth: the flat shape, seen from either side.
|
||||||
if o.depth <= 0.0 {
|
if o.depth <= 0.0 {
|
||||||
return plane(path, bounds, view);
|
return plane(path, rule, bounds, view);
|
||||||
}
|
}
|
||||||
let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||||
let camera = Camera::new(view, bounds, o.depth);
|
let camera = Camera::new(view, bounds, o.depth);
|
||||||
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] };
|
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] };
|
||||||
let steps = steps(&o);
|
let steps = steps(&o);
|
||||||
|
|
@ -675,9 +691,9 @@ pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> R
|
||||||
|
|
||||||
/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the
|
/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the
|
||||||
/// shape's own, so that every part of one object turns about the same point).
|
/// shape's own, so that every part of one object turns about the same point).
|
||||||
pub fn plane(path: &PathData, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
|
pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
|
||||||
check_bounds(bounds)?;
|
check_bounds(bounds)?;
|
||||||
let rings = rings(path, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||||
let camera = Camera::new(view, bounds, 0.0);
|
let camera = Camera::new(view, bounds, 0.0);
|
||||||
let mut b = Builder { camera: &camera, closed: false, facets: vec![] };
|
let mut b = Builder { camera: &camera, closed: false, facets: vec![] };
|
||||||
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
|
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
|
||||||
|
|
@ -997,18 +1013,18 @@ fn material(outline: &PathData, cell: Rect) -> Option<Vec<Vec<Point>>> {
|
||||||
&[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)],
|
&[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)],
|
||||||
true,
|
true,
|
||||||
));
|
));
|
||||||
let rule = vectorcraft_geom::FillRule::NonZero;
|
let rule = FillRule::NonZero;
|
||||||
let part =
|
let part =
|
||||||
vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION)
|
vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION)
|
||||||
.ok()?;
|
.ok()?;
|
||||||
rings(&part, cell.width(), MAX_PLANE_POINTS).ok()
|
rings(&part, rule, cell.width(), MAX_PLANE_POINTS).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is.
|
/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is.
|
||||||
pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> Result<Vec<Facet>, String> {
|
pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result<Vec<Facet>, String> {
|
||||||
check_bounds(bounds)?;
|
check_bounds(bounds)?;
|
||||||
let o = options.sanitized();
|
let o = options.sanitized();
|
||||||
let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||||
if rings.is_empty() {
|
if rings.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use vectorcraft_geom::{BezPath, PathData, Point, Rect};
|
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ fn close(a: f64, b: f64, tol: f64) -> bool {
|
||||||
#[test]
|
#[test]
|
||||||
fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() {
|
fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() {
|
||||||
let (p, b) = shape(&square(10.0, 20.0, 100.0));
|
let (p, b) = shape(&square(10.0, 20.0, 100.0));
|
||||||
let f = extrude(&p, b, Extrude::default(), front()).unwrap();
|
let f = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap();
|
||||||
assert_eq!(f.len(), 1, "the walls and back face away: only the front cap shows");
|
assert_eq!(f.len(), 1, "the walls and back face away: only the front cap shows");
|
||||||
assert_eq!(f[0].contours.len(), 1);
|
assert_eq!(f[0].contours.len(), 1);
|
||||||
assert_eq!(extent(&f), Rect::new(10.0, 20.0, 110.0, 120.0));
|
assert_eq!(extent(&f), Rect::new(10.0, 20.0, 110.0, 120.0));
|
||||||
|
|
@ -51,7 +51,7 @@ fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() {
|
||||||
fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() {
|
fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
let o = Extrude { depth: 40.0, ..Extrude::default() };
|
let o = Extrude { depth: 40.0, ..Extrude::default() };
|
||||||
let f = extrude(&p, b, o, View { rotation_y: 30.0, ..front() }).unwrap();
|
let f = extrude(&p, FillRule::EvenOdd, b, o, View { rotation_y: 30.0, ..front() }).unwrap();
|
||||||
// Orthographic: the silhouette is the front's width times cos 30° plus the depth times sin 30°.
|
// Orthographic: the silhouette is the front's width times cos 30° plus the depth times sin 30°.
|
||||||
let w = extent(&f).width();
|
let w = extent(&f).width();
|
||||||
assert!(close(w, 100.0 * 30f64.to_radians().cos() + 40.0 * 30f64.to_radians().sin(), 1e-6), "{w}");
|
assert!(close(w, 100.0 * 30f64.to_radians().cos() + 40.0 * 30f64.to_radians().sin(), 1e-6), "{w}");
|
||||||
|
|
@ -66,10 +66,10 @@ fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() {
|
||||||
fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() {
|
fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
|
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
|
||||||
let flat = extrude(&p, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap();
|
let flat = extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap();
|
||||||
assert_eq!(flat, plane(&p, b, v).unwrap());
|
assert_eq!(flat, plane(&p, FillRule::EvenOdd, b, v).unwrap());
|
||||||
let solid = extrude(&p, b, Extrude::default(), v).unwrap();
|
let solid = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap();
|
||||||
let hollow = extrude(&p, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap();
|
let hollow = extrude(&p, FillRule::EvenOdd, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap();
|
||||||
// Solid: front, right and top faces. Hollow: all four walls (two seen from inside), no caps.
|
// Solid: front, right and top faces. Hollow: all four walls (two seen from inside), no caps.
|
||||||
assert_eq!(solid.len(), 3);
|
assert_eq!(solid.len(), 3);
|
||||||
assert_eq!(hollow.len(), 4);
|
assert_eq!(hollow.len(), 4);
|
||||||
|
|
@ -81,10 +81,10 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
let side = View { rotation_y: 90.0, ..front() };
|
let side = View { rotation_y: 90.0, ..front() };
|
||||||
let o = Extrude { depth: 60.0, cap: false, ..Extrude::default() };
|
let o = Extrude { depth: 60.0, cap: false, ..Extrude::default() };
|
||||||
let straight = extent(&extrude(&p, b, o, side).unwrap());
|
let straight = extent(&extrude(&p, FillRule::EvenOdd, b, o, side).unwrap());
|
||||||
assert!(close(straight.height(), 100.0, 1e-6) && close(straight.width(), 60.0, 1e-6));
|
assert!(close(straight.height(), 100.0, 1e-6) && close(straight.width(), 60.0, 1e-6));
|
||||||
// Seen from the side, a 50 % taper keeps the front's full height and halves the back's.
|
// Seen from the side, a 50 % taper keeps the front's full height and halves the back's.
|
||||||
let tapered = extrude(&p, b, Extrude { taper: 0.5, ..o }, side).unwrap();
|
let tapered = extrude(&p, FillRule::EvenOdd, b, Extrude { taper: 0.5, ..o }, side).unwrap();
|
||||||
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x0, 1e-6)).collect();
|
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x0, 1e-6)).collect();
|
||||||
let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
|
let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
|
||||||
let at_front: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x1, 1e-6)).collect();
|
let at_front: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x1, 1e-6)).collect();
|
||||||
|
|
@ -93,11 +93,11 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||||
assert!(close(front_h, 100.0, 1e-6) && close(back_h, 50.0, 1e-6), "front {front_h}, back {back_h}");
|
assert!(close(front_h, 100.0, 1e-6) && close(back_h, 50.0, 1e-6), "front {front_h}, back {back_h}");
|
||||||
// Seen from behind, a 45° twist shows the back cap turned into a diamond.
|
// Seen from behind, a 45° twist shows the back cap turned into a diamond.
|
||||||
let back = View { rotation_y: 180.0, ..front() };
|
let back = View { rotation_y: 180.0, ..front() };
|
||||||
let twisted = extrude(&p, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap();
|
let twisted = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap();
|
||||||
assert_eq!(twisted.len(), 1, "only the back cap faces the viewer");
|
assert_eq!(twisted.len(), 1, "only the back cap faces the viewer");
|
||||||
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
||||||
// Its walls are cut into slices that follow the turn.
|
// Its walls are cut into slices that follow the turn.
|
||||||
let open = extrude(&p, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
let open = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||||
assert!(open.len() > 2 * 8, "{}", open.len());
|
assert!(open.len() > 2 * 8, "{}", open.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,13 +105,13 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||||
fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() {
|
fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() };
|
let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() };
|
||||||
let inside = extrude(&p, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap();
|
let inside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap();
|
||||||
// Straight on: the cap inset by the width, and the chamfer around it filling the outline.
|
// Straight on: the cap inset by the width, and the chamfer around it filling the outline.
|
||||||
let cap = inside.iter().find(|f| close(signed_area(&f.contours[0]).abs(), 80.0 * 80.0, 1e-6));
|
let cap = inside.iter().find(|f| close(signed_area(&f.contours[0]).abs(), 80.0 * 80.0, 1e-6));
|
||||||
assert!(cap.is_some(), "an 80 × 80 cap");
|
assert!(cap.is_some(), "an 80 × 80 cap");
|
||||||
assert_eq!(extent(&inside), Rect::new(0.0, 0.0, 100.0, 100.0));
|
assert_eq!(extent(&inside), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||||
assert!(close(area(&inside), 100.0 * 100.0, 1e-6));
|
assert!(close(area(&inside), 100.0 * 100.0, 1e-6));
|
||||||
let outside = extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap();
|
let outside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap();
|
||||||
assert_eq!(extent(&outside), Rect::new(-10.0, -10.0, 110.0, 110.0), "the walls stand 10 pt outside the art");
|
assert_eq!(extent(&outside), Rect::new(-10.0, -10.0, 110.0, 110.0), "the walls stand 10 pt outside the art");
|
||||||
assert!(outside.iter().any(|f| close(signed_area(&f.contours[0]).abs(), 100.0 * 100.0, 1e-6)), "the cap is the art");
|
assert!(outside.iter().any(|f| close(signed_area(&f.contours[0]).abs(), 100.0 * 100.0, 1e-6)), "the cap is the art");
|
||||||
// The chamfer's faces lean towards the light differently from the cap.
|
// The chamfer's faces lean towards the light differently from the cap.
|
||||||
|
|
@ -123,12 +123,16 @@ fn bevel_shapes_repeats_and_both_sides_change_the_surface() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
||||||
let base = Bevel { width: 12.0, height: 12.0, ..Bevel::default() };
|
let base = Bevel { width: 12.0, height: 12.0, ..Bevel::default() };
|
||||||
let classic = extrude(&p, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap();
|
let classic = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap();
|
||||||
for shape in BevelShape::ALL.into_iter().skip(1) {
|
for shape in BevelShape::ALL.into_iter().skip(1) {
|
||||||
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(), "{shape:?}");
|
assert_ne!(
|
||||||
|
classic,
|
||||||
|
extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(),
|
||||||
|
"{shape:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for bevel in [Bevel { repeat: 3, ..base }, Bevel { repeat: 3, space: 0.3, ..base }, Bevel { both_sides: true, ..base }] {
|
for bevel in [Bevel { repeat: 3, ..base }, Bevel { repeat: 3, space: 0.3, ..base }, Bevel { both_sides: true, ..base }] {
|
||||||
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}");
|
assert_ne!(classic, extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}");
|
||||||
}
|
}
|
||||||
// The step shape keeps its corners: half the width at half the height.
|
// The step shape keeps its corners: half the width at half the height.
|
||||||
assert_eq!(BevelShape::Step.at(0.5), (0.5, 0.5));
|
assert_eq!(BevelShape::Step.at(0.5), (0.5, 0.5));
|
||||||
|
|
@ -147,11 +151,11 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() {
|
||||||
// The hole drawn the same way round as the outline: even-odd nesting still makes it a hole.
|
// The hole drawn the same way round as the outline: even-odd nesting still makes it a hole.
|
||||||
b.extend(square(30.0, 30.0, 40.0));
|
b.extend(square(30.0, 30.0, 40.0));
|
||||||
let (p, r) = shape(&b);
|
let (p, r) = shape(&b);
|
||||||
let f = extrude(&p, r, Extrude::default(), front()).unwrap();
|
let f = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap();
|
||||||
assert_eq!(f.len(), 1);
|
assert_eq!(f.len(), 1);
|
||||||
assert_eq!(f[0].contours.len(), 2);
|
assert_eq!(f[0].contours.len(), 2);
|
||||||
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-6));
|
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-6));
|
||||||
let turned = extrude(&p, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap();
|
let turned = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap();
|
||||||
// Front cap, the outline's right wall and the hole's left wall (seen through the hole).
|
// Front cap, the outline's right wall and the hole's left wall (seen through the hole).
|
||||||
assert_eq!(turned.len(), 3);
|
assert_eq!(turned.len(), 3);
|
||||||
}
|
}
|
||||||
|
|
@ -159,15 +163,15 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_plane_turns_about_the_given_centre() {
|
fn a_plane_turns_about_the_given_centre() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
let f = plane(&p, b, View { rotation_y: 60.0, ..front() }).unwrap();
|
let f = plane(&p, FillRule::EvenOdd, b, View { rotation_y: 60.0, ..front() }).unwrap();
|
||||||
assert_eq!(f.len(), 1);
|
assert_eq!(f.len(), 1);
|
||||||
let e = extent(&f);
|
let e = extent(&f);
|
||||||
assert!(close(e.width(), 50.0, 1e-6) && close(e.center().x, 50.0, 1e-9));
|
assert!(close(e.width(), 50.0, 1e-6) && close(e.center().x, 50.0, 1e-9));
|
||||||
// Turned about the centre of a larger box, the square moves as well as narrowing.
|
// Turned about the centre of a larger box, the square moves as well as narrowing.
|
||||||
let wide = plane(&p, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap();
|
let wide = plane(&p, FillRule::EvenOdd, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap();
|
||||||
assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6));
|
assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6));
|
||||||
// Its back shows when turned past 90°: a plane is two-sided.
|
// Its back shows when turned past 90°: a plane is two-sided.
|
||||||
assert_eq!(plane(&p, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1);
|
assert_eq!(plane(&p, FillRule::EvenOdd, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -175,7 +179,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() {
|
||||||
let mut c = BezPath::new();
|
let mut c = BezPath::new();
|
||||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
||||||
let (p, b) = shape(&c);
|
let (p, b) = shape(&c);
|
||||||
let f = inflate(&p, b, Inflate::default(), front()).unwrap();
|
let f = inflate(&p, FillRule::EvenOdd, b, Inflate::default(), front()).unwrap();
|
||||||
// Straight on, the cushion covers the circle (sampled as a polygon) and nothing else.
|
// Straight on, the cushion covers the circle (sampled as a polygon) and nothing else.
|
||||||
assert!(close(area(&f), std::f64::consts::PI * 50.0 * 50.0, 60.0), "{}", area(&f));
|
assert!(close(area(&f), std::f64::consts::PI * 50.0 * 50.0, 60.0), "{}", area(&f));
|
||||||
assert!(extent(&f).x0 >= 50.0 - 1e-6 && extent(&f).x1 <= 150.0 + 1e-6);
|
assert!(extent(&f).x0 >= 50.0 - 1e-6 && extent(&f).x1 <= 150.0 + 1e-6);
|
||||||
|
|
@ -184,7 +188,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() {
|
||||||
assert!(hi - lo > 0.3, "{lo}..{hi}");
|
assert!(hi - lo > 0.3, "{lo}..{hi}");
|
||||||
// From the side: 50 % volume rounds a disc into a half sphere on a flat back.
|
// From the side: 50 % volume rounds a disc into a half sphere on a flat back.
|
||||||
let side = View { rotation_y: 90.0, ..front() };
|
let side = View { rotation_y: 90.0, ..front() };
|
||||||
let thick = |o: Inflate| extent(&inflate(&p, b, o, side).unwrap()).width();
|
let thick = |o: Inflate| extent(&inflate(&p, FillRule::EvenOdd, b, o, side).unwrap()).width();
|
||||||
assert!(close(thick(Inflate::default()), 50.0, 2.0), "{}", thick(Inflate::default()));
|
assert!(close(thick(Inflate::default()), 50.0, 2.0), "{}", thick(Inflate::default()));
|
||||||
assert!(close(thick(Inflate { both_sides: true, ..Inflate::default() }), 100.0, 4.0));
|
assert!(close(thick(Inflate { both_sides: true, ..Inflate::default() }), 100.0, 4.0));
|
||||||
assert!(close(thick(Inflate { depth: 30.0, ..Inflate::default() }), 80.0, 2.0));
|
assert!(close(thick(Inflate { depth: 30.0, ..Inflate::default() }), 80.0, 2.0));
|
||||||
|
|
@ -197,7 +201,7 @@ fn inflate_leaves_holes_open() {
|
||||||
let mut b = square(0.0, 0.0, 100.0);
|
let mut b = square(0.0, 0.0, 100.0);
|
||||||
b.extend(square(30.0, 30.0, 40.0));
|
b.extend(square(30.0, 30.0, 40.0));
|
||||||
let (p, r) = shape(&b);
|
let (p, r) = shape(&b);
|
||||||
let f = inflate(&p, r, Inflate::default(), front()).unwrap();
|
let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap();
|
||||||
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-3), "{}", area(&f));
|
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-3), "{}", area(&f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,9 +209,9 @@ fn inflate_leaves_holes_open() {
|
||||||
fn malformed_and_degenerate_input_is_bounded() {
|
fn malformed_and_degenerate_input_is_bounded() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
for r in [Rect::new(f64::NAN, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 1e9, 1.0)] {
|
for r in [Rect::new(f64::NAN, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 1e9, 1.0)] {
|
||||||
assert!(extrude(&p, r, Extrude::default(), front()).is_err());
|
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_err());
|
||||||
assert!(inflate(&p, r, Inflate::default(), front()).is_err());
|
assert!(inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).is_err());
|
||||||
assert!(plane(&p, r, front()).is_err());
|
assert!(plane(&p, FillRule::EvenOdd, r, front()).is_err());
|
||||||
}
|
}
|
||||||
let wild = Extrude {
|
let wild = Extrude {
|
||||||
depth: f64::INFINITY,
|
depth: f64::INFINITY,
|
||||||
|
|
@ -216,18 +220,24 @@ fn malformed_and_degenerate_input_is_bounded() {
|
||||||
cap: true,
|
cap: true,
|
||||||
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
|
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
|
||||||
};
|
};
|
||||||
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap();
|
let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap();
|
||||||
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
|
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||||||
assert!(
|
assert!(
|
||||||
inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, front())
|
inflate(
|
||||||
.is_ok()
|
&p,
|
||||||
|
FillRule::EvenOdd,
|
||||||
|
b,
|
||||||
|
Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true },
|
||||||
|
front()
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
);
|
);
|
||||||
assert!(extrude(&PathData::default(), b, Extrude::default(), front()).unwrap().is_empty());
|
assert!(extrude(&PathData::default(), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty());
|
||||||
// A line has no area to extrude.
|
// A line has no area to extrude.
|
||||||
let mut line = BezPath::new();
|
let mut line = BezPath::new();
|
||||||
line.move_to((0.0, 0.0));
|
line.move_to((0.0, 0.0));
|
||||||
line.line_to((100.0, 0.0));
|
line.line_to((100.0, 0.0));
|
||||||
assert!(extrude(&PathData::from_bezpath(&line), b, Extrude::default(), front()).unwrap().is_empty());
|
assert!(extrude(&PathData::from_bezpath(&line), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty());
|
||||||
// Too many anchors: refused, not truncated.
|
// Too many anchors: refused, not truncated.
|
||||||
let mut many = BezPath::new();
|
let mut many = BezPath::new();
|
||||||
many.move_to((0.0, 0.0));
|
many.move_to((0.0, 0.0));
|
||||||
|
|
@ -235,15 +245,15 @@ fn malformed_and_degenerate_input_is_bounded() {
|
||||||
many.line_to((f64::from(i), f64::from(i % 7)));
|
many.line_to((f64::from(i), f64::from(i % 7)));
|
||||||
}
|
}
|
||||||
many.close_path();
|
many.close_path();
|
||||||
assert!(extrude(&PathData::from_bezpath(&many), b, Extrude::default(), front()).is_err());
|
assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn perspective_makes_the_near_face_larger() {
|
fn perspective_makes_the_near_face_larger() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
let o = Extrude { depth: 100.0, ..Extrude::default() };
|
let o = Extrude { depth: 100.0, ..Extrude::default() };
|
||||||
let flat = extent(&extrude(&p, b, o, front()).unwrap());
|
let flat = extent(&extrude(&p, FillRule::EvenOdd, b, o, front()).unwrap());
|
||||||
let deep = extent(&extrude(&p, b, o, View { perspective: 30.0, ..front() }).unwrap());
|
let deep = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: 30.0, ..front() }).unwrap());
|
||||||
assert!(deep.width() > flat.width() + 1.0);
|
assert!(deep.width() > flat.width() + 1.0);
|
||||||
assert!(close(deep.center().x, flat.center().x, 1e-6));
|
assert!(close(deep.center().x, flat.center().x, 1e-6));
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +267,7 @@ fn a_wider_perspective_brings_the_eye_nearer() {
|
||||||
let mut last = 100.0;
|
let mut last = 100.0;
|
||||||
for angle in [20.0, 60.0, 110.0, 160.0] {
|
for angle in [20.0, 60.0, 110.0, 160.0] {
|
||||||
// The front face alone shows: its width is the cube's on the page.
|
// The front face alone shows: its width is the cube's on the page.
|
||||||
let width = extent(&extrude(&p, b, o, View { perspective: angle, ..front() }).unwrap()).width();
|
let width = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: angle, ..front() }).unwrap()).width();
|
||||||
assert!(width > last, "{angle}°: {width} after {last}");
|
assert!(width > last, "{angle}°: {width} after {last}");
|
||||||
last = width;
|
last = width;
|
||||||
}
|
}
|
||||||
|
|
@ -274,10 +284,10 @@ fn the_inflate_membrane_matches_known_solutions() {
|
||||||
let mut c = BezPath::new();
|
let mut c = BezPath::new();
|
||||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
||||||
let (p, b) = shape(&c);
|
let (p, b) = shape(&c);
|
||||||
let disc = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
|
let disc = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
|
||||||
assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak);
|
assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak);
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
let sq = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
|
let sq = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
|
||||||
assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak);
|
assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak);
|
||||||
// Smooth across the middle: no crease where a distance-to-the-edge height would have one
|
// Smooth across the middle: no crease where a distance-to-the-edge height would have one
|
||||||
// (the diagonals). The second difference along a diagonal stays small and even.
|
// (the diagonals). The second difference along a diagonal stays small and even.
|
||||||
|
|
@ -303,10 +313,10 @@ fn inflate_cells_across_a_hole_edge_are_cut_to_the_shape() {
|
||||||
b.close_path();
|
b.close_path();
|
||||||
let (p, r) = shape(&b);
|
let (p, r) = shape(&b);
|
||||||
let v = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..front() };
|
let v = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..front() };
|
||||||
let f = inflate(&p, r, Inflate::default(), v).unwrap();
|
let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), v).unwrap();
|
||||||
assert!(f.iter().all(|f| f.contours.len() == 1), "every facet is one piece of the surface");
|
assert!(f.iter().all(|f| f.contours.len() == 1), "every facet is one piece of the surface");
|
||||||
// Straight on, the facets still cover exactly the shape.
|
// Straight on, the facets still cover exactly the shape.
|
||||||
let flat = inflate(&p, r, Inflate::default(), front()).unwrap();
|
let flat = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap();
|
||||||
assert!(close(area(&flat), 220.0 * 220.0 - 100.0 * 90.0, 1e-3), "{}", area(&flat));
|
assert!(close(area(&flat), 220.0 * 220.0 - 100.0 * 90.0, 1e-3), "{}", area(&flat));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,19 +342,19 @@ fn bumps(count: usize) -> (PathData, Rect) {
|
||||||
fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
|
fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
|
||||||
// 300 curves were 4,800 points at sixteen each, over the old limit of 1,024.
|
// 300 curves were 4,800 points at sixteen each, over the old limit of 1,024.
|
||||||
let (p, r) = bumps(300);
|
let (p, r) = bumps(300);
|
||||||
assert!(extrude(&p, r, Extrude::default(), front()).is_ok());
|
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok());
|
||||||
assert!(inflate(&p, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok());
|
assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok());
|
||||||
assert!(plane(&p, r, front()).is_ok());
|
assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok());
|
||||||
// 4,000 curves: too many for a solid, not for Rotate.
|
// 4,000 curves: too many for a solid, not for Rotate.
|
||||||
let (p, r) = bumps(4000);
|
let (p, r) = bumps(4000);
|
||||||
assert!(extrude(&p, r, Extrude::default(), front()).unwrap_err().contains("too complex"));
|
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex"));
|
||||||
assert_eq!(plane(&p, r, front()).unwrap().len(), 1);
|
assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1);
|
||||||
// Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt
|
// Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt
|
||||||
// here (1/2000 of its 100 pt size is less).
|
// here (1/2000 of its 100 pt size is less).
|
||||||
let mut c = BezPath::new();
|
let mut c = BezPath::new();
|
||||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0), 0.01));
|
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0), 0.01));
|
||||||
let (p, r) = shape(&c);
|
let (p, r) = shape(&c);
|
||||||
let ring = &rings(&p, 100.0, MAX_SOLID_POINTS).unwrap()[0];
|
let ring = &rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap()[0];
|
||||||
for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)) {
|
for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)) {
|
||||||
let mid = a.midpoint(*b);
|
let mid = a.midpoint(*b);
|
||||||
assert!(50.0 - mid.distance(Point::new(50.0, 50.0)) <= 0.05 + 1e-3, "{a:?} {b:?}");
|
assert!(50.0 - mid.distance(Point::new(50.0, 50.0)) <= 0.05 + 1e-3, "{a:?} {b:?}");
|
||||||
|
|
@ -352,7 +362,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
|
||||||
assert!(r.width() > 0.0);
|
assert!(r.width() > 0.0);
|
||||||
// A small bump takes a few points; a large curve more than sixteen where it needs them.
|
// A small bump takes a few points; a large curve more than sixteen where it needs them.
|
||||||
let (p, r) = bumps(300);
|
let (p, r) = bumps(300);
|
||||||
let small = rings(&p, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len();
|
let small = rings(&p, FillRule::EvenOdd, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len();
|
||||||
assert!(small <= 300 * 4 + 3, "{small} points for 300 bumps");
|
assert!(small <= 300 * 4 + 3, "{small} points for 300 bumps");
|
||||||
assert!(ring.len() > 4 * 16, "a 100 pt circle within 0.05 pt: {}", ring.len());
|
assert!(ring.len() > 4 * 16, "a 100 pt circle within 0.05 pt: {}", ring.len());
|
||||||
}
|
}
|
||||||
|
|
@ -362,7 +372,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_edge_index_finds_the_nearest_edge() {
|
fn the_edge_index_finds_the_nearest_edge() {
|
||||||
let (p, r) = bumps(200);
|
let (p, r) = bumps(200);
|
||||||
let rings = rings(&p, r.width(), MAX_SOLID_POINTS).unwrap();
|
let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap();
|
||||||
let edges = Edges::new(&rings);
|
let edges = Edges::new(&rings);
|
||||||
for k in 0..400 {
|
for k in 0..400 {
|
||||||
let t = f64::from(k);
|
let t = f64::from(k);
|
||||||
|
|
@ -376,7 +386,7 @@ fn the_edge_index_finds_the_nearest_edge() {
|
||||||
/// straight ahead: the search doesn't stop before it has looked there.
|
/// straight ahead: the search doesn't stop before it has looked there.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_edge_index_looks_along_the_side_from_outside() {
|
fn the_edge_index_looks_along_the_side_from_outside() {
|
||||||
let ring = |b: BezPath| rings(&shape(&b).0, 64.0, MAX_SOLID_POINTS).unwrap().remove(0);
|
let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0);
|
||||||
// Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid).
|
// Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid).
|
||||||
let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))];
|
let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))];
|
||||||
let edges = Edges::new(&rings);
|
let edges = Edges::new(&rings);
|
||||||
|
|
@ -390,7 +400,7 @@ fn the_edge_index_looks_along_the_side_from_outside() {
|
||||||
fn twist_holds_between_0_and_360_degrees() {
|
fn twist_holds_between_0_and_360_degrees() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
||||||
let at = |twist: f64| extrude(&p, b, Extrude { twist, ..Extrude::default() }, v).unwrap();
|
let at = |twist: f64| extrude(&p, FillRule::EvenOdd, b, Extrude { twist, ..Extrude::default() }, v).unwrap();
|
||||||
assert_eq!(at(-45.0), at(0.0));
|
assert_eq!(at(-45.0), at(0.0));
|
||||||
assert_eq!(at(500.0), at(360.0));
|
assert_eq!(at(500.0), at(360.0));
|
||||||
assert_ne!(at(45.0), at(0.0));
|
assert_ne!(at(45.0), at(0.0));
|
||||||
|
|
@ -408,11 +418,11 @@ fn twisted_walls_face_the_way_they_turn() {
|
||||||
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0)] {
|
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0)] {
|
||||||
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
||||||
let o = Extrude { twist, ..Extrude::default() };
|
let o = Extrude { twist, ..Extrude::default() };
|
||||||
let got = extrude(&p, b, o, v).unwrap();
|
let got = extrude(&p, FillRule::EvenOdd, b, o, v).unwrap();
|
||||||
// Expected: walls (and caps) whose outward normal faces the viewer.
|
// Expected: walls (and caps) whose outward normal faces the viewer.
|
||||||
let o = o.sanitized();
|
let o = o.sanitized();
|
||||||
let camera = Camera::new(v, b, o.depth);
|
let camera = Camera::new(v, b, o.depth);
|
||||||
let ring = &rings(&p, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0];
|
let ring = &rings(&p, FillRule::EvenOdd, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0];
|
||||||
let slices = ((twist / 6.0).ceil() as usize).clamp(8, 64);
|
let slices = ((twist / 6.0).ceil() as usize).clamp(8, 64);
|
||||||
let z = |t: f64| o.depth / 2.0 - o.depth * t;
|
let z = |t: f64| o.depth / 2.0 - o.depth * t;
|
||||||
let mut expected = 0;
|
let mut expected = 0;
|
||||||
|
|
@ -456,7 +466,7 @@ fn inflate_twists_tapers_and_opens_its_back() {
|
||||||
let o = Inflate { depth: 60.0, volume: 0.0, ..Inflate::default() };
|
let o = Inflate { depth: 60.0, volume: 0.0, ..Inflate::default() };
|
||||||
// From the side, a flat (0 % volume) cushion is the extrusion: front 100 high, the back 50
|
// From the side, a flat (0 % volume) cushion is the extrusion: front 100 high, the back 50
|
||||||
// at a 50 % taper.
|
// at a 50 % taper.
|
||||||
let tapered = inflate(&p, b, Inflate { taper: 0.5, ..o }, side).unwrap();
|
let tapered = inflate(&p, FillRule::EvenOdd, b, Inflate { taper: 0.5, ..o }, side).unwrap();
|
||||||
let e = extent(&tapered);
|
let e = extent(&tapered);
|
||||||
assert!(close(e.width(), 60.0, 1e-6) && close(e.height(), 100.0, 1e-6), "{e:?}");
|
assert!(close(e.width(), 60.0, 1e-6) && close(e.height(), 100.0, 1e-6), "{e:?}");
|
||||||
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, e.x0, 1e-6)).collect();
|
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, e.x0, 1e-6)).collect();
|
||||||
|
|
@ -464,18 +474,19 @@ fn inflate_twists_tapers_and_opens_its_back() {
|
||||||
assert!(close(hi - lo, 50.0, 1e-6), "back {}", hi - lo);
|
assert!(close(hi - lo, 50.0, 1e-6), "back {}", hi - lo);
|
||||||
// From behind, a 45° twist shows the flat back as a diamond.
|
// From behind, a 45° twist shows the flat back as a diamond.
|
||||||
let behind = View { rotation_y: 180.0, ..front() };
|
let behind = View { rotation_y: 180.0, ..front() };
|
||||||
let twisted = inflate(&p, b, Inflate { twist: 45.0, ..o }, behind).unwrap();
|
let twisted = inflate(&p, FillRule::EvenOdd, b, Inflate { twist: 45.0, ..o }, behind).unwrap();
|
||||||
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
||||||
// Hollow: no back; from behind the inside of the inflated front shows instead of a flat face.
|
// Hollow: no back; from behind the inside of the inflated front shows instead of a flat face.
|
||||||
let solid = inflate(&p, b, Inflate { volume: 50.0, ..o }, behind).unwrap();
|
let solid = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, ..o }, behind).unwrap();
|
||||||
assert_eq!(solid.len(), 1, "the flat back alone");
|
assert_eq!(solid.len(), 1, "the flat back alone");
|
||||||
let hollow = inflate(&p, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap();
|
let hollow = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap();
|
||||||
assert!(hollow.len() > 100 && hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() < 12));
|
assert!(hollow.len() > 100 && hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() < 12));
|
||||||
// With both sides puffed, the back puff is scaled with the taper: lower and smaller.
|
// With both sides puffed, the back puff is scaled with the taper: lower and smaller.
|
||||||
let both = |taper: f64| extent(&inflate(&p, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap());
|
let both =
|
||||||
|
|taper: f64| extent(&inflate(&p, FillRule::EvenOdd, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap());
|
||||||
assert!(both(0.5).width() < both(1.0).width() && both(0.5).height() <= both(1.0).height() + 1e-6);
|
assert!(both(0.5).width() < both(1.0).width() && both(0.5).height() <= both(1.0).height() + 1e-6);
|
||||||
// Out of range values hold at the documented ends.
|
// Out of range values hold at the documented ends.
|
||||||
let at = |i: Inflate| inflate(&p, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
let at = |i: Inflate| inflate(&p, FillRule::EvenOdd, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||||
assert_eq!(at(Inflate { twist: -30.0, ..o }), at(o));
|
assert_eq!(at(Inflate { twist: -30.0, ..o }), at(o));
|
||||||
assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o }));
|
assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o }));
|
||||||
}
|
}
|
||||||
|
|
@ -487,10 +498,43 @@ fn a_flat_twisted_inflate_matches_the_extrusion() {
|
||||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||||
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0), (60.0, 200.0)] {
|
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0), (60.0, 200.0)] {
|
||||||
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
||||||
let e = extent(&extrude(&p, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap());
|
let e = extent(&extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap());
|
||||||
let i = extent(&inflate(&p, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap());
|
let i = extent(
|
||||||
|
&inflate(&p, FillRule::EvenOdd, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap(),
|
||||||
|
);
|
||||||
for (a, c) in [(e.x0, i.x0), (e.y0, i.y0), (e.x1, i.x1), (e.y1, i.y1)] {
|
for (a, c) in [(e.x0, i.x0), (e.y0, i.y0), (e.x1, i.x1), (e.y1, i.y1)] {
|
||||||
assert!(close(a, c, 1e-6), "view ({x}, {y}): extrusion {e:?}, inflate {i:?}");
|
assert!(close(a, c, 1e-6), "view ({x}, {y}): extrusion {e:?}, inflate {i:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole
|
||||||
|
/// (the middle is filled, as the art is drawn); one running the other way does, and so does
|
||||||
|
/// either under the even-odd rule.
|
||||||
|
#[test]
|
||||||
|
fn nonzero_shapes_keep_the_middle_a_same_way_contour_encloses() {
|
||||||
|
let rect = |r: Rect, clockwise: bool| {
|
||||||
|
let mut pts = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
|
||||||
|
if clockwise {
|
||||||
|
pts.reverse();
|
||||||
|
}
|
||||||
|
vectorcraft_geom::SubPath::polyline(&pts, true)
|
||||||
|
};
|
||||||
|
let (outer, inner) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(25.0, 25.0, 75.0, 75.0));
|
||||||
|
let nested = |inner_clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, inner_clockwise)]);
|
||||||
|
let flat = |p: &PathData, rule: FillRule| area(&plane(p, rule, outer, front()).unwrap());
|
||||||
|
let (solid, holed) = (100.0 * 100.0, 100.0 * 100.0 - 50.0 * 50.0);
|
||||||
|
assert!(close(flat(&nested(false), FillRule::NonZero), solid, 1e-6), "the same way round: filled");
|
||||||
|
assert!(close(flat(&nested(true), FillRule::NonZero), holed, 1e-6), "the other way round: a hole");
|
||||||
|
assert!(close(flat(&nested(false), FillRule::EvenOdd), holed, 1e-6));
|
||||||
|
assert!(close(flat(&nested(true), FillRule::EvenOdd), holed, 1e-6));
|
||||||
|
// A third contour inside the hole is an island again, whichever way it runs.
|
||||||
|
let island = |clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, true), rect(Rect::new(40.0, 40.0, 60.0, 60.0), clockwise)]);
|
||||||
|
for clockwise in [false, true] {
|
||||||
|
assert!(close(flat(&island(clockwise), FillRule::NonZero), holed + 20.0 * 20.0, 1e-6), "{clockwise}");
|
||||||
|
}
|
||||||
|
// Extrude's caps and Inflate follow the same outline.
|
||||||
|
let caps = |p: &PathData| area(&extrude(p, FillRule::NonZero, outer, Extrude::default(), front()).unwrap());
|
||||||
|
assert!(caps(&nested(false)) > caps(&nested(true)) + 1000.0);
|
||||||
|
assert!(inflate(&nested(false), FillRule::NonZero, outer, Inflate::default(), front()).is_ok());
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue