use std::collections::{HashMap, HashSet};
use uzor::ui::animation::math::timeline::Animatable;
use crate::layout::{GlyphLayout, ParagraphLayout};
use super::GlyphState;
#[derive(Debug, Clone)]
pub struct MorphTransition {
from: ParagraphLayout,
to: ParagraphLayout,
matched: Vec<(usize, usize)>,
from_only: Vec<usize>,
to_only: Vec<usize>,
}
impl MorphTransition {
pub fn matched_count(&self) -> usize {
self.matched.len()
}
pub fn from_only_count(&self) -> usize {
self.from_only.len()
}
pub fn to_only_count(&self) -> usize {
self.to_only.len()
}
}
fn glyph_keys(layout: &ParagraphLayout) -> Vec<(usize, usize, usize)> {
let mut word_ordinal: HashMap<usize, usize> = HashMap::new();
let mut gap_index: HashMap<usize, usize> = HashMap::new();
layout
.glyphs
.iter()
.map(|g| {
if is_whitespace_cluster(&g.cluster) {
let word = *word_ordinal.get(&g.run_index).unwrap_or(&0);
let gap = gap_index.entry(g.run_index).or_insert(0);
*gap += 1;
(g.run_index, word, *gap)
} else {
let word = word_ordinal.entry(g.run_index).or_insert(0);
*word += 1;
gap_index.insert(g.run_index, 0);
(g.run_index, *word, 0)
}
})
.collect()
}
fn is_whitespace_cluster(cluster: &str) -> bool {
!cluster.is_empty() && cluster.chars().all(char::is_whitespace)
}
pub fn build_morph(from: &ParagraphLayout, to: &ParagraphLayout) -> MorphTransition {
let from_keys = glyph_keys(from);
let to_keys = glyph_keys(to);
let mut to_index_by_key: HashMap<(usize, usize, usize), usize> = HashMap::with_capacity(to_keys.len());
for (idx, key) in to_keys.iter().enumerate() {
to_index_by_key.insert(*key, idx);
}
let mut matched = Vec::new();
let mut from_only = Vec::new();
let mut matched_to: HashSet<usize> = HashSet::new();
for (from_idx, key) in from_keys.iter().enumerate() {
match to_index_by_key.get(key) {
Some(&to_idx) => {
matched.push((from_idx, to_idx));
matched_to.insert(to_idx);
}
None => from_only.push(from_idx),
}
}
let to_only: Vec<usize> = (0..to.glyphs.len()).filter(|idx| !matched_to.contains(idx)).collect();
MorphTransition { from: from.clone(), to: to.clone(), matched, from_only, to_only }
}
fn at_rest(glyph: &GlyphLayout) -> GlyphState {
GlyphState { ch: glyph.cluster.clone(), pos: (glyph.x, glyph.y), opacity: 1.0, scale: 1.0, rotation: 0.0 }
}
pub fn sample(morph: &MorphTransition, t: f64) -> Vec<GlyphState> {
let t = t.clamp(0.0, 1.0);
let mut states = Vec::with_capacity(morph.matched.len() + morph.from_only.len() + morph.to_only.len());
for &(from_idx, to_idx) in &morph.matched {
let from_state = at_rest(&morph.from.glyphs[from_idx]);
let to_state = at_rest(&morph.to.glyphs[to_idx]);
states.push(from_state.lerp(&to_state, t));
}
for &from_idx in &morph.from_only {
let from_state = at_rest(&morph.from.glyphs[from_idx]);
let mut to_state = from_state.clone();
to_state.opacity = 0.0;
states.push(from_state.lerp(&to_state, t));
}
for &to_idx in &morph.to_only {
let to_state = at_rest(&morph.to.glyphs[to_idx]);
let mut from_state = to_state.clone();
from_state.opacity = 0.0;
states.push(from_state.lerp(&to_state, t));
}
states
}
pub fn sample_layout(morph: &MorphTransition, t: f64, base_rgb: u32) -> ParagraphLayout {
let t = t.clamp(0.0, 1.0);
let base_rgb = base_rgb & 0xffff_ff00;
let mut glyphs = Vec::with_capacity(morph.matched.len() + morph.from_only.len() + morph.to_only.len());
for &(from_idx, to_idx) in &morph.matched {
let from_glyph = &morph.from.glyphs[from_idx];
let to_glyph = &morph.to.glyphs[to_idx];
let sampled = at_rest(from_glyph).lerp(&at_rest(to_glyph), t);
glyphs.push(faded_glyph(to_glyph, sampled, base_rgb));
}
for &from_idx in &morph.from_only {
let from_glyph = &morph.from.glyphs[from_idx];
let from_state = at_rest(from_glyph);
let mut to_state = from_state.clone();
to_state.opacity = 0.0;
let sampled = from_state.lerp(&to_state, t);
glyphs.push(faded_glyph(from_glyph, sampled, base_rgb));
}
for &to_idx in &morph.to_only {
let to_glyph = &morph.to.glyphs[to_idx];
let to_state = at_rest(to_glyph);
let mut from_state = to_state.clone();
from_state.opacity = 0.0;
let sampled = from_state.lerp(&to_state, t);
glyphs.push(faded_glyph(to_glyph, sampled, base_rgb));
}
let width = morph.from.width.lerp(&morph.to.width, t);
let height = morph.from.height.lerp(&morph.to.height, t);
ParagraphLayout { glyphs, lines: Vec::new(), boxes: Vec::new(), decorations: Vec::new(), width, height }
}
fn faded_glyph(source: &GlyphLayout, sampled: GlyphState, base_rgb: u32) -> GlyphLayout {
let alpha = (sampled.opacity.clamp(0.0, 1.0) * 255.0).round() as u32;
GlyphLayout {
cluster: sampled.ch,
run_index: source.run_index,
line_index: source.line_index,
x: sampled.pos.0,
y: sampled.pos.1,
advance: source.advance,
width: source.width,
font: source.font,
color: Some(base_rgb | alpha),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use uzor::fonts::FontFamily;
use uzor_export::{render_to_png, ExportSpec};
use super::*;
use crate::layout::layout_text;
use crate::model::FontSpec;
use crate::shape::CosmicShaper;
const LONG_SENTENCE: &str = "The quick brown fox jumps over the lazy dog \
and then keeps running further down the road without stopping for a \
very long time indeed";
#[test]
fn build_morph_between_two_unwrapped_widths_matches_every_glyph() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let from = layout_text(LONG_SENTENCE, &font, 4000.0, &shaper);
let to = layout_text(LONG_SENTENCE, &font, 6000.0, &shaper);
assert_eq!(from.lines.len(), 1);
assert_eq!(to.lines.len(), 1);
let morph = build_morph(&from, &to);
assert_eq!(morph.matched_count(), from.glyphs.len());
assert_eq!(morph.matched_count(), to.glyphs.len());
assert_eq!(morph.from_only_count(), 0, "no glyph should be from-only when nothing wraps differently");
assert_eq!(morph.to_only_count(), 0, "no glyph should be to-only when nothing wraps differently");
}
#[test]
fn sample_at_zero_and_one_reproduces_the_endpoints_exactly() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let from = layout_text(LONG_SENTENCE, &font, 150.0, &shaper);
let to = layout_text(LONG_SENTENCE, &font, 550.0, &shaper);
assert!(from.lines.len() > to.lines.len(), "fixture must actually rewrap between the two widths");
let morph = build_morph(&from, &to);
assert!(morph.matched_count() > 0);
let at0 = sample(&morph, 0.0);
let at1 = sample(&morph, 1.0);
for (i, &(from_idx, _)) in morph_matched(&morph).iter().enumerate() {
let expected = &from.glyphs[from_idx];
assert_eq!(at0[i].pos, (expected.x, expected.y));
assert_eq!(at0[i].opacity, 1.0);
}
for (i, &(_, to_idx)) in morph_matched(&morph).iter().enumerate() {
let expected = &to.glyphs[to_idx];
assert_eq!(at1[i].pos, (expected.x, expected.y));
assert_eq!(at1[i].opacity, 1.0);
}
}
fn morph_matched(morph: &MorphTransition) -> Vec<(usize, usize)> {
let from_keys = glyph_keys(&morph.from);
let to_keys = glyph_keys(&morph.to);
let mut to_index_by_key: HashMap<(usize, usize, usize), usize> = HashMap::with_capacity(to_keys.len());
for (idx, key) in to_keys.iter().enumerate() {
to_index_by_key.insert(*key, idx);
}
from_keys
.iter()
.enumerate()
.filter_map(|(from_idx, key)| to_index_by_key.get(key).map(|&to_idx| (from_idx, to_idx)))
.collect()
}
#[test]
fn sample_at_half_stays_between_the_endpoints_and_never_produces_nan() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let from = layout_text(LONG_SENTENCE, &font, 150.0, &shaper);
let to = layout_text(LONG_SENTENCE, &font, 550.0, &shaper);
let morph = build_morph(&from, &to);
let mid = sample(&morph, 0.5);
assert_eq!(mid.len(), morph.matched_count() + morph.from_only_count() + morph.to_only_count());
let mut moved_count = 0;
for state in &mid {
assert!(state.pos.0.is_finite() && state.pos.1.is_finite(), "no NaN/inf in sampled position");
assert!((0.0..=1.0).contains(&state.opacity), "opacity must stay in [0,1], got {}", state.opacity);
}
for (idx, &(from_idx, to_idx)) in morph_matched(&morph).iter().enumerate() {
let a = &from.glyphs[from_idx];
let b = &to.glyphs[to_idx];
let sampled_pos = mid[idx].pos;
let (lo_x, hi_x) = (a.x.min(b.x), a.x.max(b.x));
let (lo_y, hi_y) = (a.y.min(b.y), a.y.max(b.y));
assert!(sampled_pos.0 >= lo_x - 1e-6 && sampled_pos.0 <= hi_x + 1e-6);
assert!(sampled_pos.1 >= lo_y - 1e-6 && sampled_pos.1 <= hi_y + 1e-6);
if (a.x - b.x).abs() > 1e-6 || (a.y - b.y).abs() > 1e-6 {
moved_count += 1;
}
}
assert!(moved_count > 0, "fixture must produce at least some real glyph movement, not just a static fixture");
}
#[test]
fn build_morph_on_mismatched_text_does_not_panic() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let from = layout_text("Hello world, a short greeting.", &font, 1000.0, &shaper);
let to = layout_text(
"A totally different, unrelated sentence about something else entirely.",
&font,
1000.0,
&shaper,
);
let morph = build_morph(&from, &to);
assert_eq!(morph.matched_count() + morph.from_only_count(), from.glyphs.len());
assert_eq!(morph.matched_count() + morph.to_only_count(), to.glyphs.len());
let at0 = sample(&morph, 0.0);
let at1 = sample(&morph, 1.0);
let mid = sample(&morph, 0.5);
assert_eq!(at0.len(), mid.len());
assert_eq!(at1.len(), mid.len());
for state in mid.iter().chain(at0.iter()).chain(at1.iter()) {
assert!(state.pos.0.is_finite() && state.pos.1.is_finite());
assert!((0.0..=1.0).contains(&state.opacity));
}
}
#[test]
fn sample_layout_produces_a_valid_layout_with_matching_glyph_count() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let from = layout_text(LONG_SENTENCE, &font, 150.0, &shaper);
let to = layout_text(LONG_SENTENCE, &font, 550.0, &shaper);
let morph = build_morph(&from, &to);
let mid_layout = sample_layout(&morph, 0.5, 0x111111ff);
assert_eq!(mid_layout.glyphs.len(), morph.matched_count() + morph.from_only_count() + morph.to_only_count());
assert!(mid_layout.width.is_finite() && mid_layout.height.is_finite());
for g in &mid_layout.glyphs {
assert!(g.x.is_finite() && g.y.is_finite());
assert!(g.color.is_some(), "sample_layout must always bake an explicit color for fade support");
}
}
const WIDTH: u32 = 600;
const HEIGHT: u32 = 400;
const MARGIN: f64 = 20.0;
const NARROW_WIDTH: f64 = 160.0;
const WIDE_WIDTH: f64 = 560.0;
const PROOF_PARAGRAPH: &str = "The quick brown fox jumps over the lazy dog and \
then keeps running further down the road without stopping, a fixed seeded \
sample paragraph morphing between a narrow and a wide column for every \
uzor-text Phase 3 kinetics proof render.";
fn out_dir() -> PathBuf {
PathBuf::from(r"C:\Users\VA PC\CODING\ML_TRADING\nemo\uzor\out")
}
fn write_proof_png(name: &str, bytes: &[u8]) {
let dir = out_dir();
std::fs::create_dir_all(&dir).expect("create uzor/out/ proof directory");
std::fs::write(dir.join(name), bytes).expect("write proof PNG");
}
fn decoded_png_dims(bytes: &[u8]) -> (u32, u32) {
let decoder = png::Decoder::new(bytes);
let reader = decoder.read_info().expect("valid PNG header");
let info = reader.info();
(info.width, info.height)
}
#[test]
fn morph_proof_strip_renders_t0_t05_t1_to_valid_pngs() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let narrow = layout_text(PROOF_PARAGRAPH, &font, NARROW_WIDTH, &shaper);
let wide = layout_text(PROOF_PARAGRAPH, &font, WIDE_WIDTH, &shaper);
assert!(narrow.lines.len() > wide.lines.len(), "fixture must actually rewrap between the two widths");
let morph = build_morph(&narrow, &wide);
let spec = ExportSpec { width_px: WIDTH, height_px: HEIGHT, dpr: 1.0, background: Some([255, 255, 255, 255]) };
for (t, name) in [(0.0, "text_p3_morph_t0.png"), (0.5, "text_p3_morph_t05.png"), (1.0, "text_p3_morph_t1.png")] {
let sampled = sample_layout(&morph, t, 0x111111_ff);
let bytes = render_to_png(&spec, |ctx| {
crate::draw::draw_paragraph(ctx, (MARGIN, MARGIN), &sampled, "#111111", false);
})
.unwrap_or_else(|e| panic!("morph proof render at t={t} should succeed: {e}"));
assert_eq!(decoded_png_dims(&bytes), (WIDTH, HEIGHT));
write_proof_png(name, &bytes);
}
}
}