diff --git a/crates/morpho-core/src/along.rs b/crates/morpho-core/src/along.rs index 895739d..3a27bb6 100644 --- a/crates/morpho-core/src/along.rs +++ b/crates/morpho-core/src/along.rs @@ -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 { + 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, bool)>, t: &Trim) -> Vec<(Vec, 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, bool, Vec)> = 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> { + let mut pieces: Vec<(f64, Vec)> = 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, @@ -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)); diff --git a/crates/morpho-core/src/engine.rs b/crates/morpho-core/src/engine.rs index bf181fb..bf07718 100644 --- a/crates/morpho-core/src/engine.rs +++ b/crates/morpho-core/src/engine.rs @@ -112,6 +112,9 @@ pub enum Content { /// Dashes or dots along the (squiggled) line. #[serde(default)] dash: Option, + /// Only part of the line drawn: animate it to draw the line on. + #[serde(default)] + trim: Option, }, /// Freehand brush stroke: points `[x, y, pressure]` in layer pixels. Stroke { @@ -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); @@ -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() @@ -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]); diff --git a/crates/morpho-core/src/modifiers.rs b/crates/morpho-core/src/modifiers.rs index 0ed41cb..6ed5e27 100644 --- a/crates/morpho-core/src/modifiers.rs +++ b/crates/morpho-core/src/modifiers.rs @@ -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 } @@ -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)] @@ -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)); @@ -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 { + (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(); @@ -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 = serde_json::from_str(r#"[{"kind":"weight","amount":0.05},{"kind":"stretch","x":1.2,"on":false}]"#).unwrap(); diff --git a/ui/src/components/Inspector.tsx b/ui/src/components/Inspector.tsx index 7837615..7ba165f 100644 --- a/ui/src/components/Inspector.tsx +++ b/ui/src/components/Inspector.tsx @@ -4,7 +4,7 @@ import { addIconGrid, alignSelected, distributeSelected, editPoints, editWarp, f import { addCentreDot, addFace, variationsOf, type Variant } from '../app/assists'; import { arrange, DEFAULT_LAYOUT, type Layout, type LayoutKind } from '../app/layout'; import { run, shortcutOf } from '../app/commands'; -import type { Content, Dash, ModEntry, ModifierKind, Squiggle, SquiggleKind, SymbolKind } from '../engine/types'; +import type { Content, Dash, ModEntry, ModifierKind, Squiggle, SquiggleKind, SymbolKind, Trim } from '../engine/types'; import { backgroundCss, fillCss } from '../render/background'; import { effectById } from '../render/effects'; import { luminance, QUICK_COLORS, recolor } from '../render/palette'; @@ -14,7 +14,7 @@ import { BG_PRESETS, lookOf, SHAPE_RECIPES, type Recipe } from '../state/recipes import { edit, select, setSettings, store, toast, undo, useStore } from '../state/store'; import { applyIconTarget, ICON_TARGETS, iconTarget, type IconTarget } from '../state/icon'; import { allLayers, findNode } from '../state/tree'; -import { modAnimKey, MORPH_KEY, pruneGeoAnims, symAnimKey } from '../state/geoMotion'; +import { lineAnimKey, modAnimKey, MORPH_KEY, pruneGeoAnims, symAnimKey, TRIM_DEFAULT } from '../state/geoMotion'; import type { ParamAnim } from '../state/motion'; import { ColorChip, ColorField } from './ColorPicker'; import { FontPicker } from './FontPicker'; @@ -415,10 +415,13 @@ function BlendRow({ node }: { node: Node }) { const SQUIGGLE_DEFAULT: Squiggle = { kind: 'zigzag', amplitude: 20, spacing: 24, smooth: 0, round: 0, loops: 1.2, perSegment: true, flatEnds: false, flip: false }; const DASH_DEFAULT: Dash = { dash: 16, gap: 12, offset: 0 }; -/** Squiggles and dashes along a pen line: zigzag, wave, meander, coil, scallop; dashes or dots. */ -function LineAlong({ c, setC }: { c: Of<'line'>; setC: (fn: (c: Of) => void, key?: string) => void }) { +/** Squiggles and dashes along a pen line: zigzag, wave, meander, coil, scallop; dashes or dots. Then how much of it is drawn. */ +function LineAlong({ c, setC, anims }: { c: Of<'line'>; anims?: GeoAnims; setC: (fn: (c: Of) => void, key?: string) => void }) { const sq = c.squiggle ?? null; const da = c.dash ?? null; + const tr = c.trim ?? TRIM_DEFAULT; + const setTr = (fn: (t: Trim) => void, key = '') => setC<'line'>((x) => fn((x.trim ??= { ...TRIM_DEFAULT })), key); + const parts = (c.d.match(/[Mm]/g) ?? []).length; const setSq = (fn: (q: Squiggle) => void, key = '') => setC<'line'>((x) => x.squiggle && fn(x.squiggle), key); const setDa = (fn: (q: Dash) => void, key = '') => setC<'line'>((x) => x.dash && fn(x.dash), key); return ( @@ -458,6 +461,11 @@ function LineAlong({ c, setC }: { c: Of<'line'>; setC: setDa((q) => (q.offset = v), 'do')} /> )} +

Drawn: the part of the line that shows. Animate End (the diamond) to draw it on; Slide moves the drawn part along, round and round on a loop.

+ setTr((t) => (t.start = v), 'trs')} /> + setTr((t) => (t.end = v), 'tre')} /> + setTr((t) => (t.offset = v), 'tro')} /> + {parts > 1 && setTr((t) => (t.each = v))} />} ); } @@ -588,7 +596,7 @@ function ContentEditor({ layer, set }: { layer: ShapeLayer; set: (fn: (l: ShapeL setC<'line'>((x) => (x.cap = v))} /> setC<'line'>((x) => (x.size = v), 'ls')} /> - + diff --git a/ui/src/components/Keyframed.tsx b/ui/src/components/Keyframed.tsx index 85d1cd6..83151ef 100644 --- a/ui/src/components/Keyframed.tsx +++ b/ui/src/components/Keyframed.tsx @@ -53,6 +53,7 @@ export function Keyframed({ label, anim, from, color, range, start, set, childre { value: 'swing', label: 'Swing', title: 'There and back, easing at each end' }, { value: 'ramp', label: 'Ramp', title: 'Steadily to To, then round again: for angles, phases, shifts and hues' }, { value: 'flicker', label: 'Flicker', title: 'Jumps between steps from its value to To, at random: tiles, lights, screens' }, + { value: 'hold', label: 'Hold', title: 'Waits at its value, then goes to To and comes back: a line wiping off and drawing on, a puff, a burst of debris' }, ]} /> diff --git a/ui/src/components/canvas/Timeline.tsx b/ui/src/components/canvas/Timeline.tsx index 91ba78b..308f6a3 100644 --- a/ui/src/components/canvas/Timeline.tsx +++ b/ui/src/components/canvas/Timeline.tsx @@ -801,7 +801,7 @@ function CycleClip({ row, k, ctx, open }: { row: Node; k: Extract]?.unit ?? '') : ''; const arrow = k.kind === 'loop' && k.motion.loop === 'slide' ? ` ${{ left: '←', right: '→', up: '↑', down: '↓' }[k.motion.dir ?? 'right']}${k.motion.oneWay ? ' one way' : ''}` : ''; - const what = k.kind === 'loop' ? `${k.label}${arrow} ${+Math.abs(k.motion.amount).toFixed(1)}${unit}` : `${k.anim.mode === 'swing' ? 'Swing' : k.anim.mode === 'ramp' ? 'Ramp' : 'Flicker'} to ${typeof k.anim.to === 'number' ? +k.anim.to.toFixed(2) : k.anim.to}`; + const what = k.kind === 'loop' ? `${k.label}${arrow} ${+Math.abs(k.motion.amount).toFixed(1)}${unit}` : `${{ swing: 'Swing', ramp: 'Ramp', flicker: 'Flicker', hold: 'Hold' }[k.anim.mode]} to ${typeof k.anim.to === 'number' ? +k.anim.to.toFixed(2) : k.anim.to}`; return (
@@ -941,6 +941,7 @@ function ParamAdvanced({ anim, set }: { anim: ParamAnim; set: (fn: (a: ParamAnim { value: 'swing', label: 'Swing', title: 'There and back, easing at each end' }, { value: 'ramp', label: 'Ramp', title: 'Steadily to To, then round again' }, { value: 'flicker', label: 'Flicker', title: 'Jumps between steps at random' }, + { value: 'hold', label: 'Hold', title: 'Waits, then goes to To and comes back' }, ]} />
diff --git a/ui/src/components/pickers.tsx b/ui/src/components/pickers.tsx index d47a700..95c4b6b 100644 --- a/ui/src/components/pickers.tsx +++ b/ui/src/components/pickers.tsx @@ -54,6 +54,7 @@ const SAMPLE_MOD: Partial> = { echo: { count: 2, spacing: 0.08, width: 0.03 }, pixelate: { size: 0.07, shape: 'square', gap: 0.12, dissolve: 0.7 }, foam: { amount: 0.8, size: 0.2, wall: 0.015 }, + glitch: { amount: 0.8, size: 0.08, spread: 0.35, shift: 0.1, bands: 0.15, seed: 3 }, isometric: { face: 'left' }, }; diff --git a/ui/src/engine/types.ts b/ui/src/engine/types.ts index 1615d6b..39362ec 100644 --- a/ui/src/engine/types.ts +++ b/ui/src/engine/types.ts @@ -40,7 +40,8 @@ export type Modifier = | { kind: 'fuse'; amount: number } | { kind: 'fitCircle'; amount: number } | { kind: 'isometric'; face: IsoFace } - | { kind: 'foam'; amount: number; size: number; wall: number; spread: number; angle: number; seed: number }; + | { kind: 'foam'; amount: number; size: number; wall: number; spread: number; angle: number; seed: number } + | { kind: 'glitch'; amount: number; size: number; spread: number; shift: number; bands: number; seed: number }; export type RepeatMode = 'radial' | 'grid' | 'trail'; @@ -113,7 +114,7 @@ export type Content = /** Freehand brush stroke; points are [x, y, pressure] in layer pixels. */ | { type: 'stroke'; points: [number, number, number][]; width: number; taper: number; smooth: number } /** Pen line: path data in em units × size; open subpaths are stroked `width` px wide. */ - | { type: 'line'; d: string; size: number; width: number; cap: LineCap; squiggle?: Squiggle | null; dash?: Dash | null }; + | { type: 'line'; d: string; size: number; width: number; cap: LineCap; squiggle?: Squiggle | null; dash?: Dash | null; trim?: Trim | null }; export type LineCap = 'round' | 'butt' | 'square'; @@ -139,6 +140,18 @@ export interface Squiggle { flip: boolean; } +/** + * Only part of a pen line drawn, from `start` to `end` (0…1 of its length), + * slid along by `offset` (wrapping round): animate End to draw it on. + * Subpaths draw one after another, or each at once with `each`. + */ +export interface Trim { + start: number; + end: number; + offset: number; + each: boolean; +} + /** Dashes along a pen line (px); a 0 dash with round ends draws dots. */ export interface Dash { dash: number; diff --git a/ui/src/render/effects.test.ts b/ui/src/render/effects.test.ts index 601862e..b749f95 100644 --- a/ui/src/render/effects.test.ts +++ b/ui/src/render/effects.test.ts @@ -35,6 +35,15 @@ describe('effects', () => { } }); + it('Form › Tube asks the height pass for the tube profile', () => { + const withForm = EFFECTS.filter((e) => e.params.some((p) => p.key === 'form')); + expect(withForm.map((e) => e.id)).toEqual(expect.arrayContaining(['chrome', 'inflate', 'vinyl'])); + for (const e of withForm) { + expect(e.height?.({ ...defaultParams(e), form: 1 }).profile, e.id).toBe(3); + expect(e.height?.({ ...defaultParams(e), form: 0 }).profile, e.id).toBe(0); + } + }); + it('presets only set parameters their effect has', () => { for (const preset of PRESETS) { const e = effectById(preset.effect); diff --git a/ui/src/render/effects.ts b/ui/src/render/effects.ts index 613d279..5a14bba 100644 --- a/ui/src/render/effects.ts +++ b/ui/src/render/effects.ts @@ -12,7 +12,7 @@ export type ParamDef = export type ParamValues = Record; export interface HeightSpec { - /** 0 round, 1 chamfer, 2 smooth. */ + /** 0 round, 1 chamfer, 2 smooth, 3 tube (each part rounded across its own width, up to the bevel). */ profile: number; bevel: number; softness: number; @@ -59,6 +59,15 @@ const choice = (key: string, label: string, options: string[], def: number): Par const n = (p: ParamValues, k: string) => Number(p[k] ?? 0); +/** + * How a 3D material rounds the shape (param `form`): Bevel rounds a band + * along the outline; Tube rounds every part across its own width, so thin + * spikes become cones and strokes become tubes, and wide parts still get a + * Bevel-wide shoulder. + */ +const FORMS = ['Bevel', 'Tube']; +const formProfile = (p: ParamValues) => (n(p, 'form') > 0.5 ? 3 : 0); + export const COMMON_GLSL = /* glsl */ ` uniform sampler2D u_mask; uniform sampler2D u_sdf; @@ -158,6 +167,7 @@ const chrome: EffectDef = { col('c3', 'Light', '#7cc8ff'), col('c4', 'Highlight', '#ffffff'), num('bevel', 'Bevel', 2, 200, 1, 46), + choice('form', 'Form', FORMS, 0), num('softness', 'Softness', 0, 80, 1, 10, true), num('depth', 'Depth', 0.1, 4, 0.05, 1.3, true), num('liquid', 'Liquid', 0, 1.5, 0.01, 0.6), @@ -167,7 +177,7 @@ const chrome: EffectDef = { num('rim', 'Rim', 0, 12, 0.1, 2.2, true), col('rimColor', 'Rim colour', '#bfe3ff', true), ], - height: (p) => ({ profile: 0, bevel: n(p, 'bevel'), softness: n(p, 'softness') }), + height: (p) => ({ profile: formProfile(p), bevel: n(p, 'bevel'), softness: n(p, 'softness') }), animated: (p) => n(p, 'speed') > 0, reach: () => 4, glsl: /* glsl */ ` @@ -264,6 +274,7 @@ const inflate: EffectDef = { col('body', 'Body', '#eef2ff'), col('shade', 'Shade', '#8f9ee6'), num('bevel', 'Puff', 4, 300, 1, 90), + choice('form', 'Form', FORMS, 0), num('softness', 'Softness', 0, 80, 1, 14, true), num('depth', 'Depth', 0.1, 3, 0.05, 0.9, true), num('gloss', 'Gloss', 0, 1.5, 0.01, 0.45), @@ -271,23 +282,59 @@ const inflate: EffectDef = { num('shadow', 'Shadow', 0, 1, 0.01, 0.4), num('shadowOffset', 'Shadow offset', 0, 120, 1, 24, true), num('shadowSoft', 'Shadow blur', 1, 200, 1, 40, true), + choice('surface', 'Surface', ['Smooth', 'Contour lines', 'Speckles'], 0), + col('marks', 'Marks colour', '#ffffff'), + num('marksAmount', 'Marks', 0, 1, 0.01, 0.8), + num('marksSize', 'Mark size', 4, 400, 1, 70), + num('marksWidth', 'Line width', 0.2, 16, 0.1, 1.6), + num('marksFlow', 'Flow', -10, 10, 0.01, 0), ], - height: (p) => ({ profile: 0, bevel: n(p, 'bevel'), softness: n(p, 'softness') }), + height: (p) => ({ profile: formProfile(p), bevel: n(p, 'bevel'), softness: n(p, 'softness') }), reach: (p) => n(p, 'shadowOffset') + n(p, 'shadowSoft') * 4, glsl: /* glsl */ ` -uniform vec3 u_body, u_shade; +uniform vec3 u_body, u_shade, u_marks; uniform float u_depth, u_gloss, u_rimTint, u_shadow, u_shadowOffset, u_shadowSoft; +uniform float u_surface, u_marksAmount, u_marksSize, u_marksWidth, u_marksFlow; +/** Topographic lines: level curves of a slow noise, lifted by the puff so they wrap round the form. */ +float contours(vec2 uv) { + vec2 p = artPx(uv) / max(u_marksSize * 5.0, 1.0); + // About one line per Mark size across the noise's slopes. + float v = (fbm3(p + vec2(u_marksFlow * 0.13, u_marksFlow * 0.05)) * 2.4 + 0.5 * H(uv)) * 9.0 + u_marksFlow * 0.25; + float d = abs(fract(v + 0.5) - 0.5) / max(fwidth(v), 1e-4); + return 1.0 - clamp(d - u_marksWidth * u_k * 0.5 + 0.5, 0.0, 1.0); +} +/** Flecks of different sizes, two offset grids of one random dot per cell. */ +float speckles(vec2 uv) { + vec2 p = artPx(uv) + vec2(u_marksFlow * 7.0, 0.0); + float s = max(u_marksSize * 0.5, 1.0), a = 0.0; + for (int k = 0; k < 2; k++) { + vec2 q = p / s + float(k) * vec2(0.5, 0.37); + vec2 cell = floor(q); + float h = hash12(cell + float(k) * 17.0); + if (h < u_marksAmount * 0.9) { + vec2 at = cell + 0.25 + 0.5 * vec2(hash12(cell + 3.1), hash12(cell + 7.7)); + float r = (0.06 + 0.2 * pow(hash12(cell + 11.3), 2.0)) * (0.6 + u_marksWidth * 0.25); + float d = (length(q - at) - r) * s * u_k; + a = max(a, clamp(0.5 - d, 0.0, 1.0) * (0.65 + 0.35 * hash12(cell + 5.3))); + } + } + return a; +} vec4 effect(vec2 uv) { float sd = sdfArt(uv - offsetUv(vec2(0.0, u_shadowOffset))); float sh = exp(-max(sd, 0.0) / max(u_shadowSoft, 1.0)) * u_shadow * 0.6; vec4 shadow = vec4(0.0, 0.0, 0.0, sh); float dt = sdfPx(uv); float cov = cover(dt); + // Marks before the early return: contour lines take derivatives. + float marks = u_surface > 1.5 ? speckles(uv) : u_surface > 0.5 ? contours(uv) * u_marksAmount : 0.0; if (cov <= 0.0) return shadow; vec3 n = normalAt(uv, u_depth); vec3 L = normalize(vec3(-0.35, -0.7, 0.62)); float wrap = clamp(dot(n, L) * 0.6 + 0.45, 0.0, 1.0); vec3 c = mix(u_shade, u_body, smoothstep(0.0, 1.0, wrap)); + // Printed on the skin: shaded with it, so marks darken on the shadow side. + c = mix(c, u_marks * mix(0.72, 1.06, wrap), marks); float fres = pow(1.0 - clamp(n.z, 0.0, 1.0), 2.5); c = mix(c, u_ambient * 1.15, fres * u_rimTint); float spec = pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 16.0) * u_gloss; @@ -603,11 +650,17 @@ const flat: EffectDef = { num('shadowBlur', 'Shadow blur', 0, 100, 0.5, 14, true), col('shadowColor', 'Shadow colour', '#000000', true), num('texture', 'Print texture', 0, 1, 0.01, 0), + num('bloom', 'Glow', 0, 2, 0.01, 0), + num('bloomRadius', 'Glow radius', 2, 400, 1, 46), + num('bloomEdge', 'Glowing edge', 0, 1, 0.01, 0.5, true), ], - reach: (p) => n(p, 'outline') + Math.max(Math.abs(n(p, 'shadowX')), Math.abs(n(p, 'shadowY'))) + n(p, 'shadowBlur') * 2, + reach: (p) => + Math.max(n(p, 'outline') + Math.max(Math.abs(n(p, 'shadowX')), Math.abs(n(p, 'shadowY'))) + n(p, 'shadowBlur') * 2, n(p, 'bloom') > 0 ? n(p, 'bloomRadius') * 3.5 : 0), glsl: /* glsl */ ` uniform vec3 u_fill, u_fill2, u_outlineColor, u_shadowColor; -uniform float u_gradient, u_angle, u_gradType, u_gradX, u_gradY, u_gradSize, u_gradStart, u_gradEnd, u_outline, u_shadowX, u_shadowY, u_shadowBlur, u_shadowOpacity, u_texture; +uniform float u_gradient, u_angle, u_gradType, u_gradX, u_gradY, u_gradSize, u_gradStart, u_gradEnd, u_outline, u_shadowX, u_shadowY, u_shadowBlur, u_shadowOpacity, u_texture, u_bloom, u_bloomRadius, u_bloomEdge; +/** Share of a blurred straight edge still lit at distance x (in blur widths): a logistic fit to the Gaussian's tail. */ +float edgeBlur(float x) { return 1.0 / (1.0 + exp(1.702 * x)); } vec4 effect(vec2 uv) { float dt = sdfPx(uv); // Blur 0 gives a hard offset shadow, as in flat "neo-brutalist" graphics. @@ -631,6 +684,16 @@ vec4 effect(vec2 uv) { colr *= 1.0 - u_texture * (0.1 * fbm3(p / 40.0) + 0.06 * hash12(floor(p * u_k))); c *= 1.0 - u_texture * 0.35 * smoothstep(0.62, 0.8, fbm(p / 5.0)) * smoothstep(-6.0, 0.0, dt / u_k); } + if (u_bloom > 0.0) { + // Glow: the shape blurred at two sizes, a tight halo and a wide haze, in + // a lighter fill colour; partly added light, so it brightens dark canvases. + float d = dt / u_k, r = max(u_bloomRadius, 1.0); + float g = (0.6 * edgeBlur(d / (r * 0.22)) + 0.4 * edgeBlur(d / r)) * 2.0 * u_bloom; + vec3 gc = mix(colr, vec3(1.0), 0.18 + 0.3 * exp(-max(d, 0.0) / (r * 0.15)) * u_bloomEdge); + f = over(vec4(gc * min(g, 1.5), clamp(g * 0.8, 0.0, 1.0)), f); + // Lit from inside: the fill pales toward its edge. + colr = mix(colr, mix(colr, vec3(1.0), 0.55), u_bloomEdge * min(u_bloom, 1.0) * exp(min(d, 0.0) / (r * 0.18))); + } return over(vec4(colr * c, c), f); }`, }; @@ -838,12 +901,36 @@ const photo: EffectDef = { num('brightness', 'Brightness', -1, 1, 0.01, 0), num('saturation', 'Saturation', 0, 2, 0.01, 1, true), num('grain', 'Grain', 0, 1, 0.01, 0), + choice('screen', 'Print screen', ['Smooth', 'Halftone dots', 'Gritty dither', 'Line screen'], 0), + num('screenSize', 'Screen size', 1, 40, 0.5, 6), ], reach: () => 1, glsl: /* glsl */ ` uniform sampler2D u_photo; -uniform float u_hasPhoto, u_imgAspect, u_zoom, u_x, u_y, u_duotone, u_contrast, u_brightness, u_saturation, u_grain; +uniform float u_hasPhoto, u_imgAspect, u_zoom, u_x, u_y, u_duotone, u_contrast, u_brightness, u_saturation, u_grain, u_screen, u_screenSize; uniform vec3 u_dark, u_light; +/** + * Printed with a screen: how much ink lands at this pixel for a tone (0 none, + * 1 solid). Dots grow with the tone, dither fills a share of random pixels + * (gritty, like a cheap photocopy), lines thicken. + */ +float screened(vec2 p, float tone) { + float s = max(u_screenSize, 1.0); + float aa = 0.75 / u_k; + if (u_screen < 1.5) { + vec2 q = mat2(0.7071, -0.7071, 0.7071, 0.7071) * p / s; + float r = length(fract(q) - 0.5) * s; + return 1.0 - smoothstep(-aa, aa, r - sqrt(clamp(tone, 0.0, 1.0)) * 0.56 * s); + } + if (u_screen < 2.5) { + // Clumped noise: a fine speck on a coarser mottle, so it reads as grit, not TV static. + vec2 c = floor(p * u_k / max(s * 0.35, 1.0)); + float th = hash12(c) * 0.6 + vnoise(p / (s * 1.6)) * 0.4; + return step(th, tone); + } + float y = abs(fract(p.y / s) - 0.5) * s; + return 1.0 - smoothstep(-aa, aa, y - clamp(tone, 0.0, 1.0) * 0.5 * s); +} vec4 effect(vec2 uv) { float cov = cover(sdfPx(uv)); vec2 box = (u_bbox.zw - u_bbox.xy) * u_region.zw; @@ -862,6 +949,14 @@ vec4 effect(vec2 uv) { l = clamp(dot(c, vec3(0.299, 0.587, 0.114)), 0.0, 1.0); c = mix(c, mix(u_dark, u_light, smoothstep(0.0, 1.0, l)), u_duotone); c += (hash12(floor(artPx(uv) * u_k)) - 0.5) * u_grain * 0.25; + if (u_screen > 0.5) { + // A screened print: each colour channel on its own, or for a duotone two + // flat inks (the highlight ink printed over the shadow ink). + vec2 p = artPx(uv); + vec3 rgb = vec3(screened(p, c.r), screened(p + 0.37 * u_screenSize, c.g), screened(p - 0.29 * u_screenSize, c.b)); + vec3 two = mix(u_dark, u_light, screened(p, l)); + c = mix(rgb, two, u_duotone); + } return vec4(clamp(c, 0.0, 1.0) * cov, cov); }`, }; @@ -1694,6 +1789,235 @@ vec4 effect(vec2 uv) { }`, }; +const vinyl: EffectDef = { + id: 'vinyl', + name: 'Inflatable vinyl', + description: 'Glossy blow-up plastic: every part rounded into a tube, saturated colour lit through the skin, crisp studio highlights, pinched rings and wrinkles. Can be clear plastic that shows what is under it.', + backdrop: true, + params: [ + col('body', 'Colour', '#4dff2e'), + col('shade', 'Shadow colour', '#0e8f1a'), + num('bevel', 'Puff', 4, 400, 1, 140), + choice('form', 'Form', FORMS, 1), + num('softness', 'Smooth joins', 0, 80, 1, 8, true), + num('depth', 'Depth', 0.1, 3, 0.05, 1, true), + num('gloss', 'Gloss', 0, 1.5, 0.01, 1), + num('sharp', 'Crisp highlights', 0, 1, 0.01, 0.75), + num('lightAngle', 'Light angle', -180, 180, 1, -115), + num('through', 'Light through', 0, 1, 0.01, 0.5), + num('rim', 'Reflections', 0, 1.5, 0.01, 0.7), + col('env', 'Reflected room', '#ffffff', true), + choice('rings', 'Pinched rings', ['None', 'Round the centre', 'Across the tubes'], 0), + num('ringGap', 'Ring spacing', 6, 400, 1, 46), + num('pinch', 'Pinch', 0, 1, 0.01, 0.6), + num('wrinkles', 'Wrinkles', 0, 1, 0.01, 0.2), + num('wrinkleSize', 'Wrinkle size', 2, 200, 0.5, 22, true), + num('seam', 'Edge seam', 0, 1, 0.01, 0.35), + num('clear', 'Clear plastic', 0, 1, 0.01, 0), + num('shadow', 'Shadow', 0, 1, 0.01, 0.35), + num('shadowOffset', 'Shadow offset', 0, 120, 1, 22, true), + num('shadowSoft', 'Shadow blur', 1, 200, 1, 34, true), + ], + height: (p) => ({ profile: formProfile(p), bevel: n(p, 'bevel'), softness: n(p, 'softness') }), + reach: (p) => n(p, 'shadowOffset') + n(p, 'shadowSoft') * 4, + glsl: /* glsl */ ` +uniform vec3 u_body, u_shade, u_env; +uniform float u_depth, u_gloss, u_sharp, u_lightAngle, u_through, u_rim, u_rings, u_ringGap, u_pinch, u_wrinkles, u_wrinkleSize, u_seam, u_clear, u_shadow, u_shadowOffset, u_shadowSoft; +/** How far the shape runs from p (art px) in one direction, by sphere tracing its distance field. */ +float run(vec2 uv, vec2 dir) { + float t = 0.0; + for (int i = 0; i < 14; i++) { + float d = sdfArt(uv + offsetUv(dir * t)); + if (d > 0.0) break; + t += max(-d, 1.5); + } + return t; +} +/** + * Pinched rings: where along the tube a pixel is (art px) and the tube's + * direction there. Round the centre, rings are circles about the middle (for + * stars and bursts); across the tubes, each part runs along whichever axis + * the shape is longer in at that pixel (for hashes, letters, frames). + */ +vec2 ringAxis(vec2 uv, out float along) { + vec2 p = artPx(uv); + if (u_rings < 1.5) { + vec2 c = p - artPx((u_bbox.xy + u_bbox.zw) * 0.5); + along = length(c); + return c / max(along, 1e-3); + } + float lx = run(uv, vec2(1.0, 0.0)) + run(uv, vec2(-1.0, 0.0)); + float ly = run(uv, vec2(0.0, 1.0)) + run(uv, vec2(0.0, -1.0)); + along = lx > ly ? p.x : p.y; + return lx > ly ? vec2(1.0, 0.0) : vec2(0.0, 1.0); +} +/** Ridged noise: thin creases where crumpled plastic folds. */ +float crease(vec2 q) { return 1.0 - abs(2.0 * fbm3(q) - 1.0); } +vec4 effect(vec2 uv) { + float sd = sdfArt(uv - offsetUv(vec2(0.0, u_shadowOffset))); + float sh = exp(-max(sd, 0.0) / max(u_shadowSoft, 1.0)) * u_shadow * 0.55; + // The shadow takes a little of the plastic's colour (light passes through it). + vec4 shadow = vec4(u_shade * 0.3 * sh, sh); + float dt = sdfPx(uv); + float d = dt / u_k; + float cov = cover(dt); + if (cov <= 0.0) return shadow; + vec3 n = normalAt(uv, u_depth); + float h = H(uv); + // Pinched rings: a groove across the tube every Ring spacing; the skin + // bulges between them, so the normal tips along the tube into each groove. + float groove = 0.0; + if (u_rings > 0.5 && u_pinch > 0.0) { + float along; + vec2 ax = ringAxis(uv, along); + float gap = max(u_ringGap, 1.0); + float f = (fract(along / gap + 0.5) - 0.5) * gap; + float w = gap * 0.14; + groove = exp(-f * f / (w * w)) * u_pinch; + // No bull's-eye in the middle of a star: rings start a ring out. + if (u_rings < 1.5) groove *= smoothstep(gap * 0.5, gap * 2.2, along); + // Slope of the groove's flank along the tube (a Gaussian's derivative), deeper on fatter parts. + float slope = 2.0 * f / (w * w) * groove * w * 1.4 * smoothstep(0.0, 0.35, h); + n = normalize(n + vec3(ax * slope, 0.0)); + } + // Wrinkles: ridged creases, strongest near the outline and in the grooves where the skin bunches. + if (u_wrinkles > 0.0) { + vec2 q = artPx(uv) / max(u_wrinkleSize, 0.5); + float e = 0.35; + float c0 = crease(q); + vec2 g = vec2(crease(q + vec2(e, 0.0)) - c0, crease(q + vec2(0.0, e)) - c0) / e; + float amt = u_wrinkles * (0.2 + 0.8 * (1.0 - smoothstep(0.05, 0.6, h)) + groove) * 0.12; + n = normalize(n - vec3(g * amt, 0.0)); + } + float la = radians(u_lightAngle); + vec3 L = normalize(vec3(cos(la) * 0.85, sin(la) * 0.85, 0.45)); + float ndl = dot(n, L); + // Three tones across the form: the deep shadow colour, the body, and a pale + // lit side. Thin plastic is lit through, so the shadow side stays rich, never grey. + float t = ndl * 0.5 + 0.5; + vec3 pale = mix(u_body, vec3(1.0), 0.5); + vec3 c = mix(u_shade, u_body, smoothstep(0.18, 0.78, t)); + c = mix(c, pale, smoothstep(0.78, 1.0, t) * 0.75); + vec3 rich = clamp(u_body * u_body * 1.35, 0.0, 1.0); + c = mix(c, rich, u_through * 0.6 * (1.0 - smoothstep(0.2, 0.6, t)) * smoothstep(0.15, 0.6, 1.0 - n.z)); + // Seen edge-on the light crosses more coloured skin: the sides of every tube deepen. + c = mix(c, mix(u_shade, rich, u_through), smoothstep(0.15, 0.85, 1.0 - n.z) * 0.45); + // Grooves catch shade. + c *= 1.0 - groove * 0.4; + // Clear plastic: what's under it, bent by the surface and tinted; thicker + // (more colour) where the skin is seen edge-on. + if (u_clear > 0.0) { + vec3 under = behind(uv + offsetUv(-n.xy * 22.0)).rgb; + float edgeOn = pow(1.0 - n.z, 1.5); + vec3 t = under * mix(vec3(1.0), u_body, 0.45) * (1.0 - 0.35 * edgeOn) + u_body * edgeOn * 0.5; + c = mix(c, t, u_clear); + } + // Clearcoat: a little of the room reflected face-on, much more edge-on. + vec3 R = reflect(vec3(0.0, 0.0, -1.0), n); + float fres = 0.04 + 0.96 * pow(1.0 - clamp(n.z, 0.0, 1.0), 4.0); + // The room: bright above the horizon, the floor below in the plastic's own shadow colour. + vec3 room = mix(u_shade * 0.55, u_env, smoothstep(-0.3, 0.3, -R.y)); + c = mix(c, room, clamp(fres * u_rim, 0.0, 0.6)); + // Studio softbox: a crisp-edged rectangle of light mirrored in the gloss. + vec3 T1 = normalize(vec3(-L.y, L.x, 0.0) + 1e-5); + vec3 T2 = cross(L, T1); + // Crisp across, fading toward its ends, like a strip light seen in a curved surface. + float across = abs(dot(R, T1)) / 0.24, lengthwise = dot(R, T2) / 0.5; + float edgeW = mix(0.7, 0.08, u_sharp); + float soft = (1.0 - smoothstep(1.0 - edgeW, 1.0 + edgeW, across)) * exp(-lengthwise * lengthwise * 1.5) * step(0.0, dot(R, L)); + // A small hard light too, and a bounce from the floor along the lower rims. + float pt = pow(max(dot(R, normalize(L + vec3(0.25, -0.1, 0.3))), 0.0), mix(60.0, 400.0, u_sharp)); + float bounce = smoothstep(0.3, 0.9, R.y) * smoothstep(0.2, 0.6, 1.0 - n.z); + c += (u_env * (soft * (0.6 + 0.3 * dot(R, L)) + pt * 0.9) + u_env * bounce * 0.25 * u_rim) * u_gloss; + // The welded seam: a fine groove just inside the outline, in the deep colour. + float seamD = d + 2.5; + c = mix(c, u_shade * 0.8, u_seam * 0.6 * exp(-seamD * seamD / 1.4)); + return over(vec4(clamp(c, 0.0, 1.0) * cov, cov), shadow); +}`, +}; + +const paper: EffectDef = { + id: 'paper', + name: 'Folded paper', + description: 'A sheet of paper folded into panels, with crisp creases worn pale, soft crumples, mottling and fibres. Turn the paper off and lay it over a whole poster to fold the design itself.', + params: [ + col('color', 'Paper', '#f1eadb'), + num('paperAlpha', 'Paper opacity', 0, 1, 0.01, 1), + num('foldsX', 'Folds across', 0, 8, 1, 1), + num('foldsY', 'Folds down', 0, 8, 1, 2), + num('crease', 'Crease depth', 0, 1, 0.01, 0.55), + num('wear', 'Worn creases', 0, 1, 0.01, 0.5), + num('crumple', 'Crumple', 0, 1, 0.01, 0.3), + num('crumpleSize', 'Crumple size', 20, 1000, 1, 220, true), + num('mottle', 'Mottling', 0, 1, 0.01, 0.35), + num('grain', 'Grain', 0, 1, 0.01, 0.45), + num('lightAngle', 'Light angle', -180, 180, 1, -125), + num('strength', 'Light and shade', 0, 2, 0.01, 1), + num('seed', 'Seed', 0, 100, 1, 5, true), + ], + reach: () => 1, + glsl: /* glsl */ ` +uniform vec3 u_color; +uniform float u_paperAlpha, u_foldsX, u_foldsY, u_crease, u_wear, u_crumple, u_crumpleSize, u_mottle, u_grain, u_lightAngle, u_strength, u_seed; +/** + * One set of parallel folds across axis x of the box (q 0..1, size in px): + * adds the slope of the panels either side of each crease to grad and + * returns how close the pixel is to a crease (1 on it). Folds sit evenly + * spaced, nudged and tilted a little each, and alternate mountain and valley. + */ +float folds(vec2 q, vec2 size, float count, float seed, inout float grad) { + float line = 0.0; + for (int i = 1; i <= 8; i++) { + float fi = float(i); + if (fi > count) break; + float at = fi / (count + 1.0) + (hash11(fi * 7.3 + seed) - 0.5) * 0.06 / (count + 1.0); + float tilt = (hash11(fi * 3.1 + seed * 1.7) - 0.5) * 0.02; + // A folded sheet never lies quite straight: the crease wanders a pixel or two. + float x = (q.x - at - tilt * (q.y - 0.5)) * size.x + (vnoise(vec2(q.y * size.y / 90.0, fi + seed)) - 0.5) * 2.5; + float side = mod(fi + floor(seed), 2.0) < 0.5 ? 1.0 : -1.0; + // A ridge (or groove) along the crease that flattens out into the panels: + // sharp light and shade at the fold, a faint warp further away. + grad += side * x / (x * x + 6.0); + line = max(line, exp(-x * x / 2.2)); + } + return line; +} +vec4 effect(vec2 uv) { + float cov = cover(sdfPx(uv)); + if (cov <= 0.0) return vec4(0.0); + vec2 q = boxUv(uv); + vec2 size = (u_bbox.zw - u_bbox.xy) * u_region.zw; + vec2 p = artPx(uv); + float gx = 0.0, gy = 0.0; + float line = folds(q, size, u_foldsX, u_seed, gx); + line = max(line, folds(q.yx, size.yx, u_foldsY, u_seed + 31.0, gy)); + vec2 grad = vec2(gx, gy) * u_crease; + // Crumples: soft dents and bumps (slope of a slow noise). + if (u_crumple > 0.0) { + vec2 c = p / u_crumpleSize + u_seed; + float e = 0.02; + float c0 = fbm3(c); + grad += vec2(fbm3(c + vec2(e, 0.0)) - c0, fbm3(c + vec2(0.0, e)) - c0) / e * 0.05 * u_crumple; + } + vec3 n = normalize(vec3(-grad, 1.0)); + float la = radians(u_lightAngle); + vec3 L = normalize(vec3(cos(la) * 0.7, sin(la) * 0.7, 0.7)); + // Light and shade relative to a flat sheet: positive lightens, negative darkens. + float lit = (dot(n, L) - L.z) * 2.0 * u_strength; + // Creases: fibres crack and show pale along the ridge (its slope already lights one side, shades the other). + lit += line * u_wear * 0.35 * u_crease; + // Mottling (uneven stock, old stains) and fibres and specks. + lit += (fbm3(p / 140.0 + u_seed * 3.0) - 0.5) * 0.16 * u_mottle; + float fib = vnoise(vec2(p.x / 9.0, p.y / 1.6) + u_seed) - 0.5; + lit += ((hash12(floor(p * u_k) + u_seed) - 0.5) * 0.08 + fib * 0.05) * u_grain; + // The paper itself, then the light and shade on top as white or black. + vec4 base = vec4(u_color * u_paperAlpha, u_paperAlpha); + float a = clamp(abs(lit), 0.0, 1.0); + vec4 o = over(vec4(vec3(lit > 0.0 ? a : 0.0), a), base); + return o * cov; +}`, +}; + const keycap: EffectDef = { id: 'keycap', name: 'Keycap', @@ -1845,7 +2169,7 @@ vec4 effect(vec2 uv) { }`, }; -export const EFFECTS: EffectDef[] = [flat, chrome, glass, inflate, sticker, aura, holo, extrude, icon, neon, distressed, emboss, halftone, riso, dither, photo, frost, lens, thermal, prism, colorDots, plush, glow, grainy, mosaic, keycap, patternFx, ribbon]; +export const EFFECTS: EffectDef[] = [flat, chrome, glass, inflate, sticker, aura, holo, extrude, icon, neon, distressed, emboss, halftone, riso, dither, photo, frost, lens, thermal, prism, colorDots, plush, glow, grainy, mosaic, keycap, vinyl, paper, patternFx, ribbon]; export function effectById(id: string): EffectDef { return EFFECTS.find((e) => e.id === id) ?? flat; @@ -1939,4 +2263,16 @@ export const PRESETS: Preset[] = [ { name: 'Colour dots', effect: 'colordots', params: {}, bg: '#000000', tags: 'halftone gradient dot screen led' }, { name: 'Plush pink', effect: 'plush', params: {}, bg: '#3ecf3e', tags: 'fuzzy toy fabric fur' }, { name: 'Plush moss', effect: 'plush', params: { color: '#5ccf3a', shade: '#1f6b12' }, bg: '#151515', tags: 'fuzzy toy fabric fur green' }, + { name: 'Lime vinyl', effect: 'vinyl', params: {}, bg: '#f2f2f0', tags: 'inflatable balloon blow-up pvc plastic 3d tube green' }, + { name: 'Bubblegum vinyl', effect: 'vinyl', params: { body: '#ff5ec4', shade: '#b0106e' }, bg: '#f2f2f0', tags: 'inflatable balloon pink plastic 3d tube' }, + { name: 'Pinched hash', effect: 'vinyl', params: { rings: 2, ringGap: 44, pinch: 0.5 }, bg: '#ffffff', tags: 'inflatable segments ribbed pool toy 3d' }, + { name: 'Silver inflatable', effect: 'vinyl', params: { body: '#e9ecf2', shade: '#5d6474', rings: 1, ringGap: 38, pinch: 0.7, through: 0.2 }, bg: '#ffffff', tags: 'inflatable star metallic balloon foil rings' }, + { name: 'Ribbed tube', effect: 'vinyl', params: { body: '#3d6bff', shade: '#1b2fb0', rings: 2, ringGap: 22, pinch: 0.5, wrinkles: 0.6 }, bg: '#ffffff', tags: 'inflatable hose corrugated ruffle blue' }, + { name: 'Clear vinyl', effect: 'vinyl', params: { body: '#dfe8ff', shade: '#9db0e8', clear: 0.9, wrinkles: 0.8, wrinkleSize: 16, bevel: 80, softness: 6 }, bg: '#ffffff', tags: 'inflatable transparent bubble wrap clear plastic see-through' }, + { name: 'Chrome spikes', effect: 'chrome', params: { c1: '#15171c', c2: '#6f747e', c3: '#d9dde5', c4: '#ffffff', rimColor: '#ffffff', liquid: 0.25, bands: 1.4, form: 1, bevel: 120, softness: 7 }, bg: '#e9e9eb', tags: 'metal silver star cones spiky 3d y2k tube' }, + { name: 'Puffy tube', effect: 'inflate', params: { body: '#ffd0ef', shade: '#e01f8a', form: 1, bevel: 200, softness: 6, gloss: 0.7 }, bg: '#ffffff', tags: '3d tube balloon pink round' }, + { name: 'Lime glow', effect: 'flat', params: { fill: '#b6ff1f', bloom: 1, bloomRadius: 50 }, bg: '#050505', tags: 'glow bloom neon light soft halo y2k' }, + { name: 'Red glow', effect: 'flat', params: { fill: '#ff3b3b', bloom: 1, bloomRadius: 60 }, bg: '#050505', tags: 'glow bloom neon star light halo' }, + { name: 'Folded poster', effect: 'paper', params: { paperAlpha: 0, foldsX: 1, foldsY: 3 }, bg: '#3a22b8', tags: 'paper fold crease overlay poster texture print' }, + { name: 'Paper sheet', effect: 'paper', params: {}, bg: '#2b2b2b', tags: 'paper fold crease card cream texture' }, ]; diff --git a/ui/src/render/passes.ts b/ui/src/render/passes.ts index 61ae84d..6cb6703 100644 --- a/ui/src/render/passes.ts +++ b/ui/src/render/passes.ts @@ -106,7 +106,9 @@ void main() { /** * Height field from the SDF. Profiles: 0 round (circular bulge), 1 linear - * chamfer, 2 smooth (s-curve). `u_bevel` is in texture pixels. + * chamfer, 2 smooth (s-curve), 3 tube (every part a full half-round as wide + * as it is, up to the bevel: spikes become cones, strokes become tubes). + * `u_bevel` is in texture pixels. */ export const HEIGHT_FS = /* glsl */ `#version 300 es precision highp float; @@ -114,13 +116,43 @@ uniform sampler2D u_sdf; uniform float u_bevel; uniform float u_profile; out vec4 o; +float sdfAt(vec2 p) { return texture(u_sdf, p / vec2(textureSize(u_sdf, 0))).r; } +/** + * Half the width of the part a pixel lies in: the depth of the middle line + * (medial axis) straight inward from it. Walking away from the nearest edge, + * depth grows one for one until that line, then stops growing, so bisect for + * where it stops. Capped at the bevel. + */ +float halfWidth(vec2 p, float a) { + // A wide stencil, so the stair steps of slanted edges don't wobble the direction. + float e = clamp(a * 0.5, 1.0, 3.0); + vec2 g = vec2(sdfAt(p + vec2(e, 0.0)) - sdfAt(p - vec2(e, 0.0)), sdfAt(p + vec2(0.0, e)) - sdfAt(p - vec2(0.0, e))); + if (dot(g, g) < 1e-6) return a; + vec2 dir = -normalize(g); + float tol = 0.6 + 0.01 * a; + float hi = max(u_bevel - a, 0.0); + if (-sdfAt(p + dir * hi) >= a + hi - tol) return a + hi; + float lo = 0.0; + for (int i = 0; i < 14; i++) { + float m = 0.5 * (lo + hi); + if (-sdfAt(p + dir * m) >= a + m - tol) lo = m; else hi = m; + } + return a + lo; +} void main() { float d = texelFetch(u_sdf, ivec2(gl_FragCoord.xy), 0).r; float x = clamp(-d / max(u_bevel, 1.0), 0.0, 1.0); float h; if (u_profile < 0.5) h = sqrt(max(1.0 - (1.0 - x) * (1.0 - x), 0.0)); else if (u_profile < 1.5) h = x; - else h = x * x * (3.0 - 2.0 * x); + else if (u_profile < 2.5) h = x * x * (3.0 - 2.0 * x); + else if (d >= 0.0) h = 0.0; + else { + // A circle of radius r (the part's half width) seen from above, s in from its edge. + float r = min(halfWidth(gl_FragCoord.xy, -d), max(u_bevel, 1.0)); + float s = min(-d, r); + h = sqrt(max(2.0 * s * r - s * s, 0.0)) / max(u_bevel, 1.0); + } o = vec4(h, 0.0, 0.0, 0.0); }`; diff --git a/ui/src/state/geoMotion.test.ts b/ui/src/state/geoMotion.test.ts index d6d543a..e311ef4 100644 --- a/ui/src/state/geoMotion.test.ts +++ b/ui/src/state/geoMotion.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { anyGeoAnim, contentAt, hasGeoAnim, modAnimKey, modifiersAt, MORPH_KEY, movingDocAt, movingIds, nodeAt, projectAt, pruneGeoAnims, symAnimKey } from './geoMotion'; +import { anyGeoAnim, contentAt, hasGeoAnim, lineAnimKey, modAnimKey, modifiersAt, MORPH_KEY, movingDocAt, movingIds, nodeAt, projectAt, pruneGeoAnims, symAnimKey } from './geoMotion'; import { emptyProject, group, lineLayer, morphLayer, newModifier, symbolLayer, textLayer, type Project, type ShapeLayer } from './model'; const wave = () => newModifier('wave', { amplitude: 0.1, wavelength: 2, phase: 0 } as never); @@ -54,6 +54,27 @@ describe('animated geometry settings', () => { expect(c.type === 'symbol' && (c.symbol as unknown as { inner: number }).inner).toBeCloseTo(0.45 + (0.8 - 0.45) / 2, 6); }); + it('draws a pen line on and slides it round', () => { + const l = lineLayer('M0 0 L1 0', 400, 8); + l.animate = { [lineAnimKey('end')]: { to: 0, period: 2, phase: 0, mode: 'swing' } }; + expect(hasGeoAnim(l)).toBe(true); + const end = (t: number) => { + const c = nodeAt(l, t).content; + return c.type === 'line' ? c.trim?.end : undefined; + }; + expect(end(0)).toBeCloseTo(1, 6); + expect(end(1)).toBeCloseTo(0, 6); + // A slide keeps going past 1 (it wraps round the line); start and end stay on it. + l.content = { ...(l.content as Extract), trim: { start: 0, end: 0.3, offset: 0.5, each: false } }; + l.animate = { [lineAnimKey('offset')]: { to: 1.5, period: 1, phase: 0, mode: 'ramp' }, [lineAnimKey('start')]: { to: 2, period: 1, phase: 0, mode: 'swing' } }; + const c = nodeAt(l, 0.75).content; + expect(c.type === 'line' && c.trim?.offset).toBeCloseTo(1.25, 6); + expect(c.type === 'line' && c.trim?.start).toBe(1); + expect(c.type === 'line' && c.trim?.end).toBeCloseTo(0.3, 6); + // Shapes have no line to draw. + expect(hasGeoAnim(symbolLayer('star', 100, { animate: { [lineAnimKey('end')]: { to: 0, period: 1, phase: 0, mode: 'swing' } } }))).toBe(false); + }); + it('moves a font morph and a morph layer between their two shapes', () => { const swing = { [MORPH_KEY]: { to: 1, period: 2, phase: 0, mode: 'swing' as const } }; const text = textLayer('Morph', 'archivo', { animate: swing }, { morph: { font: 'pacifico', t: 0, axes: {} } }); diff --git a/ui/src/state/geoMotion.ts b/ui/src/state/geoMotion.ts index 12f639c..cd2e122 100644 --- a/ui/src/state/geoMotion.ts +++ b/ui/src/state/geoMotion.ts @@ -8,7 +8,8 @@ // mod:: a modifier's number field (layers and merged groups) // sym: a library shape's number field // morph:t the blend of a text's font morph, or of a morph layer -import type { EngineDocument, ModEntry } from '../engine/types'; +// line: how much of a pen line is drawn (its Trim: start, end, offset) +import type { EngineDocument, ModEntry, Trim } from '../engine/types'; import { isChoice, isGroup, MODIFIER_UI, SYMBOL_UI, type Field, type Node, type NumberField, type Project, type ShapeLayer } from './model'; import { animatedValue, type ParamAnim } from './motion'; import { buildScene, walk } from './tree'; @@ -18,6 +19,10 @@ export type GeoAnims = Record; export const modAnimKey = (uid: string, field: string) => `mod:${uid}:${field}`; export const symAnimKey = (field: string) => `sym:${field}`; export const MORPH_KEY = 'morph:t'; +export const lineAnimKey = (field: keyof Trim) => `line:${field}`; + +/** A pen line drawn whole. */ +export const TRIM_DEFAULT: Trim = { start: 0, end: 1, offset: 0, each: false }; /** Whether a node has a morph blend to move: text morphing to another font, or a morph layer. */ const hasMorph = (n: Node) => !isGroup(n) && ((n.content.type === 'text' && !!n.content.morph) || n.content.type === 'morph'); @@ -27,6 +32,7 @@ function applies(n: Node, key: string): boolean { const m = /^mod:([^:]+):(.+)$/.exec(key); if (m) return typeof (n.modifiers.find((x) => x.uid === m[1]) as unknown as Record | undefined)?.[m[2]] === 'number'; if (key === MORPH_KEY) return hasMorph(n); + if (key.startsWith('line:')) return !isGroup(n) && n.content.type === 'line' && typeof TRIM_DEFAULT[key.slice(5) as keyof Trim] === 'number'; if (!key.startsWith('sym:') || isGroup(n) || n.content.type !== 'symbol') return false; return typeof (n.content.symbol as unknown as Record)[key.slice(4)] === 'number'; } @@ -65,6 +71,7 @@ export function modifiersAt(mods: ModEntry[], anims: GeoAnims | undefined, t: nu } const BLEND: NumberField = { key: 't', label: 'Blend', min: 0, max: 1, step: 0.005 }; +const SLIDE: NumberField = { key: 'offset', label: 'Slide', min: -1, max: 1, step: 0.005 }; /** A layer's content with its animated shape fields and morph blend at `t`. */ export function contentAt(c: ShapeLayer['content'], anims: GeoAnims | undefined, t: number, loopLength?: number): ShapeLayer['content'] { @@ -74,6 +81,19 @@ export function contentAt(c: ShapeLayer['content'], anims: GeoAnims | undefined, if (c.type === 'text' && c.morph) return { ...c, morph: { ...c.morph, t: at(c.morph.t) } }; if (c.type === 'morph') return { ...c, t: at(c.t) }; } + if (c.type === 'line') { + let trim: Trim | null = null; + for (const [k, a] of Object.entries(anims ?? {})) { + if (!k.startsWith('line:')) continue; + const key = k.slice(5) as keyof Trim; + const from = (c.trim ?? TRIM_DEFAULT)[key]; + if (typeof from !== 'number') continue; + // Start and end stay on the line; the slide wraps round, so it may go on. + const v = valueAt(from, a, t, loopLength, key === 'offset' ? SLIDE : undefined); + trim = { ...(trim ?? c.trim ?? TRIM_DEFAULT), [key]: key === 'offset' ? v : Math.min(1, Math.max(0, v)) }; + } + return trim ? { ...c, trim } : c; + } if (c.type !== 'symbol') return c; let symbol = c.symbol; for (const [k, a] of Object.entries(anims ?? {})) { diff --git a/ui/src/state/model.ts b/ui/src/state/model.ts index 6f44ec2..eacf21c 100644 --- a/ui/src/state/model.ts +++ b/ui/src/state/model.ts @@ -257,6 +257,7 @@ export const MODIFIER_DEFAULTS: Record = { fitCircle: { kind: 'fitCircle', amount: 1 }, isometric: { kind: 'isometric', face: 'top' }, foam: { kind: 'foam', amount: 0.7, size: 0.16, wall: 0.012, spread: 0, angle: 135, seed: 1 }, + glitch: { kind: 'glitch', amount: 0.5, size: 0.05, spread: 0.3, shift: 0.06, bands: 0.12, seed: 1 }, }; export interface NumberField { @@ -420,6 +421,19 @@ export const MODIFIER_UI: Record = { { key: 'seed', label: 'Seed', min: 0, max: 999, step: 1, integer: true, advanced: true }, ], }, + glitch: { + label: 'Glitch', + blurb: 'Pixel debris off the edges and torn scan lines', + group: 'Texture', + fields: [ + { key: 'amount', label: 'Debris', min: 0, max: 1, step: 0.01 }, + { key: 'size', label: 'Block size', min: 0.01, max: 0.3, step: 0.001 }, + { key: 'spread', label: 'Reach', min: 0, max: 1.5, step: 0.005 }, + { key: 'shift', label: 'Tear', min: 0, max: 0.6, step: 0.002 }, + { key: 'bands', label: 'Band height', min: 0.02, max: 0.6, step: 0.002, show: { shift: 'nonzero' } }, + { key: 'seed', label: 'Seed', min: 0, max: 999, step: 1, integer: true }, + ], + }, repeat: { label: 'Repeat', blurb: 'Copies around a circle or in a grid', diff --git a/ui/src/state/motion.test.ts b/ui/src/state/motion.test.ts index 7eb6f1a..7a65ac2 100644 --- a/ui/src/state/motion.test.ts +++ b/ui/src/state/motion.test.ts @@ -93,6 +93,14 @@ describe('animated settings', () => { expect(steps.every((v) => [0, 10 / 3, 20 / 3, 10].some((s) => Math.abs(v - s) < 1e-9))).toBe(true); expect(new Set(steps.map((v) => v.toFixed(3))).size).toBeGreaterThan(2); expect(animatedValue(0, fl, 0.3)).toBe(animatedValue(0, fl, 0.3)); + // Hold waits at its value, goes to To, waits a moment, and is back by the end of the cycle. + const ho = { ...a, mode: 'hold' as const }; + expect(animatedValue(0, ho, 0)).toBe(0); + expect(animatedValue(0, ho, 2 * 0.35)).toBe(0); + expect(animatedValue(0, ho, 2 * 0.55)).toBeGreaterThan(5); + expect(animatedValue(0, ho, 2 * 0.65)).toBeCloseTo(10); + expect(animatedValue(0, ho, 2 * 0.85)).toBeGreaterThan(0); + expect(animatedValue(0, ho, 2 * 0.999)).toBeLessThan(0.01); const mid = parseInt(String(animatedValue('#000000', { ...a, to: '#ffffff' }, 0.5)).slice(1, 3), 16); expect(Math.abs(mid - 127.5)).toBeLessThanOrEqual(1); }); diff --git a/ui/src/state/motion.ts b/ui/src/state/motion.ts index 42eb67b..c769e37 100644 --- a/ui/src/state/motion.ts +++ b/ui/src/state/motion.ts @@ -330,8 +330,13 @@ export interface ParamAnim { /** Seconds per cycle. */ period: number; phase: number; - /** Swing there and back, ramp round and round, or flicker: hold a random in-between value, then jump. */ - mode: 'swing' | 'ramp' | 'flicker'; + /** + * Swing there and back, ramp round and round, flicker (hold a random + * in-between value, then jump), or hold: wait at its own value, then ease + * to To and back (a line wiping off and drawing on again, a balloon + * puffing, debris bursting out and gathering). + */ + mode: 'swing' | 'ramp' | 'flicker' | 'hold'; } const hexRgb = (h: string) => { @@ -341,10 +346,22 @@ const hexRgb = (h: string) => { }; const rgbHex = (c: number[]) => '#' + c.map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0')).join(''); +/** Hold mode: where in a cycle it sets off, arrives at To, and starts back. */ +export const HOLD_GO = 0.4; +export const HOLD_IN = 0.6; +export const HOLD_OUT = 0.7; + /** How far (0..1) between from and to a setting is at `t`. */ export function animMix(a: ParamAnim, t: number, loopLength?: number): number { const u = t / loopPeriod(a.period, loopLength) + a.phase; if (a.mode === 'ramp') return u - Math.floor(u); + if (a.mode === 'hold') { + // At its value until HOLD_GO, at To from HOLD_IN to HOLD_OUT, back by the end. + const f = u - Math.floor(u); + const ease = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2); + if (f < HOLD_GO) return 0; + return f < HOLD_IN ? ease((f - HOLD_GO) / (HOLD_IN - HOLD_GO)) : f < HOLD_OUT ? 1 : ease((1 - f) / (1 - HOLD_OUT)); + } if (a.mode === 'flicker') { // Four holds per cycle, each a repeatable random step (0, ⅓, ⅔ or 1). const n = Math.floor(u * 4) + Math.round(a.phase * 997); diff --git a/ui/src/state/recipes.ts b/ui/src/state/recipes.ts index 39669d2..7adcb6f 100644 --- a/ui/src/state/recipes.ts +++ b/ui/src/state/recipes.ts @@ -53,6 +53,10 @@ export const SHAPE_RECIPES: Recipe[] = [ { name: 'Diamonds', mods: [{ kind: 'pixelate', size: 0.06, shape: 'diamond', gap: 0.12, dissolve: 0, angle: 180, seed: 1 }], tags: 'pixel rhombus mosaic' }, { name: 'Iso left', mods: [{ kind: 'isometric', face: 'left' }], tags: '3d isometric cube side' }, { name: 'Iso top', mods: [{ kind: 'isometric', face: 'top' }], tags: '3d isometric cube floor' }, + { name: 'Glitch', mods: [{ kind: 'glitch', amount: 0.6, size: 0.045, spread: 0.35, shift: 0.08, bands: 0.1, seed: 3 }], tags: 'pixel debris scan lines corrupt digital y2k' }, + { name: 'Pixel debris', mods: [{ kind: 'glitch', amount: 0.85, size: 0.06, spread: 0.6, shift: 0, bands: 0.12, seed: 5 }], tags: 'glitch pixel blocks scatter dust' }, + { name: 'Torn scan', mods: [{ kind: 'glitch', amount: 0, size: 0.05, spread: 0.3, shift: 0.18, bands: 0.07, seed: 2 }], tags: 'glitch vhs scan lines tear shift' }, + { name: 'Pixel badge', mods: [{ kind: 'weight', amount: 0.12 }, { kind: 'pixelate', size: 0.07, shape: 'square', gap: 0, dissolve: 0, angle: 180, seed: 1 }], tags: 'stepped container blocky backing label sticker frame (put it under the type)' }, { name: 'Rough cut', mods: [{ kind: 'wobble', amount: 0.012, scale: 0.12, seed: 5 }, { kind: 'ink', amount: 0.008, scale: 0.2, seed: 2 }], tags: 'stamp lino hand cut rough edge' }, ]; diff --git a/ui/src/state/templates.ts b/ui/src/state/templates.ts index d08a9f1..44d33f6 100644 --- a/ui/src/state/templates.ts +++ b/ui/src/state/templates.ts @@ -5,6 +5,7 @@ import { STUDIO_TEMPLATES } from './templatesStudio'; import { GRAPHIC_TEMPLATES } from './templatesGraphic'; import { LEARN_TEMPLATES, STARTER_TEMPLATES } from './templatesLearn'; import { SHOWCASE_TEMPLATES } from './templatesShowcase'; +import { POSTER_TEMPLATES } from './templatesPoster'; import { MOTION_TEMPLATES } from './templatesMotion'; import { background, fill, group, mods, pattern, strokeLayer, style, symbolLayer, textLayer, tile, type Background, type Node, type Project, type ShapeLayer } from './model'; @@ -800,6 +801,7 @@ const ALL: Template[] = [ /** Newest, most current looks first. */ const FEATURED = [ + ...POSTER_TEMPLATES.map((t) => t.name), ...SHOWCASE_TEMPLATES.map((t) => t.name), ...MOTION_TEMPLATES.map((t) => t.name), ...STARTER_TEMPLATES.map((t) => t.name), @@ -808,5 +810,5 @@ const FEATURED = [ ...LAB_TEMPLATES.map((t) => t.name), ...STUDIO_TEMPLATES.map((t) => t.name), 'Printed shapes', 'Night notes', 'Echo rings', 'Sun stamp', 'Ring seal', 'Voxel cursor', 'Dot type', 'Folder sticker', 'Pixel wordmark', 'Pixel poster', 'Dither signal', 'Fused band', 'Pixel garden']; -const EVERY = [...ALL, ...LAB_TEMPLATES, ...STUDIO_TEMPLATES, ...GRAPHIC_TEMPLATES, ...MOTION_TEMPLATES, ...LEARN_TEMPLATES, ...STARTER_TEMPLATES, ...SHOWCASE_TEMPLATES]; +const EVERY = [...ALL, ...LAB_TEMPLATES, ...STUDIO_TEMPLATES, ...GRAPHIC_TEMPLATES, ...MOTION_TEMPLATES, ...LEARN_TEMPLATES, ...STARTER_TEMPLATES, ...SHOWCASE_TEMPLATES, ...POSTER_TEMPLATES]; export const TEMPLATES: Template[] = [...FEATURED.map((n) => EVERY.find((t) => t.name === n)!).filter(Boolean), ...EVERY.filter((t) => !FEATURED.includes(t.name))]; diff --git a/ui/src/state/templatesPoster.ts b/ui/src/state/templatesPoster.ts new file mode 100644 index 0000000..dfacfe3 --- /dev/null +++ b/ui/src/state/templatesPoster.ts @@ -0,0 +1,205 @@ +// Animated posters in the Y2K / 3D-abstract / pixel-glitch style: chrome and +// inflatable tubes drawn on along pen lines (Line › Start/End), puffy shapes +// with topographic lines and speckles (Puffy › Surface), type breaking into +// pixel debris (the Glitch modifier) and stepped pixel badges behind script. +// Everything is a plain layer with app settings, so each part can be picked +// apart and reused; the moving parts are Motion loops and diamond keyframes. +import { background, fill, group, lineLayer, mods, stroke, style, symbolLayer, textLayer, type Node, type Project, type ShapeLayer, type Style } from './model'; +import { lineAnimKey } from './geoMotion'; +import { FEELS, newMotion, type Motion, type ParamAnim, type Transition } from './motion'; +import type { Background } from './model'; +import type { SymbolKind } from '../engine/types'; +import type { Template } from './templates'; + +const W = 1200; +const H = 800; + +const feel = (label: string, duration?: number): Transition => { + const t = { ...FEELS.find((f) => f.label === label)!.value }; + return duration ? { ...t, duration } : t; +}; +const anim = (to: number | string, period: number, mode: ParamAnim['mode'] = 'swing', phase = 0): ParamAnim => ({ to, period, phase, mode }); + +function shape(kind: SymbolKind, size: number, x: number, y: number, st: Style, name: string, params: Record = {}, motion?: Partial): ShapeLayer { + const n = symbolLayer(kind, size, { x, y, name, style: st }, params); + if (motion) n.motion = newMotion(motion); + return n; +} + +/** Centred type; `y` is the middle of the line. */ +function words(s: string, font: string, size: number, x: number, y: number, st: Style, name: string, motion?: Partial, tracking = 0, lineHeight = 1): ShapeLayer { + const n = textLayer(s, font, { x, y, name, style: st }, { size, align: 'center', lineHeight, tracking }); + if (motion) n.motion = newMotion(motion); + return n; +} + +/** + * A pen line (em units of `size`) that wipes off and draws itself on again: + * Line › End holds at 1, goes to 0 and comes back (Hold). At rest, and on + * the first frame, the whole line shows. + */ +function tube(d: string, size: number, width: number, x: number, y: number, st: Style, name: string, draw?: { period: number; phase?: number }): ShapeLayer { + const l = lineLayer(d, size, width, { x, y, name, style: st }); + if (draw) l.animate = { [lineAnimKey('end')]: anim(0, draw.period, 'hold', draw.phase ?? 0) }; + return l; +} + +const flat = (c: string): Style => style('flat', { fill: c }); +const puffy = (body: string, shade: string, extra: Record = {}) => style('inflate', { body, shade, ...extra }); +const vinyl = (body: string, shade: string, extra: Record = {}) => style('vinyl', { body, shade, ...extra }); +const SILVER = { c1: '#15171c', c2: '#6f747e', c3: '#d9dde5', c4: '#ffffff', rimColor: '#ffffff', liquid: 0.25, bands: 1.4 }; +const chrome = (extra: Record = {}) => style('chrome', { ...SILVER, ...extra }); + +const scene = (name: string, hint: string, layers: Node[], bg: Background, duration = 4): Template => ({ + name, + hint, + category: 'Learn', + make: (): Project => ({ version: 2, name, canvas: { width: W, height: H }, layers, background: bg, fonts: [], timeline: { duration, loop: true } }), +}); + +const solid = (c: string, grain = 0) => background(fill('solid', c), grain ? { grain } : {}); + +/** A six-petal flower outline as one closed pen line (em units): round lobes, deep pinches. */ +const FLOWER = (() => { + const pts = Array.from({ length: 144 }, (_, i) => { + const a = (i / 144) * Math.PI * 2; + const r = 0.2 + 0.26 * Math.pow(Math.abs(Math.cos(3 * a)), 0.55); + return `${(Math.cos(a) * r).toFixed(4)} ${(Math.sin(a) * r).toFixed(4)}`; + }); + return `M ${pts[0]} L ${pts.slice(1).join(' L ')} Z`; +})(); + +export const POSTER_TEMPLATES: Template[] = [ + scene( + 'Animated · Y2K set', + 'A 3D-abstract set: a puffy blob with drifting contour lines, a spiky chrome star, speckled foam, a chrome flower tube and a script stroke drawing themselves on (Line › End), around pixel and serif type', + (() => { + const blue = '#2347ff'; + const y = tube('M -0.36 -0.3 C -0.32 -0.52 -0.12 -0.3 -0.14 -0.02 C -0.15 0.16 0.04 0.18 0.11 -0.04 L 0.2 -0.36 C 0.12 0.3 0.02 0.78 -0.26 0.74 C -0.48 0.7 -0.34 0.42 0.08 0.36', 360, 15, -300, -20, flat(blue), 'Script y', { period: 4 }); + const two = words('2', 'archivo', 400, -40, -10, flat(blue), 'Pixel 2'); + two.modifiers = mods({ kind: 'pixelate', size: 0.075, shape: 'square', gap: 0, dissolve: 0, angle: 180, seed: 1 }); + const k = words('K', 'young-serif', 420, 210, -10, flat(blue), 'Serif K'); + const blob = shape('blob', 300, -400, -190, puffy('#c98cff', '#7b2fd6', { bevel: 90, gloss: 0.55, surface: 1, marks: '#f3e6ff', marksAmount: 0.85, marksSize: 55, marksWidth: 1.4 }), 'Topo blob', { lobes: 3, depth: 0.15, seed: 2, wobble: 0.4 }, { loop: 'float', amount: 14, period: 4 }); + blob.rotation = -18; + blob.style.animate = { marksFlow: anim(1.6, 4, 'swing'), bevel: anim(60, 4, 'swing') }; + const spikes = shape('rays', 330, 0, 0, chrome({ form: 1, bevel: 120, softness: 7 }), 'Spikes', { rays: 14, short: 0.45, inner: 0.16, jitter: 0.6, seed: 3 }); + const ball = shape('circle', 74, 0, 0, chrome({ c1: '#4a4f58', c2: '#9aa0aa', bevel: 40, liquid: 0, bands: 1 }), 'Ball'); + const star = group([spikes, ball], { name: 'Chrome star', motion: newMotion({ loop: 'spin', amount: 1, period: 8 }) }); + [spikes, ball].forEach((c) => ((c.x = 400), (c.y = -170))); + const foam = shape('blob', 270, -420, 230, puffy('#5ce84e', '#1c8f26', { bevel: 30, gloss: 0.3, surface: 2, marks: '#0b260d', marksAmount: 0.9, marksSize: 22, marksWidth: 1.6 }), 'Speckled foam', { lobes: 11, depth: 0.12, seed: 5, wobble: 0.8 }, { loop: 'float', amount: 12, period: 2, phase: 0.3 }); + foam.modifiers = mods({ kind: 'wobble', amount: 0.045, scale: 0.16, seed: 3 }); + const flower = tube(FLOWER, 330, 30, 420, 215, chrome({ bevel: 15, softness: 0, liquid: 0.12, bands: 1.1 }), 'Chrome flower', { period: 4, phase: 0.38 }); + flower.motion = newMotion({ loop: 'sway', amount: 8, period: 4 }); + const caption = words('Bold, tactile and perfectly futuristic.\nAbstract 3D for designers and agencies.', 'inter', 21, 0, 300, flat('#111111'), 'Caption', undefined, 0, 1.3); + const brand = words('Y2K by', 'space-grotesk', 22, -520, -350, flat('#111111'), 'Brand'); + const badge = shape('ring', 34, -455, -350, flat('#111111'), 'Eyes ring', { thickness: 0.07 }); + const eyes = shape('eyes', 22, -455, -351, flat('#111111'), 'Eyes', { gap: 0.36, width: 0.2, height: 0.38 }, { loop: 'blink', amount: 100, period: 2, phase: 0.9 }); + const arrow = tube('M -0.5 0 L 0.5 0 M 0.25 -0.22 L 0.5 0 L 0.25 0.22', 30, 2.2, 400, -351, flat('#111111'), 'Arrow'); + const dl = words('DOWNLOAD', 'space-grotesk', 18, 480, -350, flat('#111111'), 'Download', undefined, 0.04); + return [brand, badge, eyes, arrow, dl, foam, blob, y, two, k, star, flower, caption]; + })(), + solid('#ebebec', 0.12), + ), + scene( + 'Animated · Inflatable', + 'Inflated pool-toy tubes over thin black type, in Inflatable vinyl: a pinched "#" wiping off and drawing back on, a pink arrow, a ringed silver star whose pinch breathes', + (() => { + const title = words('INFLATABLE\nABSTRACT', 'inter', 158, 0, -10, flat('#0a0a0a'), 'Title', undefined, -0.035, 0.9); + const hash = tube('M -0.16 -0.46 L -0.16 0.46 M 0.16 -0.46 L 0.16 0.46 M -0.46 -0.15 L 0.46 -0.15 M -0.46 0.17 L 0.46 0.17', 270, 46, -440, -215, vinyl('#4dff2e', '#0e8f1a', { rings: 2, ringGap: 40, pinch: 0.5, shadow: 0.25 }), 'Hash', { period: 4 }); + hash.rotation = -10; + // Wipes back to its first bar, never quite gone. + hash.animate![lineAnimKey('end')].to = 0.25; + const arrow = tube('M -0.42 0.5 L -0.42 0 C -0.42 -0.12 -0.36 -0.16 -0.2 -0.16 L 0.18 -0.16', 330, 44, -400, 230, vinyl('#ff5ec4', '#b0106e', { shadow: 0.25 }), 'Arrow'); + const head = shape('polygon', 150, -290, 177, vinyl('#ff7a2e', '#c23a00', { bevel: 60, seam: 0.5, shadow: 0.25 }), 'Arrow head', { sides: 3, rounding: 0.18 }); + head.rotation = 90; + const arrowSet = group([arrow, head], { name: 'Arrow', motion: newMotion({ loop: 'float', amount: 10, period: 2 }) }); + const star = shape('sparkle', 240, 430, -230, vinyl('#e9ecf2', '#5d6474', { rings: 1, ringGap: 30, pinch: 0.7, through: 0.2, shadow: 0.3 }), 'Silver star', { pinch: 0.45 }, { loop: 'pulse', amount: 12, period: 2 }); + star.style.animate = { pinch: anim(0.2, 2, 'swing') }; + const wave = tube('M -0.5 0 L 0.5 0', 360, 36, 370, 270, vinyl('#3d6bff', '#1b2fb0', { wrinkles: 0.5, shadow: 0.25 }), 'Blue wave', { period: 4, phase: 0.4 }); + if (wave.content.type === 'line') wave.content.squiggle = { kind: 'zigzag', amplitude: 30, spacing: 46, smooth: 1, round: 0, loops: 1.2, perSegment: false, flatEnds: false, flip: false }; + const tag = shape('roundRect', 200, 270, 165, { ...flat('#86ff4a'), stroke: stroke(3, '#0a0a0a', 'inside') }, 'Tag', { radius: 0.16, aspect: 2.6 }); + const vol = words('VOL.2', 'inter', 50, 270, 163, flat('#0a0a0a'), 'Vol', undefined, -0.02); + const tagSet = group([tag, vol], { name: 'Vol tag', motion: newMotion({ enter: 'grow', distance: 0, delay: 0.3, transition: feel('Bouncy', 0.6), loop: 'sway', amount: 4, period: 2 }) }); + [tag, vol].forEach((c) => (c.rotation = -7)); + const top = words('Inflatable vol.2 / For Figma & Blender', 'space-grotesk', 18, 0, -360, flat('#0a0a0a'), 'Top line', undefined, 0.02); + return [top, title, wave, hash, arrowSet, star, tagSet]; + })(), + solid('#fbfbfb'), + ), + scene( + 'Animated · Global trend', + 'A pixel-glitch night poster: heavy type shedding and gathering pixel debris (a Glitch whose Debris and Tear move), italic outline type, glowing clouds and stars', + (() => { + const glitchy = (s: string, y: number, seed: number, phase: number) => { + const w = words(s, 'archivo', 196, 0, y, flat('#ffffff'), s, undefined, -0.01); + w.modifiers = mods({ kind: 'glitch', amount: 0.12, size: 0.065, spread: 0.32, shift: 0, bands: 0.09, seed }); + const uid = w.modifiers[0].uid; + w.animate = { [`mod:${uid}:amount`]: anim(0.6, 4, 'hold', phase), [`mod:${uid}:shift`]: anim(0.07, 2, 'flicker', phase) }; + return w; + }; + const outline = (s: string, y: number, motion?: Partial) => { + const w = words(s, 'inter-black', 150, 0, y, flat('#ffffff'), s, motion, 0.02); + w.modifiers = mods({ kind: 'slant', angle: 14 }, { kind: 'outline', width: 0.016 }); + return w; + }; + const glow = (c: string, core: string) => style('glow', { edge: c, mid: c, core, inside: 90, curve: 1.4, out1: c, out2: c, outside: 60, outAmount: 0.75, outMix: 0.8, grain: 0.25 }); + const cloudA = shape('cloud', 220, -320, 10, glow('#ff9d1a', '#ffd08a'), 'Orange cloud', { puffs: 3, seed: 2 }, { loop: 'float', amount: 14, period: 4 }); + const cloudB = shape('cloud', 260, 330, 40, glow('#c8ff2a', '#f2ffc0'), 'Lime cloud', { puffs: 4, seed: 5 }, { loop: 'float', amount: 14, period: 4, phase: 0.5 }); + const starR = shape('star', 170, -420, 130, glow('#ff3b3b', '#ffb0a8'), 'Red star', { points: 8, inner: 0.36, rounding: 0 }, { loop: 'pulse', amount: 14, period: 2 }); + const starB = shape('star', 90, 200, -60, glow('#3b4bff', '#b8c0ff'), 'Blue star', { points: 6, inner: 0.4, rounding: 0 }, { loop: 'pulse', amount: 20, period: 2, phase: 0.5 }); + [cloudA, cloudB, starR, starB].forEach((n) => (n.style.animate = { outAmount: anim(1.1, 2, 'swing', n.motion?.phase ?? 0) })); + const foot = words('PRESERVING THE LOCAL CULTURE / BRINGING THE GLOBAL VIBE', 'space-grotesk', 15, 0, 365, flat('#ffffff'), 'Footer', undefined, 0.06); + return [cloudA, cloudB, starR, starB, glitchy('GLOBAL', -250, 3, 0), outline('TREND', -110), glitchy('LOCAL', 150, 9, 0.5), outline('STYLE', 290), foot]; + })(), + background(fill('solid', '#050505'), { grain: 0.35 }), + ), + scene( + 'Animated · Pixel badge', + 'Script words (from the proverb sạch / rách) on stepped pixel badges that break apart and build back: Pixel badge (Weight + Pixels) whose Dissolve moves, with a black stroke', + (() => { + const badge = (s: string, x: number, y: number, c: string, angle: number, phase: number): Node[] => { + const back = words(s, 'pacifico', 210, x, y, { ...flat(c), stroke: stroke(4, '#0a0a0a', 'outside') }, `${s} badge`); + back.modifiers = mods({ kind: 'weight', amount: 0.14 }, { kind: 'pixelate', size: 0.075, shape: 'square', gap: 0, dissolve: 0, angle, seed: 2 }); + back.animate = { [`mod:${back.modifiers[1].uid}:dissolve`]: anim(1, 4, 'hold', phase) }; + const ink = words(s, 'pacifico', 210, x, y, flat('#0a0a0a'), s); + return [back, ink]; + }; + const cho = (x: number, y: number) => words('CHO', 'inter-black', 44, x, y, flat('#0a0a0a'), 'Cho', undefined, 0.04); + const tiny = (s: string, x: number, y: number) => words(s, 'space-grotesk', 15, x, y, flat('#0a0a0a'), s, undefined, 0.08); + return [ + ...badge('Sạch', -150, -150, '#2fe07a', 90, 0), + ...badge('Rách', 170, 170, '#ff8fd6', 270, 0.3), + cho(150, -230), + cho(-180, 250), + tiny('STEPPED PIXEL BADGES', -430, -350), + tiny('NO. 09 | [ PX ]', 480, -350), + ]; + })(), + solid('#e6e6e3', 0.15), + ), + scene( + 'Animated · Tube burst', + 'A burst of soft green tubes growing out from the middle, one after another (Line › End on each), turning slowly beside big thin type', + (() => { + const lime = puffy('#a8e63c', '#4d8a12', { bevel: 21, gloss: 0.55, rimTint: 0.15, shadow: 0.35, shadowOffset: 14 }); + const n = 16; + const spokes = Array.from({ length: n }, (_, i) => { + const a = (i / n) * Math.PI * 2 + 0.2 * Math.sin(i * 2.3); + const len = 0.28 + 0.2 * (0.5 + 0.5 * Math.sin(i * 1.7 + 0.4)); + const [x, y] = [Math.cos(a) * len, Math.sin(a) * len * 0.82]; + const t = tube(`M 0 0 L ${x.toFixed(3)} ${y.toFixed(3)}`, 520, 40 + (i % 3) * 3, 0, 0, lime, `Tube ${i + 1}`, { period: 4, phase: ((i * 7) % n) * 0.012 }); + t.animate![lineAnimKey('end')].to = 0.12; + return t; + }); + const burst = group(spokes, { name: 'Burst', motion: newMotion({ loop: 'spin', amount: 1, period: 16 }) }); + spokes.forEach((c) => ((c.x = -300), (c.y = -20))); + const title = words('flat-e', 'inter', 300, 230, -60, flat('#ffffff'), 'Title', undefined, -0.04); + const line = words('{ aims to create }', 'inter', 52, 120, 250, flat('#ffffff'), 'Tagline', undefined, -0.01); + const under = tube('M -0.5 0 L 0.5 0', 190, 4, 170, 285, flat('#a8e63c'), 'Underline', { period: 4, phase: 0.3 }); + if (under.content.type === 'line') under.content.squiggle = { kind: 'zigzag', amplitude: 5, spacing: 16, smooth: 1, round: 0, loops: 1.2, perSegment: false, flatEnds: false, flip: false }; + const nav = words('work about contact', 'inter', 22, -440, -350, flat('#ffffff'), 'Nav'); + return [nav, burst, title, line, under]; + })(), + solid('#050505'), + ), +]; diff --git a/ui/src/state/tracks.ts b/ui/src/state/tracks.ts index 4b7b981..1d6fcc4 100644 --- a/ui/src/state/tracks.ts +++ b/ui/src/state/tracks.ts @@ -74,6 +74,12 @@ export function paramInfo(n: Node, scope: 'style' | 'geo', key: string): { label const from = n.content.type === 'text' ? (n.content.morph?.t ?? 0) : n.content.t; return { label: 'Morph · Blend', color: false, from, range: { min: 0, max: 1, step: 0.005 } }; } + const line = /^line:(start|end|offset)$/.exec(key); + if (line && !isGroup(n) && n.content.type === 'line') { + const f = line[1] as 'start' | 'end' | 'offset'; + const from = n.content.trim?.[f] ?? (f === 'end' ? 1 : 0); + return { label: `Line · ${{ start: 'Start', end: 'End', offset: 'Slide' }[f]}`, color: false, from, range: { min: f === 'offset' ? -1 : 0, max: 1, step: 0.005 } }; + } const field = key.replace(/^sym:/, ''); if (!isGroup(n) && n.content.type === 'symbol') { const f = SYMBOL_UI[n.content.symbol.kind]?.fields.find((x) => x.key === field);