tilezz 0.2.0

Utilities to work with perfect-precision polygonal tiles built on top of cyclotomic integer rings.
Documentation
//! Render a Heesch / corona witness patch to a picture.
//!
//! A cannot-tile or aperiodic-candidate certificate carries its deepest
//! legal patch as a `build` -- the glue sequence that surrounds the seed
//! tile with copies of itself. This module turns that witness into an SVG:
//! [`replay_placements`] recovers each copy's placement isometry, then the
//! copies are drawn filled with black unit-edge outlines, colored one of two
//! ways:
//!
//! * [`TileColoring::Corona`] -- by concentric ring (seed = ring 0, its
//!   edge-neighbours ring 1, and so on outward), so the corona structure
//!   reads at a glance.
//! * [`TileColoring::Rotation`] -- by placement rotation (one hue per ring
//!   orientation), which exposes the orientation symmetry of the surround.
//!
//! Rings are recovered geometrically: two copies TOUCH iff they share a
//! vertex (Heesch coronas count contact at a vertex, not only along an
//! edge), and the ring index is the contact-graph distance from the seed.
//! This is exact (ring-coordinate equality) and independent of the order the
//! burial search happened to glue tiles in; the maximum ring equals the
//! tile's Heesch number.

use std::collections::VecDeque;

use crate::classify::grow::replay_placements;
use crate::cyclotomic::IsRing;
use crate::geom::iso::Iso;
use crate::geom::matches::PatchMatch;
use crate::geom::patch::boundary_vertices;
use crate::geom::rat::Rat;
use crate::vis::draw::{TileStyle, rainbow};
use crate::vis::plotutils::R64;
use crate::vis::scene::{Color, Fill, Scene, Stroke, TextStyle, Viewport};

/// How to color the tile copies of a witness patch. See the module docs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TileColoring {
    /// By concentric ring: seed = ring 0, edge-neighbours = ring 1, outward.
    Corona,
    /// By placement rotation (`Iso::rot`), one hue per ring orientation.
    Rotation,
}

/// Per-copy fill color by concentric ring: seed (copy 0) = warm, outward = cool
/// blue. Rings are the contact-graph distance from the seed (two copies touch
/// iff they share a vertex -- edge OR corner). See [`TileColoring::Corona`].
///
/// This is THE corona metric -- Heesch coronas are vertex-contact by definition
/// (a bare corner touch counts, so the max ring equals the tile's Heesch
/// number), which is exactly what this colors (a `heesch_number_witnessed`
/// corona). Do NOT repurpose it to layer an EDGE-burial patch (`grow_coronas`,
/// a hand-grown `IPatch`): that is a different, non-corona notion, and here a
/// corner touch shortcuts the BFS and mislabels outer tiles into inner rings, so
/// it reads ragged even when the patch is edge-complete. An edge-burial patch's
/// ring structure is its tile adjacency
/// ([`IPatch::adj`](crate::geom::patch::WithAdjacency::adj)), not a corona.
pub fn corona_ring_colors<T: IsRing>(base_verts: &[T], placements: &[Iso<T>]) -> Vec<Color> {
    let polys: Vec<Vec<T>> = placements.iter().map(|iso| iso.tile(base_verts)).collect();
    let rings = corona_rings::<T>(&polys);
    let pal = ring_ramp(rings.iter().copied().max().map_or(1, |m| m + 1));
    rings.iter().map(|&r| pal[r]).collect()
}

/// Per-copy fill color by placement orientation (`Iso::rot`), one hue per ring
/// rotation. See [`TileColoring::Rotation`].
pub fn orientation_colors<T: IsRing>(placements: &[Iso<T>]) -> Vec<Color> {
    let turn = T::turn() as usize;
    let pal = rainbow(turn, 0.60, 0.55);
    placements
        .iter()
        .map(|iso| pal[(iso.rot as usize).rem_euclid(turn)])
        .collect()
}

/// Draw placed copies of `base_verts` into a white-backed [`Scene`]: copy `i` is
/// `placements[i].tile(base_verts)`, filled `fills[i]` with a black edge of width
/// `border`, optionally centre-labelled `labels[i]`. The single reusable tile
/// renderer -- corona/orientation colorings ([`corona_ring_colors`],
/// [`orientation_colors`]) and downstream demos all feed it a `fills` vec, and
/// overlay markers/outlines on the returned scene themselves.
pub fn scene_from_placements<T: IsRing>(
    base_verts: &[T],
    placements: &[Iso<T>],
    fills: &[Color],
    labels: Option<&[String]>,
    border: f64,
) -> Scene {
    let mut scene = Scene::new().with_background(Color::WHITE);
    for (i, iso) in placements.iter().enumerate() {
        let pts: Vec<(f64, f64)> = iso.tile(base_verts).iter().map(|q| q.xy()).collect();
        let mut st = TileStyle::filled(Fill::solid(fills[i]), Stroke::solid(Color::BLACK, border));
        if let Some(l) = labels {
            st = st.with_center_label(l[i].clone(), TextStyle::new(0.45, Color::BLACK).bold());
        }
        scene.draw_tile(&pts, &st);
    }
    scene
}

