Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions crates/morpho-core/src/along.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,110 @@ impl Default for Dash {
}
}

/// Draw only part of a pen line, like trimming a path: from `start` to `end`
/// (fractions of its length), the window slid along by `offset` (wrapping
/// round). Several subpaths count as one line drawn in order, or with `each`
/// every one is trimmed by itself. Animate `end` to draw a line on.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Trim {
pub start: f64,
pub end: f64,
pub offset: f64,
pub each: bool,
}

impl Default for Trim {
fn default() -> Self {
Trim { start: 0.0, end: 1.0, offset: 0.0, each: false }
}
}

/// The stretch of a polyline between arc lengths `a` and `b`.
fn sub_line(pts: &[Pt], acc: &[f64], a: f64, b: f64) -> Vec<Pt> {
let at = |s: f64| -> Pt {
let i = acc.partition_point(|&x| x < s).clamp(1, pts.len() - 1);
let (a0, a1) = (acc[i - 1], acc[i]);
pts[i - 1].lerp(pts[i], if a1 > a0 { (s - a0) / (a1 - a0) } else { 0.0 })
};
let mut out = vec![at(a)];
let (i0, i1) = (acc.partition_point(|&x| x <= a), acc.partition_point(|&x| x < b));
out.extend_from_slice(&pts[i0..i1.max(i0)]);
out.push(at(b));
out
}

/// Pieces of the window `[a, b]` (fractions, `b - a` under 1) once slid by
/// `offset` and wrapped into 0…1.
fn windows(t: &Trim) -> Vec<(f64, f64)> {
let (lo, hi) = (t.start.min(t.end).clamp(0.0, 1.0), t.start.max(t.end).clamp(0.0, 1.0));
let a = lo + t.offset;
let k = a.floor();
let (a, b) = (a - k, hi + t.offset - k);
if b <= 1.0 { vec![(a, b)] } else { vec![(a, 1.0), (0.0, b - 1.0)] }
}

/// Trim centre lines (see [`Trim`]); trimmed pieces are open lines.
pub fn trim(lines: Vec<(Vec<Pt>, bool)>, t: &Trim) -> Vec<(Vec<Pt>, bool)> {
let span = (t.start - t.end).abs().min(1.0);
if span >= 1.0 - 1e-9 {
return lines;
}
if span < 1e-6 {
return Vec::new();
}
// Closed lines are measured round to their first point again.
let lines: Vec<(Vec<Pt>, bool, Vec<f64>)> = lines
.into_iter()
.filter(|(l, _)| l.len() >= 2)
.map(|(mut l, closed)| {
if closed && l.first().zip(l.last()).is_some_and(|(a, b)| a.dist(*b) > 1e-9) {
l.push(l[0]);
}
let mut acc = vec![0.0];
for w in l.windows(2) {
acc.push(acc.last().unwrap() + w[0].dist(w[1]));
}
(l, closed, acc)
})
.collect();
let win = windows(t);
let cut = |l: &[Pt], acc: &[f64], closed: bool, from: f64, len: f64, total: f64| -> Vec<Vec<Pt>> {
let mut pieces: Vec<(f64, Vec<Pt>)> = Vec::new();
for &(wa, wb) in &win {
let (a, b) = ((wa * total - from).max(0.0), (wb * total - from).min(len));
if b - a > 1e-9 {
pieces.push((a, sub_line(l, acc, a, b)));
}
}
// A window wrapping round a closed line is one piece through its start
// (`windows` gives the stretch up to the end first, then the one from 0).
if closed && pieces.len() == 2 && pieces[1].0 < 1e-9 {
let head = pieces.pop().unwrap().1;
let mut joined = pieces.pop().unwrap().1;
joined.extend_from_slice(&head[1..]);
return vec![joined];
}
pieces.into_iter().map(|(_, p)| p).collect()
};
let mut out = Vec::new();
if t.each {
for (l, closed, acc) in &lines {
let len = *acc.last().unwrap();
out.extend(cut(l, acc, *closed, 0.0, len, len).into_iter().map(|p| (p, false)));
}
} else {
let total: f64 = lines.iter().map(|(_, _, acc)| *acc.last().unwrap()).sum();
let mut from = 0.0;
for (l, closed, acc) in &lines {
let len = *acc.last().unwrap();
out.extend(cut(l, acc, *closed && lines.len() == 1, from, len, total).into_iter().map(|p| (p, false)));
from += len;
}
}
out
}

