motion-content 0.1.0

Backend-independent images, text, vectors, particles and artwork
Documentation
use super::super::*;
use super::geometry::{outline, samples};
use super::material::material;
use super::*;
use motion_core::TimeLiteral;
use motion_render::RenderPrepare;

#[test]
fn pressure_follow_path_multi_subpaths_and_validation() {
    let stroke = BrushStroke::new(
        Path2D::parse_svg("M0 0L40 0M80 0L80 40").unwrap(),
        8.0,
        Color::WHITE,
    )
    .pressure_curve(vec![(0.0, 0.1), (0.5, 2.0), (1.0, 0.1)])
    .nib_mode(NibMode::FollowPath(0.0));
    assert!(bounds(&stroke).is_ok());
    let groups = samples(&stroke, 1.0).unwrap();
    assert_eq!(groups.len(), 2);
    let outline = outline(&stroke, &groups).unwrap();
    assert!(!outline.verbs().is_empty());
    assert!(bounds(&stroke.clone().pressure_curve(vec![(0.0, 1.0), (0.0, 2.0)])).is_err());
    assert!(samples(&stroke.clone().pressure_from(|_| f64::NAN), 1.0).is_err());
    let replaced = stroke
        .clone()
        .pressure_from(|_| f64::NAN)
        .pressure(1.0, 1.0, 1.0);
    assert!(samples(&replaced, 1.0).is_ok());
    assert!(
        samples(
            &stroke
                .pressure_from(|_| f64::NAN)
                .pressure_curve(vec![(0.0, 1.0), (1.0, 1.0)]),
            1.0
        )
        .is_ok()
    );
}

#[test]
fn brush_pressure_peak_keeps_full_arc_phase_during_reveal() -> Result<(), ValidationError> {
    let outline_bounds = |stroke: &BrushStroke| -> Result<Rect, ValidationError> {
        let groups = samples(stroke, stroke.progress)?;
        Ok(outline(stroke, &groups)?.bounding_box())
    };
    for length in [32.0, 36.0, 100.0] {
        let path = Path2D::parse_svg(&format!("M8 24L{} 24", 8.0 + length))?;
        let stroke = BrushStroke::new(path, 8.0, Color::BLACK)
            .pressure(0.0, 2.0, 0.0)
            .ink_load(1.0, 0.0);
        let full = outline_bounds(&stroke.clone().write(1.0))?;
        assert!(full.height() > 14.0);
        let bounds = outline_bounds(&stroke.clone().write(0.75))?;
        assert!(
            (bounds.top - full.top).abs() < 1e-9 && (bounds.bottom - full.bottom).abs() < 1e-9,
            "length={length} bounds={bounds:?}"
        );
        let early = outline_bounds(&stroke.write(0.25))?;
        assert!(early.height() < full.height() * 0.75);
    }
    Ok(())
}

#[test]
fn recorded_timing_and_cached_material_identity() {
    let input = [
        InputSample {
            position: Point::new(0.0, 0.0),
            pressure: 1.0,
            tilt: 0.0,
            time: Time::ZERO,
        },
        InputSample {
            position: Point::new(20.0, 0.0),
            pressure: 0.5,
            tilt: 0.3,
            time: 1.seconds(),
        },
        InputSample {
            position: Point::new(40.0, 0.0),
            pressure: 1.0,
            tilt: 0.6,
            time: 3.seconds(),
        },
    ];
    let stroke = BrushStroke::from_samples(&input, 8.0, Color::WHITE)
        .unwrap()
        .nib_mode(NibMode::Tilt);
    let revealed = stroke.clone().write_with(2.seconds()).unwrap();
    assert!((revealed.progress - 0.75).abs() < 1e-9);
    assert!(Arc::ptr_eq(&stroke.full_material, &revealed.full_material));
    assert!(Arc::ptr_eq(
        &stroke.full_material,
        &stroke
            .clone()
            .write_timed(1.seconds(), &BrushTiming::new(3.seconds()))
            .unwrap()
            .full_material
    ));
    assert!(!Arc::ptr_eq(
        &stroke.full_material,
        &stroke.clone().edge_darkening(0.3).full_material
    ));
    assert!(BrushStroke::from_samples(&[input[0], input[0]], 8.0, Color::WHITE).is_err());
}

