From 4b31909aa3bb5108907fd3538a32c364e9befcf4 Mon Sep 17 00:00:00 2001 From: srivtx Date: Thu, 22 Oct 2026 01:23:16 +0530 Subject: [PATCH 1/4] Draw lines on, Glitch debris, contour and speckle Puffy, Hold keyframes New tools for Y2K, 3D-abstract and pixel-glitch posters: - Pen lines get Start, End and Slide (a Trim in the engine): draw only part of a line, keyframed to draw it on or run a stretch round a loop. Subpaths draw in order, or each at once. Ribbon colour stays put while it draws. - Glitch modifier: square pixel debris breaking off the outside of the whole form (letter gaps and counters stay clean), bites out of the edge, and bands torn sideways by whole blocks. More Debris keeps the blocks it had, so debris gathers rather than reshuffles. - Puffy Surface: contour lines (topographic, wrapping round the puff) or speckles, with a Flow to animate. - Hold keyframe mode: waits at its value, then goes to To and back, so a design at rest shows its own value. - Recipes: Glitch, Pixel debris, Torn scan, Pixel badge. Co-Authored-By: Claude Opus 5.5 --- crates/morpho-core/src/along.rs | 134 +++++++++++++++++++++++ crates/morpho-core/src/engine.rs | 13 ++- crates/morpho-core/src/modifiers.rs | 148 ++++++++++++++++++++++++++ ui/src/components/Inspector.tsx | 18 +++- ui/src/components/Keyframed.tsx | 1 + ui/src/components/canvas/Timeline.tsx | 3 +- ui/src/components/pickers.tsx | 1 + ui/src/engine/types.ts | 17 ++- ui/src/render/effects.ts | 38 ++++++- ui/src/state/geoMotion.test.ts | 23 +++- ui/src/state/geoMotion.ts | 22 +++- ui/src/state/model.ts | 14 +++ ui/src/state/motion.test.ts | 8 ++ ui/src/state/motion.ts | 21 +++- ui/src/state/recipes.ts | 4 + ui/src/state/tracks.ts | 6 ++ 16 files changed, 456 insertions(+), 15 deletions(-) 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.ts b/ui/src/render/effects.ts index 613d279..20c2fcb 100644 --- a/ui/src/render/effects.ts +++ b/ui/src/render/effects.ts @@ -271,23 +271,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') }), 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; 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/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); From d4f4eb4c488a9e7160ea61d09f67831f88c8cc7a Mon Sep 17 00:00:00 2001 From: srivtx Date: Thu, 22 Oct 2026 01:23:16 +0530 Subject: [PATCH 2/4] Five animated posters made with the new tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Y2K set, Inflatable, Global trend, Pixel badge and Tube burst, under Templates › Learn: chrome and inflatable tubes drawing on, a topo-line puff, speckled foam, glitching type, stepped pixel badges that build back, and a burst of tubes growing out. Each was checked frame by frame against the reference poster it follows. Co-Authored-By: Claude Opus 5.5 --- ui/src/state/templates.ts | 4 +- ui/src/state/templatesPoster.ts | 204 ++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 ui/src/state/templatesPoster.ts 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..58dee2e --- /dev/null +++ b/ui/src/state/templatesPoster.ts @@ -0,0 +1,204 @@ +// 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 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({ bevel: 9, softness: 0 }), '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: a glossy "#" drawing itself on, a pink arrow, a silver star puffing up and down (Puffy with a moving Puff)', + (() => { + 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, puffy('#62ff3a', '#139c1e', { bevel: 22, gloss: 1, rimTint: 0.2, 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, puffy('#ff74d9', '#c4128c', { bevel: 22, gloss: 0.9, shadow: 0.25 }), 'Arrow'); + const head = shape('polygon', 150, -290, 177, puffy('#ff8a3d', '#d43d00', { bevel: 40, gloss: 0.9, 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, puffy('#f4f6fa', '#7d8496', { bevel: 50, gloss: 1.2, rimTint: 0.5, shadow: 0.3 }), 'Silver star', { pinch: 0.45 }, { loop: 'pulse', amount: 12, period: 2 }); + star.style.animate = { bevel: anim(22, 2, 'swing') }; + const wave = tube('M -0.5 0 L 0.5 0', 360, 36, 370, 270, puffy('#5b8cff', '#1f3fc4', { bevel: 18, gloss: 1, 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'), + ), +]; From 3ad0b6e840dce561023f572ac77097325a751d63 Mon Sep 17 00:00:00 2001 From: srivtx Date: Thu, 22 Oct 2026 01:34:08 +0530 Subject: [PATCH 3/4] Materials: tube form, inflatable vinyl, glow, folded paper, print screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Height pass: a Tube profile that rounds every part across its own width (half width found by bisecting toward the medial axis), so spikes shade as cones and strokes as tubes. Chrome and Puffy get Form › Bevel / Tube. - Inflatable vinyl: saturated plastic lit through the skin, crisp softbox highlights, room reflections, pinched rings (round the centre or across the tubes), wrinkles, an edge seam and a clear plastic mode. - Flat › Glow: a two-size bloom in the fill colour with a glowing edge. - Folded paper: creased panels, crumples, mottling and grain; with the paper off it folds a whole poster laid underneath. - Photo › Print screen: halftone dots, gritty dither or line screen, per channel or in the duotone's two inks. - Presets for each. Co-Authored-By: Claude Opus 5.5 --- ui/src/render/effects.test.ts | 9 + ui/src/render/effects.ts | 314 +++++++++++++++++++++++++++++++++- ui/src/render/passes.ts | 36 +++- 3 files changed, 350 insertions(+), 9 deletions(-) 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 20c2fcb..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), @@ -278,7 +289,7 @@ const inflate: EffectDef = { 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, u_marks; @@ -639,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. @@ -667,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); }`, }; @@ -874,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; @@ -898,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); }`, }; @@ -1730,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', @@ -1881,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; @@ -1975,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); }`; From 7e54000158bff8a9b7dc20f84a620c8b63e8c36a Mon Sep 17 00:00:00 2001 From: srivtx Date: Thu, 22 Oct 2026 01:36:24 +0530 Subject: [PATCH 4/4] Inflatable in vinyl, Y2K spikes shaded as cones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Inflatable poster's tubes use Inflatable vinyl (a pinched hash, a ringed silver star whose pinch breathes), and the Y2K star's spikes use Liquid chrome with Form › Tube, so each spike shades as a cone. Co-Authored-By: Claude Opus 5.5 --- ui/src/state/templatesPoster.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ui/src/state/templatesPoster.ts b/ui/src/state/templatesPoster.ts index 58dee2e..dfacfe3 100644 --- a/ui/src/state/templatesPoster.ts +++ b/ui/src/state/templatesPoster.ts @@ -46,6 +46,7 @@ function tube(d: string, size: number, width: number, x: number, y: number, st: 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 }); @@ -81,7 +82,7 @@ export const POSTER_TEMPLATES: Template[] = [ 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({ bevel: 9, softness: 0 }), 'Spikes', { rays: 14, short: 0.45, inner: 0.16, jitter: 0.6, seed: 3 }); + 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))); @@ -101,20 +102,20 @@ export const POSTER_TEMPLATES: Template[] = [ ), scene( 'Animated · Inflatable', - 'Inflated pool-toy tubes over thin black type: a glossy "#" drawing itself on, a pink arrow, a silver star puffing up and down (Puffy with a moving Puff)', + '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, puffy('#62ff3a', '#139c1e', { bevel: 22, gloss: 1, rimTint: 0.2, shadow: 0.25 }), 'Hash', { period: 4 }); + 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, puffy('#ff74d9', '#c4128c', { bevel: 22, gloss: 0.9, shadow: 0.25 }), 'Arrow'); - const head = shape('polygon', 150, -290, 177, puffy('#ff8a3d', '#d43d00', { bevel: 40, gloss: 0.9, shadow: 0.25 }), 'Arrow head', { sides: 3, rounding: 0.18 }); + 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, puffy('#f4f6fa', '#7d8496', { bevel: 50, gloss: 1.2, rimTint: 0.5, shadow: 0.3 }), 'Silver star', { pinch: 0.45 }, { loop: 'pulse', amount: 12, period: 2 }); - star.style.animate = { bevel: anim(22, 2, 'swing') }; - const wave = tube('M -0.5 0 L 0.5 0', 360, 36, 370, 270, puffy('#5b8cff', '#1f3fc4', { bevel: 18, gloss: 1, shadow: 0.25 }), 'Blue wave', { period: 4, phase: 0.4 }); + 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);