use std::cell::RefCell;
use std::collections::HashMap;
use std::io;
use std::rc::Rc;
use serde::Serialize;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use crate::cyclotomic::{IsRing, *};
use crate::geom::rat::Rat;
use crate::geom::snake::{Snake, Turtle};
use crate::stringmatch::{LazyRatDafsaAsync, repetition_factor};
use crate::vis::draw::{MarkerStyle, TileStyle};
use crate::vis::plotutils::P64;
use crate::vis::scene::{ArrowStyle, Color, Fill, Scene, Stroke, TextStyle, Viewport};
#[wasm_bindgen(start)]
pub fn start() {
console_error_panic_hook::set_once();
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LabelMode {
#[default]
None,
Angles,
IndexEdge,
}
impl LabelMode {
fn from_u8(v: u8) -> Self {
match v {
1 => LabelMode::Angles,
2 => LabelMode::IndexEdge,
_ => LabelMode::None,
}
}
}
#[derive(Debug, Default, Serialize)]
pub struct AnalysisResult {
pub error: Option<String>,
pub svg: String,
pub state: SnakeState,
pub preview: Option<PreviewSummary>,
pub self_intersect_at: Option<usize>,
}
#[derive(Debug, Default, Serialize)]
pub struct SnakeState {
pub length: usize,
pub angle_sum: i64,
pub closed: bool,
pub angles: Vec<i8>,
pub rat: Option<RatInfo>,
}
#[derive(Debug, Serialize)]
pub struct RatInfo {
pub chirality: i8,
pub rotational_order: u32,
pub achiral: bool,
pub canonical_chiral: Vec<i8>,
pub canonical_achiral: Vec<i8>,
}
#[derive(Debug, Serialize)]
pub struct PreviewSummary {
pub angle: i8,
pub accepted: bool,
}
#[wasm_bindgen]
pub fn analyze(ring: u8, angles: Vec<i8>, preview: Option<i32>, labels: u8) -> JsValue {
let result = analyze_data_with_labels(ring, &angles, preview, LabelMode::from_u8(labels));
serde_wasm_bindgen::to_value(&result).unwrap_or(JsValue::NULL)
}
fn odd_ring_parent(ring: u8) -> Option<(u8, i8)> {
match ring {
3 => Some((6, 2)),
5 => Some((10, 2)),
7 => Some((14, 2)),
9 => Some((18, 2)),
_ => None,
}
}
fn halve_displayed_turns(result: &mut AnalysisResult, scale: i8) {
let s = scale as i64;
for a in result.state.angles.iter_mut() {
*a /= scale;
}
result.state.angle_sum /= s;
if let Some(rat) = result.state.rat.as_mut() {
for a in rat.canonical_chiral.iter_mut() {
*a /= scale;
}
for a in rat.canonical_achiral.iter_mut() {
*a /= scale;
}
}
if let Some(p) = result.preview.as_mut() {
p.angle /= scale;
}
}
pub fn analyze_data(ring: u8, angles: &[i8], preview: Option<i32>) -> AnalysisResult {
analyze_data_with_labels(ring, angles, preview, LabelMode::None)
}
fn analyze_data_with_labels(
ring: u8,
angles: &[i8],
preview: Option<i32>,
labels: LabelMode,
) -> AnalysisResult {
let preview_angle: Option<i8> = preview.and_then(|p| i8::try_from(p).ok());
if angles.is_empty() && preview_angle.is_none() {
return AnalysisResult {
svg: empty_svg(),
..AnalysisResult::default()
};
}
if let Some((parent, scale)) = odd_ring_parent(ring) {
let scaled: Vec<i8> = angles.iter().map(|&a| a.saturating_mul(scale)).collect();
let scaled_preview = preview_angle.map(|p| p.saturating_mul(scale));
let mut result = dispatch_ring(parent, &scaled, scaled_preview, scale, labels);
halve_displayed_turns(&mut result, scale);
return result;
}
dispatch_ring(ring, angles, preview_angle, 1, labels)
}
fn dispatch_ring(
ring: u8,
angles: &[i8],
preview_angle: Option<i8>,
display_scale: i8,
labels: LabelMode,
) -> AnalysisResult {
match ring {
4 => analyze_for_ring::<ZZ4>(angles, preview_angle, display_scale, labels),
6 => analyze_for_ring::<ZZ6>(angles, preview_angle, display_scale, labels),
8 => analyze_for_ring::<ZZ8>(angles, preview_angle, display_scale, labels),
10 => analyze_for_ring::<ZZ10>(angles, preview_angle, display_scale, labels),
12 => analyze_for_ring::<ZZ12>(angles, preview_angle, display_scale, labels),
14 => analyze_for_ring::<ZZ14>(angles, preview_angle, display_scale, labels),
16 => analyze_for_ring::<ZZ16>(angles, preview_angle, display_scale, labels),
18 => analyze_for_ring::<ZZ18>(angles, preview_angle, display_scale, labels),
20 => analyze_for_ring::<ZZ20>(angles, preview_angle, display_scale, labels),
24 => analyze_for_ring::<ZZ24>(angles, preview_angle, display_scale, labels),
32 => analyze_for_ring::<ZZ32>(angles, preview_angle, display_scale, labels),
60 => analyze_for_ring::<ZZ60>(angles, preview_angle, display_scale, labels),
_ => AnalysisResult {
error: Some(format!("unsupported ring: {ring}")),
svg: empty_svg(),
..AnalysisResult::default()
},
}
}
#[derive(Debug, Clone, Copy)]
enum PreviewState {
None,
Accepted(i8),
Rejected(i8),
}
fn analyze_for_ring<R: IsRing>(
angles: &[i8],
preview_angle: Option<i8>,
display_scale: i8,
labels: LabelMode,
) -> AnalysisResult {
let mut snake: Snake<R> = Snake::new();
let mut self_intersect_at: Option<usize> = None;
for (i, &a) in angles.iter().enumerate() {
if !snake.add(a) {
self_intersect_at = Some(i);
break;
}
}
let committed_was_closed = snake.is_closed();
let committed_polyline: Vec<P64> = snake.to_polyline_f64(Turtle::default());
let committed_angles: Vec<i8> = snake.angles().to_vec();
let mut conflict_edge_idx: Option<usize> = None;
let preview_state = if self_intersect_at.is_some() || committed_was_closed {
PreviewState::None
} else {
match preview_angle {
Some(p) => match snake.add_diagnosed(p) {
None => PreviewState::Accepted(p),
Some(idx) => {
conflict_edge_idx = Some(idx);
PreviewState::Rejected(p)
}
},
None => PreviewState::None,
}
};
if snake.is_empty() {
return AnalysisResult {
svg: empty_svg(),
state: SnakeState {
length: 0,
angle_sum: 0,
closed: false,
angles: Vec::new(),
rat: None,
},
preview: None,
self_intersect_at,
..AnalysisResult::default()
};
}
let polyline: Vec<P64> = snake.to_polyline_f64(Turtle::default());
let rejected_endpoint: Option<P64> = if let PreviewState::Rejected(p) = preview_state {
let mut probe: Vec<i8> = angles.to_vec();
probe.push(p);
let probe_snake: Snake<R> = Snake::from_slice_unchecked(&probe);
probe_snake
.to_polyline_f64(Turtle::default())
.last()
.copied()
} else {
None
};
let preview_endpoint: Option<P64> = match preview_state {
PreviewState::None => None,
PreviewState::Accepted(_) if snake.is_closed() => None,
PreviewState::Accepted(_) => polyline.last().copied(),
PreviewState::Rejected(_) => rejected_endpoint,
};
let mut min_x = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut min_y = f64::INFINITY;
let mut max_y = f64::NEG_INFINITY;
for &(x, y) in committed_polyline.iter().chain(preview_endpoint.iter()) {
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
}
let cx = (min_x + max_x) / 2.0;
let cy = (min_y + max_y) / 2.0;
let half = ((max_x - min_x).max(max_y - min_y) / 2.0).max(0.05) * 1.15;
let bounds = ((cx - half, cy - half), (cx + half, cy + half));
let viewport = Viewport::square_for(400, bounds, 8);
let mut scene = Scene::new().with_background(Color::WHITE);
let stroke_w = 0.018 * half;
let marker_size = 0.05 * half;
let arrow_size = 0.07 * half;
let committed = &committed_polyline;
if committed_was_closed {
let style = TileStyle::filled(
Fill::solid(Color::YELLOW.with_alpha(96)),
Stroke::solid(Color::BLACK, stroke_w),
)
.with_vertex_marker(MarkerStyle::filled_circle(marker_size, Color::BLUE))
.with_edge_arrows_mid(arrow_size);
scene.draw_tile(committed, &style);
if let Some(&start) = committed.first() {
let start_marker = MarkerStyle::filled_circle(marker_size * 1.4, Color::RED);
scene.draw_points(&[start], &start_marker);
}
} else if committed.len() >= 2 {
scene.draw_polyline_with_arrow(
committed,
Stroke::solid(Color::BLACK, stroke_w),
ArrowStyle::per_edge_mid(arrow_size),
);
let blue_dot = MarkerStyle::filled_circle(marker_size, Color::BLUE);
scene.draw_points(committed, &blue_dot);
if let Some(&start) = committed.first() {
let start_marker = MarkerStyle::filled_circle(marker_size * 1.4, Color::RED);
scene.draw_points(&[start], &start_marker);
}
let preview_active = matches!(
preview_state,
PreviewState::Accepted(_) | PreviewState::Rejected(_)
);
if !preview_active && let Some(&target) = committed.last() {
let target_marker = MarkerStyle::filled_circle(marker_size * 1.4, Color::GREEN);
scene.draw_points(&[target], &target_marker);
}
} else if let Some(&p) = committed.first() {
let start_marker = MarkerStyle::filled_circle(marker_size * 1.4, Color::RED);
scene.draw_points(&[p], &start_marker);
}
if !matches!(labels, LabelMode::None) && !committed_angles.is_empty() {
let n = committed_angles.len();
let no_marker = MarkerStyle::filled_circle(0.0, Color::BLACK.with_alpha(0));
let vtxt = TextStyle::new(marker_size * 1.6, Color::WHITE).bold();
let disk_for = |i: usize| {
let col = if i == 0 { Color::RED } else { Color::BLUE };
MarkerStyle::filled_circle(marker_size * 2.0, col)
};
match labels {
LabelMode::Angles => {
for i in 0..n {
let a = committed_angles[i] / display_scale;
scene.draw_labeled_points(&[committed[i]], &disk_for(i), &vtxt, move |_, _| {
format!("{a}")
});
}
}
LabelMode::IndexEdge => {
let edge_style = TextStyle::new(marker_size * 1.5, Color::rgb(0, 70, 150)).bold();
for i in 0..n {
scene.draw_labeled_points(&[committed[i]], &disk_for(i), &vtxt, move |_, _| {
format!("{i}")
});
let (from, to) = (committed[i], committed[i + 1]);
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let off = marker_size * 1.6;
let mid = (
(from.0 + to.0) / 2.0 - dy / len * off,
(from.1 + to.1) / 2.0 + dx / len * off,
);
let a = committed_angles[i] / display_scale;
scene.draw_labeled_points(&[mid], &no_marker, &edge_style, move |_, _| {
format!("{a}")
});
}
}
LabelMode::None => {}
}
}
let preview_overlay: Option<(P64, Color, i8)> = match preview_state {
PreviewState::None => None,
PreviewState::Accepted(p) => polyline.last().map(|&pt| (pt, Color::rgb(0, 180, 200), p)),
PreviewState::Rejected(p) => {
rejected_endpoint.map(|endp| (endp, Color::rgb(255, 120, 0), p))
}
};
if let Some((to, color, angle)) = preview_overlay
&& let Some(&from) = committed.last()
{
let preview_stroke = Stroke::solid(color, stroke_w * 1.2);
scene.draw_polyline_with_arrow(
&[from, to],
preview_stroke,
ArrowStyle::per_edge_mid(arrow_size),
);
if let Some(idx) = conflict_edge_idx {
let (e0, e1) = (committed_polyline[idx], committed_polyline[idx + 1]);
let point = segment_intersection_f64(from, to, e0, e1)
.unwrap_or_else(|| closest_point_on_segment(to, e0, e1));
let conflict_outer =
MarkerStyle::filled_circle(marker_size * 0.8, Color::rgb(220, 0, 0));
let conflict_inner = MarkerStyle::filled_circle(marker_size * 0.35, Color::YELLOW);
scene.draw_points(&[point], &conflict_outer);
scene.draw_points(&[point], &conflict_inner);
}
let preview_marker = MarkerStyle::filled_circle(marker_size * 2.0, color);
let label_style = TextStyle::new(marker_size * 1.6, Color::WHITE).bold();
let shown_angle = angle / display_scale;
scene.draw_labeled_points(&[from], &preview_marker, &label_style, |_, _| {
format!("{shown_angle}")
});
}
let svg = scene.to_svg(&viewport);
if matches!(preview_state, PreviewState::Accepted(_)) {
snake.pop();
}
let state = snake_state(&snake);
let preview = match preview_state {
PreviewState::None => None,
PreviewState::Accepted(a) => Some(PreviewSummary {
angle: a,
accepted: true,
}),
PreviewState::Rejected(a) => Some(PreviewSummary {
angle: a,
accepted: false,
}),
};
AnalysisResult {
error: None,
svg,
state,
preview,
self_intersect_at,
}
}
fn segment_intersection_f64(a: P64, b: P64, c: P64, d: P64) -> Option<P64> {
let (ax, ay) = a;
let (bx, by) = b;
let (cx, cy) = c;
let (dx, dy) = d;
let r1 = bx - ax;
let r2 = by - ay;
let r3 = -(dx - cx);
let r4 = -(dy - cy);
let det = r1 * r4 - r2 * r3;
if det.abs() < 1e-18 {
return None; }
let rhs1 = cx - ax;
let rhs2 = cy - ay;
let t = (rhs1 * r4 - rhs2 * r3) / det;
let t = t.clamp(0.0, 1.0);
Some((ax + t * r1, ay + t * r2))
}
fn closest_point_on_segment(p: P64, c: P64, d: P64) -> P64 {
let (px, py) = p;
let (cx, cy) = c;
let (dx, dy) = d;
let (ux, uy) = (dx - cx, dy - cy);
let len2 = ux * ux + uy * uy;
if len2 < 1e-18 {
return c; }
let t = (((px - cx) * ux + (py - cy) * uy) / len2).clamp(0.0, 1.0);
(cx + t * ux, cy + t * uy)
}
fn snake_state<R: IsRing>(snake: &Snake<R>) -> SnakeState {
let closed = snake.is_closed();
let length = snake.len();
let angle_sum = snake.angle_sum();
let angles = snake.angles().to_vec();
let rat = if closed {
let rat = Rat::from_unchecked(snake);
let chirality = rat.chirality();
let rat = if rat.chirality() >= 0 {
rat
} else {
rat.reversed()
};
let rotational_order = repetition_factor(rat.seq()) as u32;
let chiral_canon = rat.clone().canonical();
let mirror_canon = rat.reflected().canonical();
let chiral_seq = chiral_canon.seq();
let mirror_seq = mirror_canon.seq();
let achiral = chiral_seq == mirror_seq;
let achiral_seq: &[i8] = if chiral_seq <= mirror_seq {
chiral_seq
} else {
mirror_seq
};
Some(RatInfo {
chirality,
rotational_order,
achiral,
canonical_chiral: chiral_seq.to_vec(),
canonical_achiral: achiral_seq.to_vec(),
})
} else {
None
};
SnakeState {
length,
angle_sum,
closed,
angles,
rat,
}
}
fn empty_svg() -> String {
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"-1 -1 2 2\" \
width=\"400\" height=\"400\"></svg>"
.to_string()
}
struct DbState {
dafsa: LazyRatDafsaAsync,
asset_dir: String,
inflight: RefCell<HashMap<u32, js_sys::Promise>>,
}
thread_local! {
static DBS: RefCell<HashMap<u8, Rc<DbState>>> = RefCell::new(HashMap::new());
}
#[wasm_bindgen]
pub async fn db_init(ring: u8, asset_dir: String) -> Result<JsValue, JsValue> {
let normalised_dir = asset_dir.trim_end_matches('/').to_string();
let manifest_url = format!("{normalised_dir}/block_index.json");
let bytes = fetch_url_to_bytes(&manifest_url)
.await
.map_err(|e| JsValue::from_str(&format!("fetch manifest: {e}")))?;
let text = std::str::from_utf8(&bytes)
.map_err(|e| JsValue::from_str(&format!("manifest not UTF-8: {e}")))?;
let dafsa = LazyRatDafsaAsync::open(text)
.map_err(|e| JsValue::from_str(&format!("parse manifest: {e}")))?;
let total = dafsa.len();
let max_len = dafsa.manifest().max_indexed_length;
DBS.with(|dbs| {
dbs.borrow_mut().insert(
ring,
Rc::new(DbState {
dafsa,
asset_dir: normalised_dir,
inflight: RefCell::new(HashMap::new()),
}),
)
});
let obj = js_sys::Object::new();
js_sys::Reflect::set(&obj, &"total".into(), &(total as f64).into())?;
js_sys::Reflect::set(&obj, &"max_len".into(), &(max_len as f64).into())?;
Ok(obj.into())
}
#[wasm_bindgen]
pub async fn db_id_of(ring: u8, angles: Vec<i8>) -> Option<f64> {
let state = lookup_db(ring)?;
let (canon_ring, canon_angles): (u8, Vec<i8>) = match odd_ring_parent(ring) {
Some((parent, scale)) => (
parent,
angles.iter().map(|&a| a.saturating_mul(scale)).collect(),
),
None => (ring, angles),
};
let canonical = closing_free_canonical_for_ring(canon_ring, &canon_angles)?;
let state_for_fetch = state.clone();
let fetch = move |block_index: u32| {
let st = state_for_fetch.clone();
async move { fetch_block_coalesced(st, block_index).await }
};
state
.dafsa
.index_of(&canonical, &fetch)
.await
.map(|r| r as f64)
}
#[wasm_bindgen]
pub async fn db_seq_of(ring: u8, id: f64) -> Option<Vec<i8>> {
if !(id.is_finite() && id >= 0.0) {
return None;
}
let state = lookup_db(ring)?;
let state_for_fetch = state.clone();
let fetch = move |block_index: u32| {
let st = state_for_fetch.clone();
async move { fetch_block_coalesced(st, block_index).await }
};
let seq = state.dafsa.get(id as u64, &fetch).await?;
Some(match odd_ring_parent(ring) {
Some((_, scale)) => seq.iter().map(|&a| a / scale).collect(),
None => seq,
})
}
#[wasm_bindgen]
pub async fn db_prewarm(ring: u8, max_depth: usize) -> f64 {
let Some(state) = lookup_db(ring) else {
return 0.0;
};
let state_for_fetch = state.clone();
let fetch = move |block_index: u32| {
let st = state_for_fetch.clone();
async move { fetch_block_coalesced(st, block_index).await }
};
state.dafsa.prewarm_to_depth(max_depth, &fetch).await as f64
}
fn lookup_db(ring: u8) -> Option<Rc<DbState>> {
DBS.with(|dbs| dbs.borrow().get(&ring).cloned())
}
fn resolve_block_url(asset_dir: &str, block_url: &str) -> String {
if block_url.contains("://") {
block_url.to_string()
} else {
format!("{asset_dir}/{block_url}")
}
}
async fn fetch_block_coalesced(state: Rc<DbState>, block_index: u32) -> io::Result<Vec<u8>> {
let existing = state.inflight.borrow().get(&block_index).cloned();
let promise = match existing {
Some(p) => p,
None => {
let url = {
let manifest = state.dafsa.manifest();
let entry = &manifest.blocks[block_index as usize];
resolve_block_url(&state.asset_dir, &manifest.block_url(entry))
};
let promise = wasm_bindgen_futures::future_to_promise(async move {
match fetch_url_to_bytes(&url).await {
Ok(bytes) => {
let arr = js_sys::Uint8Array::new_with_length(bytes.len() as u32);
arr.copy_from(&bytes);
Ok(arr.into())
}
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
});
state
.inflight
.borrow_mut()
.insert(block_index, promise.clone());
promise
}
};
let result = JsFuture::from(promise).await;
state.inflight.borrow_mut().remove(&block_index);
match result {
Ok(v) => {
let arr: js_sys::Uint8Array = v
.dyn_into()
.map_err(|_| io::Error::other("coalesced fetch: not a Uint8Array"))?;
Ok(arr.to_vec())
}
Err(e) => Err(io::Error::other(format!("coalesced fetch: {e:?}"))),
}
}
async fn fetch_url_to_bytes(url: &str) -> io::Result<Vec<u8>> {
let window = web_sys::window().ok_or_else(|| io::Error::other("no window"))?;
let resp_value = JsFuture::from(window.fetch_with_str(url))
.await
.map_err(|e| io::Error::other(format!("fetch: {e:?}")))?;
let resp: web_sys::Response = resp_value
.dyn_into()
.map_err(|_| io::Error::other("response not a Response"))?;
if !resp.ok() {
return Err(io::Error::other(format!(
"HTTP {} for {url}",
resp.status()
)));
}
let buf_promise = resp
.array_buffer()
.map_err(|e| io::Error::other(format!("array_buffer: {e:?}")))?;
let buf = JsFuture::from(buf_promise)
.await
.map_err(|e| io::Error::other(format!("body: {e:?}")))?;
let array = js_sys::Uint8Array::new(&buf);
Ok(array.to_vec())
}
fn closing_free_canonical_for_ring(ring: u8, angles: &[i8]) -> Option<Vec<i8>> {
match ring {
4 => closing_free_canonical::<ZZ4>(angles),
6 => closing_free_canonical::<ZZ6>(angles),
8 => closing_free_canonical::<ZZ8>(angles),
10 => closing_free_canonical::<ZZ10>(angles),
12 => closing_free_canonical::<ZZ12>(angles),
14 => closing_free_canonical::<ZZ14>(angles),
16 => closing_free_canonical::<ZZ16>(angles),
18 => closing_free_canonical::<ZZ18>(angles),
20 => closing_free_canonical::<ZZ20>(angles),
24 => closing_free_canonical::<ZZ24>(angles),
32 => closing_free_canonical::<ZZ32>(angles),
60 => closing_free_canonical::<ZZ60>(angles),
_ => None,
}
}
fn closing_free_canonical<R: IsRing>(angles: &[i8]) -> Option<Vec<i8>> {
if angles.is_empty() {
return None;
}
let mut snake: Snake<R> = Snake::new();
for &a in angles {
if !snake.add(a) {
return None; }
}
if !snake.is_closed() {
return None;
}
let rat = Rat::<R>::from_unchecked(&snake);
let rat = if rat.chirality() >= 0 {
rat
} else {
rat.reversed()
};
let chiral = rat.clone().canonical();
let mirror = rat.reflected().canonical();
let chiral_seq = chiral.seq();
let mirror_seq = mirror.seq();
Some(if chiral_seq <= mirror_seq {
chiral_seq.to_vec()
} else {
mirror_seq.to_vec()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_is_empty_state() {
let r = analyze_data(12, &[], None);
assert!(r.error.is_none());
assert!(r.svg.contains("<svg"), "missing svg placeholder");
assert_eq!(r.state.length, 0);
assert!(!r.state.closed);
assert!(r.state.angles.is_empty());
assert!(r.state.rat.is_none());
assert!(r.preview.is_none());
assert!(r.self_intersect_at.is_none());
}
#[test]
fn unsupported_ring_rejected() {
let r = analyze_data(13, &[1, 2, 3], None);
assert!(r.error.as_deref().unwrap_or("").contains("unsupported"));
}
#[test]
fn equilateral_triangle_zz12_is_closed_rat() {
let r = analyze_data(12, &[4, 4, 4], None);
assert!(r.state.closed, "expected closed");
assert!(r.svg.contains("<polygon"), "closed -> <polygon>");
let rat = r.state.rat.expect("closed -> rat info present");
assert_eq!(rat.rotational_order, 3, "3-fold symmetry");
assert!(rat.achiral, "equilateral triangle is achiral");
assert_eq!(rat.chirality, 1, "default orientation is CCW (+1)");
}
#[test]
fn newly_wired_rings_close() {
for (ring, turn) in [(6u8, 2i8), (18, 6)] {
let r = analyze_data(ring, &[turn, turn, turn], None);
assert!(r.error.is_none(), "ZZ{ring}: {:?}", r.error);
assert!(r.state.closed, "ZZ{ring} triangle should close");
assert!(r.state.rat.is_some(), "ZZ{ring} -> rat info");
}
let r = analyze_data(14, &[1; 14], None);
assert!(r.error.is_none(), "ZZ14: {:?}", r.error);
assert!(r.state.closed, "ZZ14 regular 14-gon should close");
assert!(r.state.rat.is_some(), "ZZ14 -> rat info");
}
#[test]
fn odd_ring_zz3_triangle_is_native() {
let r = analyze_data(3, &[1, 1, 1], None);
assert!(r.error.is_none(), "ZZ3: {:?}", r.error);
assert!(r.state.closed, "ZZ3 triangle should close");
assert!(r.svg.contains("<polygon"), "closed -> <polygon>");
assert_eq!(r.state.angles, vec![1, 1, 1], "angles in ZZ3 units");
assert_eq!(r.state.angle_sum, 3, "one full ZZ3 turn");
let rat = r.state.rat.expect("closed -> rat info");
assert_eq!(rat.rotational_order, 3, "3-fold symmetry");
assert!(rat.achiral, "equilateral triangle is achiral");
assert_eq!(rat.chirality, 1, "default orientation CCW");
assert_eq!(
rat.canonical_chiral,
vec![1, 1, 1],
"canonical in ZZ3 units"
);
assert_eq!(rat.canonical_achiral, vec![1, 1, 1]);
}
#[test]
fn odd_ring_zz7_heptagon_is_native() {
let r = analyze_data(7, &[1; 7], None);
assert!(r.error.is_none(), "ZZ7: {:?}", r.error);
assert!(r.state.closed, "ZZ7 heptagon should close");
assert_eq!(r.state.angle_sum, 7, "one full ZZ7 turn");
let rat = r.state.rat.expect("closed -> rat info");
assert_eq!(rat.rotational_order, 7, "7-fold symmetry");
assert_eq!(rat.canonical_chiral, vec![1; 7], "canonical in ZZ7 units");
}
#[test]
fn odd_ring_db_key_is_double_display_canonical() {
let shown = analyze_data(7, &[1; 7], None)
.state
.rat
.expect("closed")
.canonical_achiral;
assert_eq!(shown, vec![1; 7]);
let scaled: Vec<i8> = [1i8; 7].iter().map(|&a| a * 2).collect();
let key = closing_free_canonical_for_ring(14, &scaled).expect("closed parent canonical");
assert_eq!(key, vec![2; 7], "DB key stays in even parent units");
let doubled: Vec<i8> = shown.iter().map(|&a| a * 2).collect();
assert_eq!(key, doubled);
}
#[test]
fn open_prefix_renders_polyline() {
let r = analyze_data(12, &[1, 2, 1], None);
assert!(r.svg.contains("<polyline"), "open -> <polyline>");
assert!(!r.state.closed);
assert!(r.state.rat.is_none());
}
#[test]
fn closed_flag_tracks_committed_state() {
let open = analyze_data(12, &[4, 4], Some(0));
assert!(!open.state.closed, "open committed should report false");
let closed = analyze_data(12, &[4, 4, 4], Some(0));
assert!(closed.state.closed, "closed committed should report true");
}
#[test]
fn closed_rat_has_canonical_sequences() {
let r = analyze_data(12, &[4, 4, 4], None);
let rat = r.state.rat.expect("closed");
assert!(rat.achiral);
assert_eq!(rat.canonical_chiral, rat.canonical_achiral);
assert_eq!(rat.canonical_chiral, vec![4, 4, 4]);
}
#[test]
fn cw_input_shows_ccw_canonical() {
let ccw = analyze_data(4, &[1, 1, 1, 1], None)
.state
.rat
.expect("closed");
let cw = analyze_data(4, &[-1, -1, -1, -1], None)
.state
.rat
.expect("closed");
assert_eq!(ccw.chirality, 1, "1,1,1,1 is CCW");
assert_eq!(
cw.chirality, -1,
"-1,-1,-1,-1 is CW (input orientation reported)"
);
assert_eq!(cw.canonical_chiral, vec![1, 1, 1, 1]);
assert_eq!(cw.canonical_achiral, vec![1, 1, 1, 1]);
assert_eq!(ccw.canonical_chiral, cw.canonical_chiral);
assert_eq!(ccw.canonical_achiral, cw.canonical_achiral);
}
#[test]
fn spectre_is_chiral() {
let r = analyze_data(12, &[3, 2, 0, 2, -3, 2, 3, 2, -3, 2, 3, -2, 3, -2], None);
assert!(r.state.closed, "spectre should close");
let rat = r.state.rat.expect("closed");
assert!(!rat.achiral, "spectre is chiral");
assert_ne!(
rat.canonical_chiral, rat.canonical_achiral,
"chiral and achiral canonicals must differ"
);
}
#[test]
fn chiral_rat_canonicals_diverge() {
let r = analyze_data(12, &[3, 4, 5], None);
if !r.state.closed {
return; }
let rat = r.state.rat.expect("closed");
assert!(!rat.achiral, "scalene triangle is chiral");
assert_ne!(rat.canonical_chiral, rat.canonical_achiral);
}
#[test]
fn preview_summary_round_trips() {
let no_preview = analyze_data(12, &[4, 4], None);
assert!(no_preview.preview.is_none());
assert!(!no_preview.svg.contains("stroke-dasharray"));
let with_preview = analyze_data(12, &[4, 4], Some(4));
let p = with_preview.preview.expect("preview should be present");
assert_eq!(p.angle, 4);
assert!(p.accepted, "angle 4 closes the triangle cleanly");
assert!(!with_preview.svg.contains("stroke-dasharray"));
assert!(with_preview.svg.contains("<text"));
assert!(with_preview.svg.contains(">4<"));
assert_eq!(with_preview.state.length, 2);
}
#[test]
fn odd_ring_preview_label_is_halved() {
let with_preview = analyze_data(7, &[1, 1], Some(2));
let p = with_preview.preview.expect("preview present");
assert_eq!(p.angle, 2, "preview.angle in ZZ7 units");
assert!(with_preview.svg.contains("<text"), "preview is labelled");
assert!(
with_preview.svg.contains(">2<"),
"label shows odd-ring angle 2"
);
assert!(
!with_preview.svg.contains(">4<"),
"must not show the even-parent angle 4"
);
}
#[test]
fn collinear_rejected_preview_still_marks_collision() {
let r = analyze_data(9, &[0, 0, 2, 2, 1, 3, -4, -2, 4, 1], Some(4));
let p = r.preview.expect("preview present");
assert!(!p.accepted, "preview should be rejected (self-intersects)");
assert!(
r.svg.contains("#dc0000"),
"collision bullseye must be drawn even for a collinear overlap"
);
}
#[test]
fn rejected_preview_is_flagged() {
let r = analyze_data(12, &[4, 4], Some(0));
if let Some(p) = r.preview {
assert_eq!(p.angle, 0);
assert!(!r.svg.contains("stroke-dasharray"));
let _ = p.accepted;
}
}
#[test]
fn closing_preview_does_not_displace_bbox() {
let baseline = analyze_data(12, &[0, 3, 3], None);
let with_preview = analyze_data(12, &[0, 3, 3], Some(3));
assert!(
with_preview.preview.as_ref().is_some_and(|p| p.accepted),
"preview 3 must be accepted (closes the square)"
);
fn first_polyline_points(svg: &str) -> &str {
svg.split("points=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
}
let pts_baseline = first_polyline_points(&baseline.svg);
let pts_preview = first_polyline_points(&with_preview.svg);
assert_eq!(
pts_baseline, pts_preview,
"closing preview must not shift the committed polyline"
);
for pair in pts_preview.split_whitespace() {
let mut it = pair.split(',');
let x: f64 = it.next().unwrap().parse().unwrap();
let y: f64 = it.next().unwrap().parse().unwrap();
assert!(
(8.0..=392.0).contains(&x) && (8.0..=392.0).contains(&y),
"committed polyline vertex ({x}, {y}) lies outside the 400x400 viewport",
);
}
}
#[test]
fn db_lookup_recovers_every_walk_form() {
use crate::rat_enum::canonical::make_ops;
use crate::rat_enum::dfs::rat_enum_with;
use crate::rat_enum::prune::{
Prunes,
closure_table::{ClosureTablePrune, collect_closure_keys},
modular::ModularPrune,
units::unit_vectors_for_ring,
};
use crate::stringmatch::RatDafsa;
use std::sync::Arc;
let max_steps = 10usize;
let (units, phi) = unit_vectors_for_ring(12);
let keys = collect_closure_keys::<ZZ12>(4);
let prunes = Prunes {
modular_prune: Some(Arc::new(ModularPrune::build(&units, phi, max_steps, None))),
closure_table_prune: Some(Arc::new(ClosureTablePrune { max_l: 4, keys })),
shadow_prune: None,
};
let (rats, _) = rat_enum_with::<ZZ12>(
max_steps,
1,
make_ops(true),
"db_lookup_test",
"",
false,
&prunes,
);
assert!(!rats.is_empty(), "ZZ12 free n<=8 returned no rats");
let dafsa = RatDafsa::from_rats(rats.iter().map(|v| v.as_slice()));
type Mismatch = (Vec<i8>, Vec<i8>, Option<Vec<i8>>);
let mut mismatches: Vec<Mismatch> = Vec::new();
for c in &rats {
let canonical_id = dafsa.index_of(c.as_slice()).expect("canonical in DB");
let n = c.len();
let base_forms: [Vec<i8>; 4] = [
c.clone(),
c.iter().rev().map(|&a| -a).collect::<Vec<_>>(),
c.iter().rev().copied().collect::<Vec<_>>(),
c.iter().map(|&a| -a).collect::<Vec<_>>(),
];
for base in &base_forms {
for k in 0..n.max(1) {
let mut walk: Vec<i8> = base.clone();
walk.rotate_left(k);
let canon = closing_free_canonical_for_ring(12, &walk);
match canon {
Some(recovered) => {
let recovered_id = dafsa.index_of(recovered.as_slice());
if recovered_id != Some(canonical_id) {
mismatches.push((c.clone(), walk.clone(), Some(recovered)));
}
}
None => {
mismatches.push((c.clone(), walk.clone(), None));
}
}
}
}
}
if !mismatches.is_empty() {
let mut report = format!(
"{} walk forms failed to recover their DB rat:\n",
mismatches.len()
);
for (c, walk, got) in mismatches.iter().take(5) {
report.push_str(&format!(
" canonical {c:?} -> walk {walk:?} -> recovered {got:?}\n"
));
}
panic!("{report}");
}
}
}