/// Build a [`Scene`] of a witness patch and its float bounding box.
/// `None` if the build cannot be replayed (malformed witness).
fn build_scene<T: IsRing>(
    base: &Rat<T>,
    build: &[PatchMatch],
    coloring: TileColoring,
) -> Option<(Scene, R64)> {
    let placements = replay_placements(base, build)?;
    let verts = boundary_vertices::<T>(base.seq());
    let colors = match coloring {
        TileColoring::Rotation => orientation_colors::<T>(&placements),
        TileColoring::Corona => corona_ring_colors::<T>(&verts, &placements),
    };
    let scene = scene_from_placements::<T>(&verts, &placements, &colors, None, 0.05);
    let bounds = scene.auto_bounds()?;
    Some((scene, bounds))
}

/// A witness patch as a square SVG string (side `side_px`, ~2% padding).
/// `None` if the build cannot be replayed.
pub fn witness_svg<T: IsRing>(
    base: &Rat<T>,
    build: &[PatchMatch],
    coloring: TileColoring,
    side_px: u32,
) -> Option<String> {
    let (scene, bounds) = build_scene(base, build, coloring)?;
    Some(scene.to_svg(&Viewport::square_for(side_px, bounds, side_px / 50)))
}

/// Concentric ring index of each placed copy: seed (copy 0) is ring 0, and a
/// copy's ring is its contact-graph distance from the seed, where two copies
/// are adjacent iff they share a vertex (Heesch corona contact -- edge OR
/// corner). Any copy not connected to the seed falls back to ring 0.
fn corona_rings<T: IsRing>(polys: &[Vec<T>]) -> Vec<usize> {
    let n = polys.len();
    let mut ring = vec![usize::MAX; n];
    if n == 0 {
        return ring;
    }
    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
    for a in 0..n {
        for b in (a + 1)..n {
            let touch = polys[a].iter().any(|p| polys[b].contains(p));
            if touch {
                adj[a].push(b);
                adj[b].push(a);
            }
        }
    }
    ring[0] = 0;
    let mut q = VecDeque::from([0usize]);
    while let Some(u) = q.pop_front() {
        for &v in &adj[u] {
            if ring[v] == usize::MAX {
                ring[v] = ring[u] + 1;
                q.push_back(v);
            }
        }
    }
    for r in &mut ring {
        if *r == usize::MAX {
            *r = 0;
        }
    }
    ring
}

/// Ordinal ring palette: the seed (ring 0) is a warm highlight; outer rings
/// are a cool blue lightening outward. Clamps past the last defined ring.
fn ring_ramp(nrings: usize) -> Vec<Color> {
    const RAMP: [Color; 5] = [
        Color::rgb(230, 126, 34),  // ring 0: seed (warm)
        Color::rgb(52, 120, 190),  // ring 1
        Color::rgb(120, 175, 225), // ring 2
        Color::rgb(176, 206, 236), // ring 3
        Color::rgb(208, 224, 242), // ring 4+
    ];
    (0..nrings.max(1))
        .map(|k| RAMP[k.min(RAMP.len() - 1)])
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::classify::heesch::{count_coronas, heesch_number_witnessed};
    use crate::cyclotomic::ZZ12;
    use crate::geom::tileset::TileSet;

    /// A known Heesch-2 tile's maximal corona renders to a well-formed SVG in
    /// both coloring modes, and the recovered ring labels match the tile's
    /// Heesch number (seed = 0, up to ring 2 with no gaps).
    #[test]
    fn witness_renders_and_rings_match_heesch_number() {
        // idx 153373378 from the n=15 screen: Heesch 2, 20-tile corona.
        let seq: &[i8] = &[-4, 0, 2, 2, 2, 2, -2, 0, 2, 0, 2, 4, -2, 0, 4];
        let base = Rat::<ZZ12>::from_slice_trusted(seq);
        let (_h, build) = heesch_number_witnessed(TileSet::single(base.clone()), 0, 3, 200_000);

        let placements = replay_placements(&base, &build).expect("replay");
        let verts = boundary_vertices::<ZZ12>(base.seq());
        let polys: Vec<Vec<ZZ12>> = placements.iter().map(|iso| iso.tile(&verts)).collect();
        let rings = corona_rings::<ZZ12>(&polys);
        assert_eq!(rings[0], 0, "seed must be ring 0");
        let maxr = rings.iter().copied().max().unwrap();
        assert_eq!(
            maxr,
            count_coronas(&base, &build),
            "max ring must equal the corona count"
        );
        // every ring 0..=maxr is populated (no gaps)
        for r in 0..=maxr {
            assert!(rings.contains(&r), "ring {r} should be non-empty");
        }

        for mode in [TileColoring::Corona, TileColoring::Rotation] {
            let svg = witness_svg(&base, &build, mode, 400).expect("svg");
            assert!(svg.starts_with("<svg"), "svg well-formed ({mode:?})");
            assert!(svg.contains("<polygon"), "svg has tile polygons ({mode:?})");
        }
    }
}