/// A flattened line with its arc length and where each pen segment ends.
struct Track {
pts: Vec<Pt>,
Expand Down Expand Up @@ -493,6 +597,36 @@ mod tests {
CurveContour { start: a, segs: vec![Seg::Line(b)] }
}

#[test]
fn trim_keeps_a_window_of_the_line() {
let l = vec![(vec![pt(0.0, 0.0), pt(100.0, 0.0)], false)];
let half = trim(l.clone(), &Trim { end: 0.5, ..Trim::default() });
assert_eq!(half.len(), 1);
assert!((half[0].0.last().unwrap().x - 50.0).abs() < 1e-9);
assert!(trim(l.clone(), &Trim { end: 0.0, ..Trim::default() }).is_empty());
assert_eq!(trim(l.clone(), &Trim::default()), l);
// Slid past the end it wraps round to the start.
let w = trim(l, &Trim { start: 0.0, end: 0.4, offset: 0.8, each: false });
assert_eq!(w.len(), 2);
assert!((w[0].0[0].x - 80.0).abs() < 1e-9 && (w[1].0.last().unwrap().x - 20.0).abs() < 1e-9);
}

#[test]
fn trim_draws_subpaths_in_order_or_each() {
let two = vec![(vec![pt(0.0, 0.0), pt(100.0, 0.0)], false), (vec![pt(0.0, 10.0), pt(100.0, 10.0)], false)];
let first = trim(two.clone(), &Trim { end: 0.25, ..Trim::default() });
assert_eq!(first.len(), 1);
assert!((first[0].0.last().unwrap().x - 50.0).abs() < 1e-9);
let each = trim(two, &Trim { end: 0.25, each: true, ..Trim::default() });
assert_eq!(each.len(), 2);
assert!(each.iter().all(|(p, _)| (p.last().unwrap().x - 25.0).abs() < 1e-9));
// A closed square, window across its start: one unbroken piece.
let sq = vec![(vec![pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0), pt(0.0, 10.0)], true)];
let r = trim(sq, &Trim { start: 0.0, end: 0.5, offset: 0.75, each: false });
assert_eq!(r.len(), 1);
assert!((r[0].0[0].y - 10.0).abs() < 1e-9 && (r[0].0.last().unwrap().x - 10.0).abs() < 1e-9);
}

