use std::collections::BTreeSet;
use crate::clipping::{SegId, Segment};
use crate::svg::path_command;
use super::AliveZone;
#[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
impl AliveZone {
#[must_use]
pub fn to_svg(&self) -> String {
let mut visited: BTreeSet<SegId> = BTreeSet::new();
let mut commands: Vec<String> = Vec::new();
let bound = self.graph.segment_count();
for shape in self.graph.shape_ids() {
for seed in self.graph.segments(shape) {
if visited.contains(&seed) {
continue;
}
if !self.graph.is_active(seed) {
visited.insert(seed);
continue;
}
let seed_successor = self.graph.segment(seed).map(Segment::next);
let mut current = Some(seed);
let mut needs_move = true;
let mut steps = 0_usize;
while let Some(id) = current {
debug_assert!(
steps < bound,
"the outline walk from {seed:?} never closed within {bound} segments — the structure is malformed"
);
if steps >= bound {
break;
}
steps += 1;
if let Some(span) = self.graph.segment_span(id) {
commands.push(path_command(span, needs_move));
}
needs_move = false;
visited.insert(id);
let Some(arrival) = self
.graph
.segment(id)
.map(Segment::next)
.and_then(|next| self.graph.segment(next))
.map(Segment::point)
else {
break;
};
let sharing = self.graph.segments_at(arrival);
if sharing.contains(&seed) {
commands.push("Z".to_owned());
break;
}
current = sharing.iter().copied().find(|candidate| {
self.graph.is_active(*candidate)
&& !visited.contains(candidate)
&& Some(*candidate) != seed_successor
});
}
}
}
if commands.is_empty() {
return String::new();
}
format!("<path d=\"{}\" />", commands.join(" "))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
use crate::{AliveZone, Point, STONE_DIAMETER, StoneId};
const BOARD: f64 = 20.0;
fn p(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn carve_all(centers: &[Point]) -> AliveZone {
let mut zone = AliveZone::new(BOARD);
for (index, center) in centers.iter().enumerate() {
zone.remove_circle(StoneId::new(index as u32), *center)
.unwrap();
}
assert_eq!(zone.validate(), Ok(()));
zone
}
fn path_data(zone: &AliveZone) -> String {
let svg = zone.to_svg();
let start = svg.find("d=\"").expect("a path with data") + 3;
let rest = svg.get(start..).expect("the data starts inside the string");
let end = rest.find('"').expect("the data is quoted");
rest.get(..end)
.expect("the data ends inside the string")
.to_owned()
}
fn commands(zone: &AliveZone) -> Vec<String> {
let data = path_data(zone);
let mut commands: Vec<String> = Vec::new();
for token in data.split_whitespace() {
if token
.chars()
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
{
commands.push(token.to_owned());
} else if let Some(last) = commands.last_mut() {
last.push(' ');
last.push_str(token);
}
}
commands
}
fn drawn(zone: &AliveZone) -> Vec<String> {
let mut drawn: Vec<String> = commands(zone)
.into_iter()
.filter(|command| command.starts_with('A') || command.starts_with('L'))
.collect();
drawn.sort();
drawn
}
fn drawn_points(zone: &AliveZone) -> Vec<Point> {
commands(zone)
.iter()
.filter_map(|command| {
let pair = command.rsplit(' ').next()?;
let (x, y) = pair.split_once(',')?;
Some(Point::new(x.parse().ok()?, y.parse().ok()?))
})
.collect()
}
#[test]
fn every_point_drawn_is_on_the_boundary_of_the_playable_area() {
let zone = carve_all(&[
p(2.0, 10.0),
p(3.5, 11.0),
p(6.0, 6.0),
p(8.5, 7.0),
p(7.0, 9.0),
p(10.0, 10.0),
p(11.0, 11.5),
]);
let points = drawn_points(&zone);
assert!(points.len() > 10, "not much of a test: {}", points.len());
for point in points {
assert!(
zone.is_placeable(point),
"({}, {}) is drawn but is not on the boundary",
point.x,
point.y
);
}
}
#[test]
fn an_empty_board_draws_its_inset_square() {
let zone = AliveZone::new(BOARD);
assert_eq!(
path_data(&zone),
"M 1.00000000,1.00000000 L 1.00000000,19.00000000 \
L 19.00000000,19.00000000 L 19.00000000,1.00000000 L 1.00000000,1.00000000 Z"
);
}
#[test]
fn a_dead_zone_in_open_space_draws_its_whole_rim() {
let zone = carve_all(&[p(10.0, 10.0)]);
let commands = commands(&zone);
assert_eq!(commands.iter().filter(|c| c.starts_with('L')).count(), 4);
assert_eq!(commands.iter().filter(|c| *c == "Z").count(), 1);
}
#[test]
fn a_dead_zone_against_an_edge_hands_the_outline_over_and_back() {
let zone = carve_all(&[p(2.0, 10.0)]);
let commands = commands(&zone);
assert!(
commands.iter().any(|c| c.starts_with('A')),
"the rim is part of the boundary: {commands:?}"
);
assert!(commands.iter().any(|c| c.starts_with('L')));
assert_eq!(
commands.iter().filter(|c| *c == "Z").count(),
1,
"one loop, still: {commands:?}"
);
assert!(commands.first().is_some_and(|c| c.starts_with('M')));
}
#[test]
fn a_string_of_dead_zones_still_closes_every_loop() {
let centers: Vec<Point> = (0..6).map(|i| p(4.0 + f64::from(i) * 1.5, 10.0)).collect();
let zone = carve_all(¢ers);
let commands = commands(&zone);
let moves = commands.iter().filter(|c| c.starts_with('M')).count();
let closes = commands.iter().filter(|c| *c == "Z").count();
assert!(moves > 0);
assert_eq!(moves, closes, "every subpath closes: {commands:?}");
}
#[test]
fn an_enclosed_dead_zone_leaves_no_arc_behind() {
let spacing = STONE_DIAMETER * 1.5;
let mut centers: Vec<Point> = (0..6)
.map(|step| {
let angle = f64::from(step) * core::f64::consts::TAU / 6.0;
p(10.0 + spacing * angle.cos(), 10.0 + spacing * angle.sin())
})
.collect();
centers.push(p(10.0, 10.0));
let zone = carve_all(¢ers);
assert!(!zone.contains(p(10.0, 10.0)));
assert!(!path_data(&zone).is_empty());
}
#[test]
fn a_zone_with_nothing_visible_draws_nothing() {
let mut zone = AliveZone::new(4.0);
zone.remove_circle(StoneId::new(0), p(2.0, 2.0)).unwrap();
assert_eq!(zone.to_svg(), "");
}
#[test]
fn the_path_is_the_same_however_the_zone_was_reached() {
let centers = [p(6.0, 6.0), p(8.5, 7.0), p(7.0, 9.0), p(2.0, 10.0)];
assert_eq!(
path_data(&carve_all(¢ers)),
path_data(&carve_all(¢ers))
);
}
#[test]
fn a_round_trip_redraws_the_same_geometry_possibly_rotated() {
let centers = [p(6.0, 6.0), p(8.5, 7.0), p(7.0, 9.0), p(2.0, 10.0)];
let mut zone = carve_all(¢ers);
let before = drawn(&zone);
zone.reclaim_circle(StoneId::new(1)).unwrap();
zone.remove_circle(StoneId::new(1), centers[1]).unwrap();
assert_eq!(zone.validate(), Ok(()));
assert_eq!(drawn(&zone), before);
}
#[test]
fn drawing_the_outline_changes_nothing() {
let zone = carve_all(&[p(6.0, 6.0), p(8.5, 7.0), p(2.0, 10.0)]);
let before = zone.fingerprint();
let _ = zone.to_svg();
assert_eq!(zone.fingerprint(), before);
assert_eq!(zone.validate(), Ok(()));
}
}