#[test]
fn curved_recordings_pauses_tilt_and_flat_caps_are_analytic() -> motion_core::Result {
    let input = [
        InputSample {
            position: Point::new(0.0, 0.0),
            pressure: 1.0,
            tilt: 0.0,
            time: Time::ZERO,
        },
        InputSample {
            position: Point::new(20.0, 0.0),
            pressure: 1.0,
            tilt: 0.6,
            time: 1.seconds(),
        },
        InputSample {
            position: Point::new(20.0, 0.0),
            pressure: 1.0,
            tilt: 0.6,
            time: 2.seconds(),
        },
        InputSample {
            position: Point::new(30.0, 30.0),
            pressure: 0.5,
            tilt: 1.2,
            time: 3.seconds(),
        },
    ];
    let stroke = BrushStroke::from_samples(&input, 8.0, Color::WHITE)?.nib_mode(NibMode::Tilt);
    let options = &stroke.advanced;
    assert_eq!(options.input_arcs[1], options.input_arcs[2]);
    assert_eq!(
        stroke.clone().write_with(1.seconds())?.progress,
        stroke.clone().write_with(1500.milliseconds())?.progress
    );
    let halfway = samples(&stroke, 1.0)?;
    assert!(
        halfway[0]
            .iter()
            .any(|sample| sample.angle > 0.0 && sample.angle < 0.6)
    );
    let path = Path2D::parse_svg("M0 0L20 0M40 0L60 0")?;
    let mut timing = BrushTiming::new(2.seconds());
    timing.lift_pause = 1.seconds();
    let stroke = BrushStroke::new(path, 8.0, Color::WHITE);
    assert_eq!(
        stroke
            .clone()
            .write_timed(1500.milliseconds(), &timing)?
            .progress,
        0.5
    );
    assert_eq!(
        stroke
            .clone()
            .write_timed(2500.milliseconds(), &timing)?
            .progress,
        0.75
    );
    let flat = stroke.clone().caps(BrushCap::Flat, BrushCap::Flat);
    let outline = outline(&flat, &samples(&flat, 1.0)?)?;
    assert!(outline.bounding_box().left >= 0.0 && outline.bounding_box().right <= 60.0);
    assert!(
        stroke
            .clone()
            .tip_node(motion_core::Circle::new(Point::new(0.0, 0.0), 1.0).fill(Color::WHITE))
            .is_ok()
    );
    let late = stroke.clone().boil(1.0, 0.1, 17, 100);
    let early = stroke.clone().boil(1.0, 0.1, 17, 3);
    let again = stroke.boil(1.0, 0.1, 17, 100);
    assert_eq!(
        format!("{:?}", samples(&late, 1.0)?),
        format!("{:?}", samples(&again, 1.0)?)
    );
    assert_ne!(
        format!("{:?}", samples(&late, 1.0)?),
        format!("{:?}", samples(&early, 1.0)?)
    );
    Ok(())
}

#[test]
fn material_tiles_parallelism_and_reveal_cache_are_exact() -> motion_core::Result {
    let stroke = BrushStroke::new(
        Path2D::parse_svg("M8 16L56 16")?,
        8.0,
        Color::from_hex(0x4080c0),
    )
    .ink(Ink::new(19).granulation(0.3))
    .tiled_material(32);
    let bounds = bounds(&stroke)?;
    let serial = material(&stroke, bounds)?;
    let parallel = material(&stroke.clone().parallel_material(true), bounds)?;
    assert_eq!(serial.len(), parallel.len());
    for (serial, parallel) in serial.iter().zip(&parallel) {
        assert_eq!(serial.paint, parallel.paint);
    }
    motion_core::Node::from(stroke.clone().write(0.3)).compile(
        motion_core::Size::new(64.0, 32.0),
        motion_core::PixelSize::new(64, 32),
        motion_core::ValidationLimits::default(),
    )?;
    assert!(stroke.full_material.get().unwrap().is_ok());
    let first = stroke.full_material.get().unwrap() as *const _;
    motion_core::Node::from(stroke.clone().write(0.7)).compile(
        motion_core::Size::new(64.0, 32.0),
        motion_core::PixelSize::new(64, 32),
        motion_core::ValidationLimits::default(),
    )?;
    assert_eq!(first, stroke.full_material.get().unwrap() as *const _);
    assert!(material(&stroke.material_work_limit(1), bounds).is_err());
    Ok(())
}