#[test]
fn zigzag_tips_alternate_at_amplitude() {
let c = line(pt(0.0, 0.0), pt(100.0, 0.0));
Expand Down
13 changes: 11 additions & 2 deletions crates/morpho-core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ pub enum Content {
/// Dashes or dots along the (squiggled) line.
#[serde(default)]
dash: Option<crate::along::Dash>,
/// Only part of the line drawn: animate it to draw the line on.
#[serde(default)]
trim: Option<crate::along::Trim>,
},
/// Freehand brush stroke: points `[x, y, pressure]` in layer pixels.
Stroke {
Expand Down Expand Up @@ -975,7 +978,7 @@ impl Engine {
normalize_winding(&mut poly);
(Geo { poly, curves: None }, em)
}
Content::Line { d, size, width, cap, squiggle, dash } => {
Content::Line { d, size, width, cap, squiggle, dash, trim } => {
let subs = svg::parse_subpaths(d)?;
let m = Affine::scale(*size, *size);
let tol = (width.abs() * 0.02).clamp(0.02, 1.0);
Expand All @@ -989,6 +992,12 @@ impl Engine {
}
})
.collect();
// A trimmed line keeps the whole centre line as its spine, so
// colour along it stays put while it draws on.
let whole = trim.as_ref().map(|_| lines.clone());
if let Some(tr) = trim {
lines = crate::along::trim(lines, tr);
}
if let Some(dd) = dash {
lines = lines
.into_iter()
Expand All @@ -1001,7 +1010,7 @@ impl Engine {
.collect();
}
// At most ~2 px cells: coarser fields show as steps along long lines.
spine = Some(lines.iter().map(|(l, closed)| {
spine = Some(whole.as_ref().unwrap_or(&lines).iter().map(|(l, closed)| {
let mut l = l.clone();
if *closed && l.first().zip(l.last()).is_some_and(|(a, b)| a.dist(*b) > 1e-9) {
l.push(l[0]);
Expand Down
148 changes: 148 additions & 0 deletions crates/morpho-core/src/modifiers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ fn full_turn() -> f64 {
fn twelve() -> f64 {
12.0
}
fn glitch_size() -> f64 {
0.05
}
fn glitch_spread() -> f64 {
0.3
}
fn glitch_bands() -> f64 {
0.12
}
fn echo_gap() -> f64 {
0.06
}
Expand Down Expand Up @@ -242,6 +251,28 @@ pub enum Modifier {
#[serde(default)]
seed: u32,
},
/// Digital glitch: square pixel debris scattered off the edges (and
/// bitten out of them), thinning out `spread` em away, and horizontal
/// bands of the shape torn sideways by up to `shift` em.
Glitch {
/// How much debris, 0..1; the same blocks stay as it grows.
#[serde(default = "half")]
amount: f64,
/// Block size in em.
#[serde(default = "glitch_size")]
size: f64,
/// How far debris reaches from the edge, in em.
#[serde(default = "glitch_spread")]
spread: f64,
/// Largest sideways tear of a band, in em.
#[serde(default)]
shift: f64,
/// Typical band height, in em.
#[serde(default = "glitch_bands")]
bands: f64,
#[serde(default)]
seed: u32,
},
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -597,6 +628,7 @@ pub fn apply(geo: Geo, m: &Modifier, ctx: &Ctx) -> Geo {
g
}
Modifier::Foam { amount, size, wall, spread, angle, seed } => Geo::poly_only(foam(&geo.poly, ctx, amount, size, wall, spread, angle, seed)),
Modifier::Glitch { amount, size, spread, shift, bands, seed } => Geo::poly_only(glitch(&geo.poly, ctx, amount, size, spread, shift, bands, seed)),
Modifier::Echo { count, spacing, width } => {
let n = (count.round() as usize).clamp(0, 40);
let (sp, hw) = (spacing.abs().max(0.002) * em, (width.abs() * em * 0.5).max(1e-6));
Expand Down Expand Up @@ -935,6 +967,105 @@ fn foam(s: &Shape, ctx: &Ctx, amount: f64, size: f64, wall: f64, spread: f64, an
extract(&v, &g, 0.0, g.cell * 0.15, g.cell * g.cell * 1.5)
}

/// Bilinear sample of a field at `p` (clamped to the grid).
fn sample(v: &[f32], g: &Grid, p: Pt) -> f32 {
let x = ((p.x - g.origin.x) / g.cell - 0.5).clamp(0.0, (g.w - 1) as f64);
let y = ((p.y - g.origin.y) / g.cell - 0.5).clamp(0.0, (g.h - 1) as f64);
let (i, j) = ((x as usize).min(g.w.saturating_sub(2)), (y as usize).min(g.h.saturating_sub(2)));
let (fx, fy) = ((x - i as f64) as f32, (y - j as f64) as f32);
let at = |i: usize, j: usize| v[j * g.w + i];
let top = at(i, j) + (at(i + 1, j) - at(i, j)) * fx;
let bot = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * fx;
top + (bot - top) * fy
}

/// See [`Modifier::Glitch`]. Works on the distance field: bands read it
/// shifted sideways, then debris blocks are unioned in and bites cut out
/// as exact box distances, so blocks keep square corners.
#[allow(clippy::too_many_arguments)]
fn glitch(s: &Shape, ctx: &Ctx, amount: f64, size: f64, spread: f64, shift: f64, bands: f64, seed: u32) -> Shape {
let bbox = s.bbox();
let em = ctx.em;
let amount = amount.clamp(0.0, 1.0);
let block = (size.abs() * em).max(em / 200.0);
let reach = spread.abs() * em;
let shift = shift.abs() * em;
if bbox.is_empty() || (amount < 1e-3 && shift < 1e-6) {
return s.clone();
}
let cell = ctx.cell().min(block / 4.0).max(em / 1024.0);
let pad = reach + shift + block * 2.0;
let g = Grid::covering(bbox, pad, cell);
let src = signed_distance(s, &g, pad + 3.0 * g.cell);
// Bands of random height; about half of them torn sideways by whole blocks.
let bh = (bands.abs() * em).max(g.cell * 2.0);
let (y0, y1) = (g.origin.y, g.origin.y + g.h as f64 * g.cell);
let mut cuts: Vec<(f64, f64)> = Vec::new();
let mut y = y0;
for k in 0..4096i64 {
if y >= y1 {
break;
}
y += bh * (0.35 + 1.3 * cell_hash(k, 7, seed));
let dx = if shift > 0.0 && cell_hash(k, 11, seed) < 0.45 { ((cell_hash(k, 13, seed) * 2.0 - 1.0) * shift / block).round() * block } else { 0.0 };
cuts.push((y, dx));
}
let dx_at = |y: f64| cuts.get(cuts.partition_point(|c| c.0 < y)).map_or(0.0, |c| c.1);
let tear = |f: &[f32]| -> Vec<f32> {
(0..g.len())
.map(|k| {
let p = g.sample_pos(k % g.w, k / g.w);
let dx = if shift > 0.0 { dx_at(p.y) } else { 0.0 };
if dx == 0.0 { f[k] } else { sample(f, &g, Pt { x: p.x - dx, y: p.y }) }
})
.collect()
};
let mut v = tear(&src);
if amount >= 1e-3 && reach > 0.0 {
// Debris breaks off the outside of the whole form: measured from its
// silhouette with the gaps between letters and their counters closed.
let r = 0.3 * em;
let closed = offset(&offset(s, r, ctx), -r, ctx);
let hull = tear(&signed_distance(&closed, &g, pad + 3.0 * g.cell));
let torn = v.clone();
let half = block * 0.5;
let (bi0, bj0) = ((g.origin.x / block).floor() as i64, (g.origin.y / block).floor() as i64);
let (bi1, bj1) = (((g.origin.x + g.w as f64 * g.cell) / block).ceil() as i64, ((g.origin.y + g.h as f64 * g.cell) / block).ceil() as i64);
for bj in bj0..bj1 {
for bi in bi0..bi1 {
let c = Pt { x: (bi as f64 + 0.5) * block, y: (bj as f64 + 0.5) * block };
let (d, inside) = (sample(&hull, &g, c) as f64, sample(&torn, &g, c) as f64);
// Debris comes in clumps (low-frequency noise over the block
// grid), densest against the edge, so it reads as stepped
// chunks breaking off rather than dust.
let n = 0.5 + 0.5 * crate::noise::noise2(bi as f64 * 0.23, bj as f64 * 0.23, seed);
let clump = ((n - 0.42) / 0.3).clamp(0.0, 1.0);
let r = cell_hash(bi, bj, seed.wrapping_add(1));
let near = (1.0 - d / reach).clamp(0.0, 1.0);
let add = d > 0.0 && d < reach && r < amount * clump * (0.08 + 2.2 * near * near);
let bite = inside <= 0.0 && -inside < block * 1.2 && d > -block * 1.5 && r < amount * 0.12 * clump * clump;
if !add && !bite {
continue;
}
let i0 = (((c.x - half - g.origin.x) / g.cell).floor() - 2.0).max(0.0) as usize;
let j0 = (((c.y - half - g.origin.y) / g.cell).floor() - 2.0).max(0.0) as usize;
let i1 = ((((c.x + half - g.origin.x) / g.cell).ceil() + 2.0).max(0.0) as usize).min(g.w);
let j1 = ((((c.y + half - g.origin.y) / g.cell).ceil() + 2.0).max(0.0) as usize).min(g.h);
for j in j0..j1 {
for i in i0..i1 {
let p = g.sample_pos(i, j);
let (qx, qy) = ((p.x - c.x).abs() - half, (p.y - c.y).abs() - half);
let bx = ((qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt() + qx.max(qy).min(0.0)) as f32;
let k = j * g.w + i;
v[k] = if add { v[k].min(bx) } else { v[k].max(-bx) };
}
}
}
}
}
extract(&v, &g, 0.0, g.cell * 0.15, g.cell * g.cell * 1.5)
}

#[allow(clippy::too_many_arguments)]
fn slice(geo: Geo, ctx: &Ctx, count: f64, gap: f64, angle: f64, ring: f64, hole: f64, jitter: f64, seed: u32, shift: (f64, f64)) -> Geo {
let bbox = geo.poly.bbox();
Expand Down Expand Up @@ -1372,6 +1503,23 @@ mod tests {
assert!((b.center().y - 10.0).abs() < 6.0, "{b:?}");
}

#[test]
fn glitch_adds_debris_and_tears_bands() {
let base = || Geo::poly_only(square(100.0));
let plain = apply(base(), &Modifier::Glitch { amount: 0.0, size: 0.05, spread: 0.3, shift: 0.0, bands: 0.12, seed: 1 }, &CTX).poly;
assert!((plain.area() - square(100.0).area()).abs() < 1.0);
let g = apply(base(), &Modifier::Glitch { amount: 1.0, size: 0.05, spread: 0.3, shift: 0.0, bands: 0.12, seed: 1 }, &CTX).poly;
assert!(g.contours.len() > 3, "debris blocks");
assert!(g.bbox().width() > 101.0, "debris reaches past the edge");
// Torn bands stick out sideways but keep the height.
let t = apply(base(), &Modifier::Glitch { amount: 0.0, size: 0.05, spread: 0.3, shift: 0.2, bands: 0.1, seed: 2 }, &CTX).poly;
assert!(t.bbox().width() > 102.0 && (t.bbox().height() - 100.0).abs() < 2.0, "{:?}", t.bbox());
// Growing the amount keeps the blocks it had: less debris reaches no farther.
let less = apply(base(), &Modifier::Glitch { amount: 0.4, size: 0.05, spread: 0.3, shift: 0.0, bands: 0.12, seed: 1 }, &CTX).poly;
let (a, b) = (less.bbox(), g.bbox());
assert!(a.x0 >= b.x0 - 0.5 && a.y0 >= b.y0 - 0.5 && a.x1 <= b.x1 + 0.5 && a.y1 <= b.y1 + 0.5, "{a:?} {b:?}");
}

#[test]
fn modifiers_deserialize_with_defaults() {
let m: Vec<ModEntry> = serde_json::from_str(r#"[{"kind":"weight","amount":0.05},{"kind":"stretch","x":1.2,"on":false}]"#).unwrap();
Expand Down
Loading
Loading