mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Security: planar map budgets for booleans, Live Paint, Offset Path and 3D effects
CodeRabbit findings #6, #19, #53, #59, #62, #167: - Before a sweep builds a planar map (Boolean, normalize, Pathfinder, Offset Path, Live Paint, Shape Builder), the pairs of segments whose bounds overlap are counted exactly in O(n log n); past 4M the operation fails as `PathOpsError::TooComplex` (a crossing can only lie where two segments' bounds overlap). - Live Paint's winding crossings are sparse: the sweep keeps one per edge, and a vector as long as the input count made disjoint shapes quadratic in memory. - Gap detection groups the segments near a loose end by track once. - 3D: ring containment checks bounds first and walks at most 50M edges; Inflate's mixed-cell booleans take at most 4M outline segments together.
This commit is contained in:
@@ -281,6 +281,9 @@ impl Tidy {
|
||||
impl Arrangement {
|
||||
pub(crate) fn new(mut paths: Vec<(BezPath, FillRule)>) -> Result<Self, PathOpsError> {
|
||||
let eps = eps_for(paths.iter().map(|p| &p.0))?;
|
||||
if !crate::budget::sweep_fits(paths.iter().map(|p| &p.0)) {
|
||||
return Err(PathOpsError::TooComplex);
|
||||
}
|
||||
snap_horizontals(&mut paths.iter_mut().map(|p| &mut p.0).collect::<Vec<_>>(), eps);
|
||||
// The sweep can panic on rare near-degenerate input; that must never take the app down,
|
||||
// so it becomes an error (callers fall back to leaving the art unchanged).
|
||||
@@ -638,6 +641,9 @@ pub fn try_normalize(path: &PathData, rule: FillRule) -> Result<PathData, PathOp
|
||||
|
||||
pub(crate) fn normalize_bez(bp: &BezPath, rule: FillRule) -> Result<Contours, PathOpsError> {
|
||||
let eps = eps_for([bp])?;
|
||||
if !crate::budget::sweep_fits([bp]) {
|
||||
return Err(PathOpsError::TooComplex);
|
||||
}
|
||||
let mut bp = bp.clone();
|
||||
snap_horizontals(&mut [&mut bp], eps);
|
||||
let top = Topology::<i32>::from_path(&bp, eps).map_err(|_| PathOpsError::OpenPath)?;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! What the sweep that builds a planar map may be given. The topology it builds holds every
|
||||
//! crossing of the input's segments, and a crossing can only be where two segments' bounds
|
||||
//! overlap (two cubics cross at most 9 times). The pairs of overlapping bounds are counted before
|
||||
//! the sweep, exactly and in O(n log n), and past [`MAX_SEGMENT_PAIRS`] the operation fails as
|
||||
//! [`crate::PathOpsError::TooComplex`]: one compact self-crossing path could otherwise make
|
||||
//! millions of crossings, and a live effect rebuilt them on every draw.
|
||||
|
||||
use kurbo::{BezPath, ParamCurveExtrema, Rect};
|
||||
|
||||
/// Most pairs of segments whose bounds overlap that a planar map is built from. Neighbouring
|
||||
/// segments of a path always overlap at their shared end; real art of 65,536 segments (the live
|
||||
/// Pathfinder's cap) overlaps a few times per segment, far below this.
|
||||
pub const MAX_SEGMENT_PAIRS: usize = 1 << 22;
|
||||
|
||||
/// Fenwick tree of counts over its slots.
|
||||
struct Counts(Vec<isize>);
|
||||
|
||||
// `isolate_lowest_one` is still unstable in Rust 1.96, and the workspace builds on 1.95 (rust-version).
|
||||
#[allow(clippy::manual_isolate_lowest_one)]
|
||||
impl Counts {
|
||||
fn add(&mut self, slot: usize, d: isize) {
|
||||
let mut i = slot + 1;
|
||||
while let Some(c) = self.0.get_mut(i) {
|
||||
*c += d;
|
||||
i += i & i.wrapping_neg();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum of the slots before `end`.
|
||||
fn before(&self, end: usize) -> isize {
|
||||
let (mut i, mut s) = (end.min(self.0.len().saturating_sub(1)), 0);
|
||||
while i > 0 {
|
||||
s += self.0.get(i).copied().unwrap_or(0);
|
||||
i -= i & i.wrapping_neg();
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Do at most `cap` pairs of `boxes` overlap (touching counts)? A sweep in x keeps the open boxes
|
||||
/// in two Fenwick trees over their y extents, so this costs O(n log n) however they lie, and stops
|
||||
/// as soon as the count passes `cap`.
|
||||
pub(crate) fn overlaps_within(boxes: &[Rect], cap: usize) -> bool {
|
||||
let mut ys: Vec<f64> = boxes.iter().flat_map(|b| [b.y0, b.y1]).collect();
|
||||
ys.sort_by(f64::total_cmp);
|
||||
ys.dedup_by(|a, b| a.total_cmp(b).is_eq());
|
||||
let below = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_lt());
|
||||
let upto = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_le());
|
||||
// (x, is end, box); starts sort before ends at the same x since touching boxes overlap.
|
||||
let mut events: Vec<(f64, bool, usize)> = boxes.iter().enumerate().flat_map(|(i, b)| [(b.x0, false, i), (b.x1, true, i)]).collect();
|
||||
events.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
let mut by_y0 = Counts(vec![0; ys.len() + 1]);
|
||||
let mut by_y1 = Counts(vec![0; ys.len() + 1]);
|
||||
let (mut active, mut overlaps) = (0isize, 0usize);
|
||||
for (_, end, i) in events {
|
||||
let Some(b) = boxes.get(i) else { continue };
|
||||
if end {
|
||||
by_y0.add(below(b.y0), -1);
|
||||
by_y1.add(below(b.y1), -1);
|
||||
active -= 1;
|
||||
continue;
|
||||
}
|
||||
// Open boxes neither entirely above b nor entirely below it.
|
||||
let above = active - by_y0.before(upto(b.y1));
|
||||
let under = by_y1.before(below(b.y0));
|
||||
overlaps = overlaps.saturating_add(usize::try_from(active - above - under).unwrap_or(0));
|
||||
if overlaps > cap {
|
||||
return false;
|
||||
}
|
||||
by_y0.add(below(b.y0), 1);
|
||||
by_y1.add(below(b.y1), 1);
|
||||
active += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Can a planar map be built of `paths` within [`MAX_SEGMENT_PAIRS`]?
|
||||
pub(crate) fn sweep_fits<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> bool {
|
||||
let boxes: Vec<Rect> = paths.into_iter().flat_map(|p| p.segments().map(|s| s.bounding_box())).collect();
|
||||
overlaps_within(&boxes, MAX_SEGMENT_PAIRS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Pairs of overlapping boxes are counted exactly: a grid of n crossing bars makes n² pairs.
|
||||
#[test]
|
||||
fn crossing_bars_count_as_their_pairs() {
|
||||
let bars = |n: usize| -> Vec<Rect> {
|
||||
let across = (0..n).map(|i| Rect::new(0.0, i as f64 * 2.0, 100.0, i as f64 * 2.0 + 1.0));
|
||||
let down = (0..n).map(|i| Rect::new(i as f64 * 2.0, 0.0, i as f64 * 2.0 + 1.0, 100.0));
|
||||
across.chain(down).collect()
|
||||
};
|
||||
assert!(overlaps_within(&bars(10), 100));
|
||||
assert!(!overlaps_within(&bars(10), 99));
|
||||
// Disjoint boxes, many of them: none overlap.
|
||||
let apart: Vec<Rect> = (0..10_000).map(|i| Rect::new(i as f64 * 3.0, 0.0, i as f64 * 3.0 + 1.0, 1.0)).collect();
|
||||
assert!(overlaps_within(&apart, 0));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod boolean;
|
||||
mod budget;
|
||||
mod edit;
|
||||
mod fit;
|
||||
mod offset;
|
||||
@@ -36,4 +37,6 @@ pub enum PathOpsError {
|
||||
OpenPath,
|
||||
#[error("the shapes are too degenerate to combine")]
|
||||
Degenerate,
|
||||
#[error("the shapes cross themselves and each other too often to combine")]
|
||||
TooComplex,
|
||||
}
|
||||
|
||||
@@ -32,23 +32,18 @@ const UNSHEAR: Affine = Affine::new([1.0, -1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
|
||||
/// Boundary crossings per input, counted from the far west: slot `2i` counts input `i`'s
|
||||
/// segments running one way, slot `2i + 1` the other. An open path traced there and back adds one
|
||||
/// to each, so its edges never cancel out of the map.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Crossings(Vec<u32>);
|
||||
/// to each, so its edges never cancel out of the map. Only the slots that aren't 0 are kept, in
|
||||
/// order: the sweep keeps one of these for every edge, and a vector as long as the input's number
|
||||
/// (as this was) made disjoint shapes cost the square of their count in memory.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct Crossings(Vec<(usize, u32)>);
|
||||
|
||||
impl Crossings {
|
||||
fn get(&self, slot: usize) -> u32 {
|
||||
self.0.get(slot).copied().unwrap_or(0)
|
||||
self.0.binary_search_by_key(&slot, |(s, _)| *s).map_or(0, |i| self.0.get(i).map_or(0, |(_, c)| *c))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Crossings {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
(0..self.0.len().max(other.0.len())).all(|i| self.get(i) == other.get(i))
|
||||
}
|
||||
}
|
||||
impl Eq for Crossings {}
|
||||
|
||||
impl std::ops::Add for Crossings {
|
||||
type Output = Crossings;
|
||||
fn add(mut self, rhs: Self) -> Crossings {
|
||||
@@ -59,33 +54,35 @@ impl std::ops::Add for Crossings {
|
||||
|
||||
impl std::ops::AddAssign for Crossings {
|
||||
fn add_assign(&mut self, rhs: Self) {
|
||||
if self.0.len() < rhs.0.len() {
|
||||
self.0.resize(rhs.0.len(), 0);
|
||||
if rhs.0.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (a, b) in self.0.iter_mut().zip(rhs.0) {
|
||||
*a = a.saturating_add(b);
|
||||
let mut out = Vec::with_capacity(self.0.len() + rhs.0.len());
|
||||
let (mut a, mut b) = (std::mem::take(&mut self.0).into_iter().peekable(), rhs.0.into_iter().peekable());
|
||||
loop {
|
||||
let next = match (a.peek(), b.peek()) {
|
||||
(Some(x), Some(y)) if x.0 == y.0 => {
|
||||
let (x, y) = (a.next(), b.next());
|
||||
x.zip(y).map(|(x, y)| (x.0, x.1.saturating_add(y.1)))
|
||||
}
|
||||
(Some(x), Some(y)) if x.0 < y.0 => a.next(),
|
||||
(Some(_), Some(_)) | (None, Some(_)) => b.next(),
|
||||
(Some(_), None) => a.next(),
|
||||
(None, None) => break,
|
||||
};
|
||||
out.extend(next.filter(|(_, c)| *c != 0));
|
||||
}
|
||||
self.0 = out;
|
||||
}
|
||||
}
|
||||
|
||||
impl WindingNumber for Crossings {
|
||||
type Tag = usize;
|
||||
fn single(tag: usize, positive: bool) -> Self {
|
||||
let slot = 2 * tag + usize::from(!positive);
|
||||
let mut v = vec![0; slot + 1];
|
||||
if let Some(c) = v.get_mut(slot) {
|
||||
*c = 1;
|
||||
}
|
||||
Crossings(v)
|
||||
Crossings(vec![(2 * tag + usize::from(!positive), 1)])
|
||||
}
|
||||
fn of_tag(&self, tag: usize) -> Self {
|
||||
let mut v = vec![0; 2 * tag + 2];
|
||||
for slot in [2 * tag, 2 * tag + 1] {
|
||||
if let Some(c) = v.get_mut(slot) {
|
||||
*c = self.get(slot);
|
||||
}
|
||||
}
|
||||
Crossings(v)
|
||||
Crossings([2 * tag, 2 * tag + 1].into_iter().map(|slot| (slot, self.get(slot))).filter(|(_, c)| *c != 0).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,9 +626,10 @@ fn gap_from(tracks: &[Track], grid: &SegGrid, own: usize, e: Point, from: f64, m
|
||||
let o = tracks.get(own)?;
|
||||
let reach = GAP_DETOUR * max_gap;
|
||||
let mut best: Option<(f64, Point)> = None;
|
||||
// `around` holds the segments a way of up to `reach` from `e` can cross, and `crossed` the
|
||||
// crossings with each track.
|
||||
let mut around: Option<Vec<(usize, usize)>> = None;
|
||||
// `around` holds the segments a way of up to `reach` from `e` can cross, by track (grouped
|
||||
// once: filtering the whole list again for each track near `e` made clusters of parallel
|
||||
// paths cubic), and `crossed` the crossings with each track.
|
||||
let mut around: Option<HashMap<usize, Vec<usize>>> = None;
|
||||
let mut crossed: HashMap<usize, Vec<(f64, f64)>> = HashMap::new();
|
||||
for (j, k) in grid.near(e, max_gap) {
|
||||
let Some(q) = tracks.get(j) else { continue };
|
||||
@@ -650,8 +648,14 @@ fn gap_from(tracks: &[Track], grid: &SegGrid, own: usize, e: Point, from: f64, m
|
||||
o.along(from, at)
|
||||
} else {
|
||||
let xs = crossed.entry(j).or_insert_with(|| {
|
||||
let segs = around.get_or_insert_with(|| grid.near(e, reach + max_gap));
|
||||
crossings(o, from, reach, q, segs.iter().filter(|c| c.0 == j).map(|c| c.1))
|
||||
let by_track = around.get_or_insert_with(|| {
|
||||
let mut by_track: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for (t, k) in grid.near(e, reach + max_gap) {
|
||||
by_track.entry(t).or_default().push(k);
|
||||
}
|
||||
by_track
|
||||
});
|
||||
crossings(o, from, reach, q, by_track.get(&j).into_iter().flatten().copied())
|
||||
});
|
||||
xs.iter().map(|&(a, b)| o.along(from, a) + q.along(b, at)).fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
@@ -1040,6 +1044,9 @@ pub(crate) fn map(shapes: &[Shape]) -> Option<Map> {
|
||||
return None;
|
||||
}
|
||||
let eps = eps_for(inputs.iter().map(|p| &p.0)).ok()?;
|
||||
if !crate::budget::sweep_fits(inputs.iter().map(|p| &p.0)) {
|
||||
return None;
|
||||
}
|
||||
snap_horizontals(&mut inputs.iter_mut().map(|p| &mut p.0).collect::<Vec<_>>(), eps);
|
||||
// The sweep can panic on rare near-degenerate input; that must never take the app down.
|
||||
let sweep =
|
||||
@@ -1471,6 +1478,19 @@ mod tests {
|
||||
use proptest::prelude::*;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
/// Sparse crossings add, compare and read like the dense counts they stand for.
|
||||
#[test]
|
||||
fn crossings_are_sparse_counts() {
|
||||
let mut c = Crossings::single(7, true);
|
||||
c += Crossings::single(2, false);
|
||||
c += Crossings::single(7, true);
|
||||
assert_eq!((c.get(14), c.get(5), c.get(15), c.get(0)), (2, 1, 0, 0));
|
||||
assert_eq!(c.0.len(), 2, "only the slots that aren't 0");
|
||||
assert_eq!(c.of_tag(7), Crossings(vec![(14, 2)]));
|
||||
assert_eq!(Crossings::single(2, false) + Crossings::single(7, true) + Crossings::single(7, true), c);
|
||||
assert_eq!(Crossings::default() + Crossings::default(), Crossings::default());
|
||||
}
|
||||
|
||||
fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape {
|
||||
Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key)
|
||||
}
|
||||
|
||||
+51
-11
@@ -349,27 +349,45 @@ fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec
|
||||
out.push(ring);
|
||||
}
|
||||
}
|
||||
// Which rings hold which: each ring against the others, its edges walked only where its
|
||||
// bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges in all.
|
||||
let too_complex = || String::from("The shape is too complex for a 3D effect");
|
||||
let mut work = 0usize;
|
||||
let mut within = |out: &[Vec<Point>], bounds: &[Rect], i: usize| -> Result<Vec<usize>, String> {
|
||||
let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default();
|
||||
let mut held_by = vec![];
|
||||
for (j, (o, b)) in out.iter().zip(bounds).enumerate() {
|
||||
if j == i || !(b.x0 <= p.x && p.x <= b.x1 && b.y0 <= p.y && p.y <= b.y1) {
|
||||
continue;
|
||||
}
|
||||
work = work.saturating_add(o.len());
|
||||
if work > MAX_CONTAINMENT_EDGES {
|
||||
return Err(too_complex());
|
||||
}
|
||||
if contains(o, p) {
|
||||
held_by.push(j);
|
||||
}
|
||||
}
|
||||
Ok(held_by)
|
||||
};
|
||||
let bounds_of = |out: &[Vec<Point>]| -> Vec<Rect> {
|
||||
out.iter().map(|r| r.iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect()
|
||||
};
|
||||
if rule == FillRule::NonZero {
|
||||
let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 };
|
||||
let bounds = bounds_of(&out);
|
||||
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();
|
||||
let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).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.
|
||||
let holes: Vec<bool> = out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
let p = r.first().copied().unwrap_or_default();
|
||||
out.iter().enumerate().filter(|(j, o)| *j != i && contains(o, p)).count() % 2 == 1
|
||||
})
|
||||
.collect();
|
||||
let bounds = bounds_of(&out);
|
||||
let holes: Vec<bool> = (0..out.len()).map(|i| within(&out, &bounds, i).map(|held| held.len() % 2 == 1)).collect::<Result<_, _>>()?;
|
||||
for (r, hole) in out.iter_mut().zip(holes) {
|
||||
if (signed_area(r) > 0.0) == hole {
|
||||
r.reverse();
|
||||
@@ -378,6 +396,10 @@ fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most ring edges [`rings`] walks to tell which rings hold which: every ring is tested against
|
||||
/// the others, so many rings of many points each would otherwise take billions of steps.
|
||||
const MAX_CONTAINMENT_EDGES: usize = 50_000_000;
|
||||
|
||||
fn signed_area(r: &[Point]) -> f64 {
|
||||
r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>() / 2.0
|
||||
}
|
||||
@@ -1262,6 +1284,9 @@ fn cell_triangles(pieces: &[Vec<Point>]) -> Option<Vec<[Point; 3]>> {
|
||||
Some(triangles)
|
||||
}
|
||||
|
||||
/// Most outline segments the booleans of one inflated object's mixed cells take together.
|
||||
const MAX_MATERIAL_SEGMENTS: usize = 4_000_000;
|
||||
|
||||
/// The part of `outline` (its rings, each with its bounds) inside `cell`, as rings (holes running
|
||||
/// the other way). Only the rings whose bounds meet the cell's go into the boolean.
|
||||
fn material(outline: &[(Rect, vectorcraft_geom::SubPath)], cell: Rect) -> Option<Vec<Vec<Point>>> {
|
||||
@@ -1390,6 +1415,10 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result
|
||||
let unit = cell / FINE as f64;
|
||||
let at = |qx: usize, qy: usize| Point::new(bounds.x0 + qx as f64 * unit, bounds.y0 + qy as f64 * unit);
|
||||
let base = |i: usize, j: usize| Rect::from_points(at(i * FINE, j * FINE), at((i + 1) * FINE, (j + 1) * FINE));
|
||||
// Segments the booleans of mixed cells may take in all ([`MAX_MATERIAL_SEGMENTS`]): each one
|
||||
// builds the topology of every outline ring near its cell, so a self-crossing outline cost its
|
||||
// crossings again in every cell it spans. Past it, cells take their clipped pieces.
|
||||
let material_left = std::cell::Cell::new(MAX_MATERIAL_SEGMENTS);
|
||||
// The shape's part of a rectangle, as rings.
|
||||
let cut = |r: Rect| -> Vec<Vec<Point>> {
|
||||
// A cell no edge crosses is wholly inside the shape or wholly outside it.
|
||||
@@ -1404,7 +1433,18 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result
|
||||
// Where a hole's piece cancels part of an outline's, that only holds flat: lifted onto
|
||||
// the surface the two no longer meet. Such a cell takes the shape's own part of it.
|
||||
let mixed = pieces.iter().any(|p| signed_area(p) < 0.0) && pieces.iter().any(|p| signed_area(p) > 0.0);
|
||||
if mixed { material(&outline, r).unwrap_or(pieces) } else { pieces }
|
||||
if !mixed {
|
||||
return pieces;
|
||||
}
|
||||
let near: usize =
|
||||
outline.iter().filter(|(b, _)| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1).map(|(_, sp)| sp.anchors.len()).sum();
|
||||
match material_left.get().checked_sub(near) {
|
||||
Some(left) => {
|
||||
material_left.set(left);
|
||||
material(&outline, r).unwrap_or(pieces)
|
||||
}
|
||||
None => pieces,
|
||||
}
|
||||
};
|
||||
// How many times each cell is halved each way (0, 1 or 2), from how steeply it rises.
|
||||
let levels: Vec<u32> = (0..nx * ny)
|
||||
|
||||
Reference in New Issue
Block a user