#[test]
fn pigment_intersections_and_height_outputs_are_nonblank() -> motion_core::Result {
    let mixed = PigmentMix::KubelkaMunk
        .mix(Color::from_hex(0x0000ff), Color::from_hex(0xffff00), 0.5)?
        .to_rgba_float();
    assert!(mixed[1] > mixed[0] && mixed[1] > mixed[2]);
    let first = BrushStroke::new(
        Path2D::parse_svg("M8 16L56 16")?,
        8.0,
        Color::from_hex(0x0000ff),
    );
    let second = BrushStroke::new(
        Path2D::parse_svg("M32 4L32 28")?,
        8.0,
        Color::from_hex(0xffff00),
    );
    let limits = motion_core::RasterLimits::default();
    let render = |node: motion_core::Node| -> motion_core::Result<Vec<u8>> {
        Ok(node
            .compile(
                motion_core::Size::new(64.0, 32.0),
                motion_core::PixelSize::new(64, 32),
                motion_core::ValidationLimits::default(),
            )?
            .prepare(motion_core::ValidationLimits::default(), limits)?
            .render_linear(limits)?
            .to_srgb_rgba8(false, limits)?
            .bytes()
            .to_vec())
    };
    let painting = PigmentMix::KubelkaMunk.painting(vec![first.clone(), second]);
    let pixels = render(motion_core::Node::from(painting.clone()))?;
    let center = &pixels[(16 * 64 + 32) * 4..(16 * 64 + 33) * 4];
    assert!(center[1] > center[0] && center[1] > center[2]);
    assert_eq!(pixels, render(motion_core::Node::from(painting))?);
    let height = render(motion_core::Node::from(first.height_map(1.0)))?;
    assert!(
        height
            .as_chunks::<4>()
            .0
            .iter()
            .any(|pixel| pixel[0] > 0 && pixel[0] == pixel[1] && pixel[1] == pixel[2])
    );
    Ok(())
}

#[test]
fn tiled_pixels_match_single_tile_and_default_reveal_is_unchanged() -> motion_core::Result {
    let stroke = BrushStroke::new(
        Path2D::parse_svg("M8 16L56 16")?,
        8.0,
        Color::from_hex(0x4080c0),
    )
    .ink(Ink::new(19).granulation(0.3));
    let render = |stroke: BrushStroke| -> motion_core::Result<Vec<u8>> {
        let limits = motion_core::RasterLimits::default();
        let prepared = motion_core::Node::from(stroke)
            .compile(
                motion_core::Size::new(64.0, 32.0),
                motion_core::PixelSize::new(64, 32),
                motion_core::ValidationLimits::default(),
            )?
            .prepare(motion_core::ValidationLimits::default(), limits)?;
        Ok(prepared
            .render_linear(limits)?
            .to_srgb_rgba8(false, limits)?
            .bytes()
            .to_vec())
    };
    let tiled = render(stroke.clone().tiled_material(16))?;
    let whole = render(stroke.clone().tiled_material(256))?;
    assert_eq!(tiled, whole);
    assert!(tiled.iter().any(|byte| *byte > 0));
    let revealed = stroke.clone().write(0.5);
    let mut uncached = revealed.clone();
    uncached.reset();
    assert_eq!(render(revealed)?, render(uncached)?);
    Ok(())
}

#[test]
#[cfg(feature = "gpu")]
#[ignore = "requires native GPU; no fonts, window or encoder"]
fn extended_brush_material_matches_cpu_gpu() -> motion_core::Result {
    let stroke = BrushStroke::new(
        Path2D::parse_svg("M8 16Q32 0 56 16")?,
        8.0,
        Color::from_hex(0x4080c0),
    )
    .ink(Ink::new(19).granulation(0.3))
    .pressure_curve(vec![(0.0, 0.2), (0.5, 1.0), (1.0, 0.1)])
    .nib_mode(NibMode::FollowPath(0.0))
    .tiled_material(16);
    let limits = motion_core::RasterLimits::default();
    let prepared = motion_core::Node::from(stroke)
        .compile(
            motion_core::Size::new(64.0, 32.0),
            motion_core::PixelSize::new(64, 32),
            motion_core::ValidationLimits::default(),
        )?
        .prepare(motion_core::ValidationLimits::default(), limits)?;
    let expected = prepared
        .render_linear(limits)?
        .to_srgb_rgba8(false, limits)?;
    let mut gpu = motion_render::gpu::Gpu::new()?;
    let actual = gpu
        .render(&prepared, motion_render::gpu::Plan::new(&prepared, limits)?)?
        .to_srgb_rgba8(false, limits)?;
    assert!(
        expected
            .bytes()
            .iter()
            .zip(actual.bytes())
            .all(|(expected, actual)| expected.abs_diff(*actual) <= 2)
    );
    let replay = gpu
        .render(&prepared, motion_render::gpu::Plan::new(&prepared, limits)?)?
        .to_srgb_rgba8(false, limits)?;
    assert_eq!(actual.bytes(), replay.bytes());
    Ok(())
}