From 3feacb39e6e23330be10a3bb3422d2488e55af12 Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 15:15:08 -0400
Subject: [PATCH 01/24] cantellation
---
src/polyhedron/conway.rs | 5 +++
src/polyhedron/mod.rs | 6 +--
src/polyhedron/render.rs | 7 +++
src/polyhedron/shape/conway.rs | 65 +++++++++++++++++++++++++++-
src/polyhedron/shape/distance/mod.rs | 7 +++
src/polyhedron/shape/test.rs | 29 +++++++++++++
src/polyhedron/test.rs | 36 +++++++++++++++
7 files changed, 150 insertions(+), 5 deletions(-)
diff --git a/src/polyhedron/conway.rs b/src/polyhedron/conway.rs
index c703a52d..101bd636 100644
--- a/src/polyhedron/conway.rs
+++ b/src/polyhedron/conway.rs
@@ -49,4 +49,9 @@ impl Polyhedron {
pub fn chamfer(&mut self) {
self.shape.chamfer();
}
+
+ pub fn expand(&mut self) {
+ let parents = self.shape.expand();
+ self.render.rebuild_from_parents(&parents);
+ }
}
diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs
index ae49e063..649ade4f 100644
--- a/src/polyhedron/mod.rs
+++ b/src/polyhedron/mod.rs
@@ -165,10 +165,8 @@ impl Polyhedron {
vec![Name('t')]
}
Expand => {
- self.ambo_contract();
- let edges = self.ambo();
- // self.shape.expand(false);
- vec![Contraction(edges), Name('e')]
+ self.expand();
+ vec![Name('e')]
}
Snub => {
// self.graph.expand(true);
diff --git a/src/polyhedron/render.rs b/src/polyhedron/render.rs
index 99baa442..fea9ea01 100644
--- a/src/polyhedron/render.rs
+++ b/src/polyhedron/render.rs
@@ -63,6 +63,13 @@ impl Render {
self.speeds.extend(vec![Vec3::zero(); n]);
}
+ /// Re-seeds positions after a re-indexing rebuild: new vertex `k` starts at its parent's position.
+ /// Speeds reset to zero so the fresh vertices relax outward from the original corners.
+ pub fn rebuild_from_parents(&mut self, parents: &[VertexId]) {
+ self.positions = parents.iter().map(|&p| self.positions[p]).collect();
+ self.speeds = vec![Vec3::zero(); parents.len()];
+ }
+
pub fn spring_length(&self, [v, u]: [VertexId; 2]) -> f32 {
(self.positions[v] - self.positions[u]).mag()
}
diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs
index ab5afbc3..ab7674b0 100644
--- a/src/polyhedron/shape/conway.rs
+++ b/src/polyhedron/shape/conway.rs
@@ -1,5 +1,6 @@
-use super::{Cycle, Cycles, Shape};
+use super::{Cycle, Cycles, Distance, Shape};
use crate::polyhedron::VertexId;
+use std::collections::HashMap;
impl Shape {
pub fn split_vertex(&mut self, v: VertexId) -> Vec<[usize; 2]> {
@@ -50,6 +51,68 @@ impl Shape {
edges
}
+ /// `e` expand (cantellation): one new vertex per original vertex-face corner.
+ /// Returns each new vertex's originating vertex, so render can re-seed positions.
+ pub fn expand(&mut self) -> Vec {
+ let cycles: Vec> = self
+ .cycles
+ .iter()
+ .map(|c| c.iter().copied().collect())
+ .collect();
+
+ // Index every (face, corner) incidence; `c[f][i]` is the new vertex there.
+ let mut c: Vec> = Vec::with_capacity(cycles.len());
+ let mut parents: Vec = Vec::new();
+ for cycle in &cycles {
+ let row = cycle
+ .iter()
+ .map(|&v| {
+ parents.push(v);
+ parents.len() - 1
+ })
+ .collect();
+ c.push(row);
+ }
+
+ // Which two faces each original edge borders.
+ let mut edge_faces: HashMap<[VertexId; 2], Vec> = HashMap::new();
+ for (f, cycle) in cycles.iter().enumerate() {
+ let n = cycle.len();
+ for k in 0..n {
+ let (a, b) = (cycle[k], cycle[(k + 1) % n]);
+ let edge = if a < b { [a, b] } else { [b, a] };
+ edge_faces.entry(edge).or_default().push(f);
+ }
+ }
+
+ let mut distance = Distance::new(parents.len());
+ // Face-figure edges: the original n-gon, using this face's corner copies.
+ for (f, cycle) in cycles.iter().enumerate() {
+ let n = cycle.len();
+ for k in 0..n {
+ distance.connect([c[f][k], c[f][(k + 1) % n]]);
+ }
+ }
+ // Vertex-figure rungs: link the two faces' copies of each endpoint.
+ // The edge quads emerge for free as chordless 4-cycles of ff-edges + rungs.
+ for (edge, faces) in &edge_faces {
+ if faces.len() != 2 {
+ continue;
+ }
+ let [f, g] = [faces[0], faces[1]];
+ for &v in edge {
+ let pf = cycles[f].iter().position(|&x| x == v).unwrap();
+ let pg = cycles[g].iter().position(|&x| x == v).unwrap();
+ distance.connect([c[f][pf], c[g][pg]]);
+ }
+ }
+
+ distance.inherit_ancestry(&self.distance, &parents);
+ self.distance = distance;
+ self.recompute();
+ parents
+ }
+
pub fn chamfer(&mut self) {
let originals = self.edges().collect::>();
for cycle in self.cycles.iter() {
diff --git a/src/polyhedron/shape/distance/mod.rs b/src/polyhedron/shape/distance/mod.rs
index d57917fc..c9c7bee9 100644
--- a/src/polyhedron/shape/distance/mod.rs
+++ b/src/polyhedron/shape/distance/mod.rs
@@ -93,6 +93,13 @@ impl Distance {
&self.ancestors[v]
}
+ /// Copies each vertex's ancestor set from `source`, one per entry in `parents`.
+ /// Used when a rebuild re-indexes vertices but must carry provenance for face coloring.
+ pub fn inherit_ancestry(&mut self, source: &Distance, parents: &[VertexId]) {
+ self.ancestors = parents.iter().map(|&p| source.ancestors[p].clone()).collect();
+ self.next_tag = source.next_tag;
+ }
+
/// Wipes vertex ancestry back to a fresh singleton tag per current vertex.
/// Left unbounded, repeated merges eventually saturate every vertex's tags to the whole original set, making distinct faces indistinguishable by ancestry alone.
pub fn reset_ancestry(&mut self) {
diff --git a/src/polyhedron/shape/test.rs b/src/polyhedron/shape/test.rs
index 8e7e9392..13c5493d 100644
--- a/src/polyhedron/shape/test.rs
+++ b/src/polyhedron/shape/test.rs
@@ -6,6 +6,35 @@ impl Shape {
}
}
+#[test]
+fn expand_cube() {
+ let mut cube = Shape::prism(4);
+ assert_eq!(cube.order(), 8);
+ assert_eq!(cube.edges().count(), 12);
+ assert_eq!(cube.cycles.len(), 6);
+
+ cube.expand();
+
+ // Rhombicuboctahedron: V=24, E=48, F=26 (6 squares + 8 triangles + 12 squares).
+ assert_eq!(cube.order(), 24, "vertex count");
+ assert_eq!(cube.edges().count(), 48, "edge count");
+ assert_eq!(cube.cycles.len(), 26, "face count");
+ // Euler characteristic.
+ assert_eq!(
+ cube.order() as i64 - cube.edges().count() as i64 + cube.cycles.len() as i64,
+ 2
+ );
+ // Every vertex has degree 4 in a cantellation.
+ for v in cube.vertices() {
+ assert_eq!(cube.degree(v), 4, "vertex {v} degree");
+ }
+ // Face multiset: triangles + quads only, in the expected counts.
+ let tris = cube.cycles.iter().filter(|c| c.len() == 3).count();
+ let quads = cube.cycles.iter().filter(|c| c.len() == 4).count();
+ assert_eq!(tris, 8, "triangle faces");
+ assert_eq!(quads, 18, "quad faces");
+}
+
#[test]
#[ignore]
fn split_vertex_contract() {
diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs
index d6939e30..9ad393de 100644
--- a/src/polyhedron/test.rs
+++ b/src/polyhedron/test.rs
@@ -50,6 +50,12 @@ fn apply_ambo(polyhedron: &mut Polyhedron) {
polyhedron.reconcile_face_colors();
}
+fn apply_expand(polyhedron: &mut Polyhedron) {
+ polyhedron.cache_faces();
+ polyhedron.expand();
+ polyhedron.reconcile_face_colors();
+}
+
/// Every face sharing a `FaceTypeSignature` must share a color.
fn assert_uniform_colors_per_facetype(polyhedron: &Polyhedron) {
let signatures = polyhedron.face_signatures();
@@ -126,6 +132,36 @@ fn ambo_twice_preserves_facetype_colors() {
assert_ne!(vertex_figure_color, square_color);
}
+#[test]
+fn expand_preserves_facetype_colors() {
+ // cube ("C"); every square borders four squares.
+ let mut polyhedron = Polyhedron::preset(&Prism(4));
+ let square = FaceTypeSignature {
+ side_count: 4,
+ neighbor_sides: vec![4, 4, 4, 4],
+ };
+ let square_color = color_for_signature(&polyhedron, &square);
+
+ // rhombicuboctahedron ("eC")
+ apply_expand(&mut polyhedron);
+ assert_uniform_colors_per_facetype(&polyhedron);
+
+ // Original faces persist as squares bordered by squares; must keep their color via ancestry.
+ assert_eq!(color_for_signature(&polyhedron, &square), square_color);
+
+ // Genuinely new facetypes must be distinct from the persisting square.
+ let vertex_figure = FaceTypeSignature {
+ side_count: 3,
+ neighbor_sides: vec![4, 4, 4],
+ };
+ let edge_quad = FaceTypeSignature {
+ side_count: 4,
+ neighbor_sides: vec![3, 3, 4, 4],
+ };
+ assert_ne!(color_for_signature(&polyhedron, &vertex_figure), square_color);
+ assert_ne!(color_for_signature(&polyhedron, &edge_quad), square_color);
+}
+
#[test]
fn ambo_octahedron_gives_distinct_facetype_colors() {
// octahedron ("O")
From 1432afa4f2105578a098dfbaffd312ba4da5baa6 Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 15:18:44 -0400
Subject: [PATCH 02/24] rewrite truncation
---
src/polyhedron/conway.rs | 10 ++++++-
src/polyhedron/shape/conway.rs | 40 +++++++++++++++++++++++++
src/polyhedron/shape/test.rs | 28 +++++++++++++++++
src/polyhedron/test.rs | 55 ++++++++++++++++++++++++++++++----
4 files changed, 126 insertions(+), 7 deletions(-)
diff --git a/src/polyhedron/conway.rs b/src/polyhedron/conway.rs
index 101bd636..2a79e13e 100644
--- a/src/polyhedron/conway.rs
+++ b/src/polyhedron/conway.rs
@@ -9,9 +9,17 @@ impl Polyhedron {
}
pub fn truncate(&mut self, d: usize) -> Vec<[VertexId; 2]> {
+ // Full truncation is built in one pass and recomputes once.
+ if d == 0 {
+ let (new_edges, parents) = self.shape.truncate();
+ self.render.rebuild_from_parents(&parents);
+ return new_edges;
+ }
+
+ // Selective truncation still uses the slow per-vertex path; perf is a follow-up.
let mut new_edges = Vec::default();
for v in self.shape.vertices().rev() {
- if d == 0 || self.shape.degree(v) == d {
+ if self.shape.degree(v) == d {
new_edges.extend(self.split_vertex(v));
self.shape.recompute();
}
diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs
index ab7674b0..df625f3e 100644
--- a/src/polyhedron/shape/conway.rs
+++ b/src/polyhedron/shape/conway.rs
@@ -10,6 +10,46 @@ impl Shape {
edges
}
+ /// `t` full truncation: one new vertex per (vertex, incident-edge) corner.
+ /// Returns the vertex-figure edges (so `ambo` contracts the rest) and each
+ /// new vertex's originating vertex (so render can re-seed positions).
+ pub fn truncate(&mut self) -> (Vec<[VertexId; 2]>, Vec) {
+ // Index every (vertex, neighbor) corner; `corner[(v, u)]` is the new vertex there.
+ let mut corner: HashMap<(VertexId, VertexId), VertexId> = HashMap::new();
+ let mut parents: Vec = Vec::new();
+ let mut vertex_order: Vec> = Vec::with_capacity(self.order());
+ for v in self.vertices() {
+ let sc = self.cycles.sorted_connections(v);
+ for &u in &sc {
+ corner.insert((v, u), parents.len());
+ parents.push(v);
+ }
+ vertex_order.push(sc);
+ }
+
+ let mut distance = Distance::new(parents.len());
+ // Vertex-figure d-gon: link a vertex's corners in cyclic neighbor order.
+ // The truncated faces (2n-gons) emerge for free from these plus the original edges.
+ let mut new_edges = Vec::new();
+ for (v, sc) in vertex_order.iter().enumerate() {
+ let d = sc.len();
+ for i in 0..d {
+ let edge = [corner[&(v, sc[i])], corner[&(v, sc[(i + 1) % d])]];
+ distance.connect(edge);
+ new_edges.push(edge);
+ }
+ }
+ // Original edges: each keeps its two endpoint corners joined.
+ for [v, u] in self.edges() {
+ distance.connect([corner[&(v, u)], corner[&(u, v)]]);
+ }
+
+ distance.inherit_ancestry(&self.distance, &parents);
+ self.distance = distance;
+ self.recompute();
+ (new_edges, parents)
+ }
+
pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) {
self.distance.contract_edges(edges);
// Delete a
diff --git a/src/polyhedron/shape/test.rs b/src/polyhedron/shape/test.rs
index 13c5493d..9062777f 100644
--- a/src/polyhedron/shape/test.rs
+++ b/src/polyhedron/shape/test.rs
@@ -35,6 +35,34 @@ fn expand_cube() {
assert_eq!(quads, 18, "quad faces");
}
+#[test]
+fn truncate_cube() {
+ let mut cube = Shape::prism(4);
+ assert_eq!(cube.order(), 8);
+ assert_eq!(cube.edges().count(), 12);
+ assert_eq!(cube.cycles.len(), 6);
+
+ cube.truncate();
+
+ // Truncated cube: V=24, E=36, F=14 (8 triangles + 6 octagons).
+ assert_eq!(cube.order(), 24, "vertex count");
+ assert_eq!(cube.edges().count(), 36, "edge count");
+ assert_eq!(cube.cycles.len(), 14, "face count");
+ // Euler characteristic.
+ assert_eq!(
+ cube.order() as i64 - cube.edges().count() as i64 + cube.cycles.len() as i64,
+ 2
+ );
+ // Every vertex has degree 3 in a truncation.
+ for v in cube.vertices() {
+ assert_eq!(cube.degree(v), 3, "vertex {v} degree");
+ }
+ let tris = cube.cycles.iter().filter(|c| c.len() == 3).count();
+ let octs = cube.cycles.iter().filter(|c| c.len() == 8).count();
+ assert_eq!(tris, 8, "triangle faces");
+ assert_eq!(octs, 6, "octagon faces");
+}
+
#[test]
#[ignore]
fn split_vertex_contract() {
diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs
index 9ad393de..199ee7ef 100644
--- a/src/polyhedron/test.rs
+++ b/src/polyhedron/test.rs
@@ -1,6 +1,5 @@
use super::*;
-use crate::render::message::PresetMessage::{self, *};
-use std::fs::create_dir_all;
+use crate::render::message::PresetMessage::*;
//
impl Polyhedron {}
@@ -33,17 +32,25 @@ fn truncate_contract() {
}
#[test]
-#[ignore]
fn ambo() {
- use PresetMessage::*;
- let prefix = "tests/ambo/";
- create_dir_all(prefix).unwrap();
+ // Ambo tetrahedron == octahedron; exercises the one-shot truncate + contraction.
let mut polyhedron = Polyhedron::preset(&Pyramid(3));
polyhedron.ambo_contract();
let octahedron = Polyhedron::preset(&Octahedron);
assert_eq!(polyhedron.shape, octahedron.shape);
}
+#[test]
+fn ambo_cube_gives_cuboctahedron() {
+ // Ambo cube: V=12, E=24, F=14 (8 triangles + 6 squares).
+ let mut polyhedron = Polyhedron::preset(&Prism(4));
+ polyhedron.ambo_contract();
+ assert_eq!(polyhedron.shape.order(), 12, "vertex count");
+ assert_eq!(polyhedron.shape.edges().count(), 24, "edge count");
+ assert_eq!(polyhedron.shape.cycles.len(), 14, "face count");
+ assert_eq!(polyhedron.render.positions.len(), 12, "render stays in sync");
+}
+
fn apply_ambo(polyhedron: &mut Polyhedron) {
polyhedron.cache_faces();
polyhedron.ambo_contract();
@@ -56,6 +63,12 @@ fn apply_expand(polyhedron: &mut Polyhedron) {
polyhedron.reconcile_face_colors();
}
+fn apply_truncate(polyhedron: &mut Polyhedron) {
+ polyhedron.cache_faces();
+ polyhedron.truncate(0);
+ polyhedron.reconcile_face_colors();
+}
+
/// Every face sharing a `FaceTypeSignature` must share a color.
fn assert_uniform_colors_per_facetype(polyhedron: &Polyhedron) {
let signatures = polyhedron.face_signatures();
@@ -162,6 +175,36 @@ fn expand_preserves_facetype_colors() {
assert_ne!(color_for_signature(&polyhedron, &edge_quad), square_color);
}
+#[test]
+fn truncate_preserves_facetype_colors() {
+ // cube ("C"); every square borders four squares.
+ let mut polyhedron = Polyhedron::preset(&Prism(4));
+ let square = FaceTypeSignature {
+ side_count: 4,
+ neighbor_sides: vec![4, 4, 4, 4],
+ };
+ let square_color = color_for_signature(&polyhedron, &square);
+
+ // truncated cube ("tC")
+ apply_truncate(&mut polyhedron);
+ assert_uniform_colors_per_facetype(&polyhedron);
+
+ // Each original square becomes an octagon bordered by 4 triangles + 4 octagons;
+ // it must keep the square's color via ancestry.
+ let octagon = FaceTypeSignature {
+ side_count: 8,
+ neighbor_sides: vec![3, 3, 3, 3, 8, 8, 8, 8],
+ };
+ assert_eq!(color_for_signature(&polyhedron, &octagon), square_color);
+
+ // Vertex-figure triangles are a genuinely new facetype; must differ.
+ let triangle = FaceTypeSignature {
+ side_count: 3,
+ neighbor_sides: vec![8, 8, 8],
+ };
+ assert_ne!(color_for_signature(&polyhedron, &triangle), square_color);
+}
+
#[test]
fn ambo_octahedron_gives_distinct_facetype_colors() {
// octahedron ("O")
From dd9ef4f9b70daef1303c72d086bc4eb395bae7c4 Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 15:38:36 -0400
Subject: [PATCH 03/24] duals
---
assets/tailwind.css | 4 +++
src/polyhedron/conway.rs | 9 ++++++-
src/polyhedron/mod.rs | 14 +++++-----
src/polyhedron/platonic.rs | 10 +++++++-
src/polyhedron/render.rs | 15 ++++++++---
src/polyhedron/shape/conway.rs | 14 +++++++---
src/polyhedron/shape/distance/conway.rs | 12 ++++++++-
src/polyhedron/shape/distance/test.rs | 19 ++++++++++++++
src/polyhedron/test.rs | 34 +++++++++++++++++++++++++
9 files changed, 114 insertions(+), 17 deletions(-)
diff --git a/assets/tailwind.css b/assets/tailwind.css
index d9be7bef..69c5036a 100644
--- a/assets/tailwind.css
+++ b/assets/tailwind.css
@@ -588,6 +588,10 @@ video {
}
}
+.collapse {
+ visibility: collapse;
+}
+
.static {
position: static;
}
diff --git a/src/polyhedron/conway.rs b/src/polyhedron/conway.rs
index 2a79e13e..c39a7c77 100644
--- a/src/polyhedron/conway.rs
+++ b/src/polyhedron/conway.rs
@@ -59,7 +59,14 @@ impl Polyhedron {
}
pub fn expand(&mut self) {
- let parents = self.shape.expand();
+ let (parents, _) = self.shape.expand();
self.render.rebuild_from_parents(&parents);
}
+
+ /// Expands, then returns the face-figure edges to contract for the dual.
+ pub fn dual(&mut self) -> Vec<[VertexId; 2]> {
+ let (parents, face_edges) = self.shape.expand();
+ self.render.rebuild_from_parents(&parents);
+ face_edges
+ }
}
diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs
index 649ade4f..e5727be0 100644
--- a/src/polyhedron/mod.rs
+++ b/src/polyhedron/mod.rs
@@ -115,13 +115,13 @@ impl Polyhedron {
let new_transactions = match conway {
Dual => {
- // let edges = self.expand(false);
- // vec![
- // Wait(Instant::now() + Duration::from_millis((65.0 * speed) as u64)),
- // Contraction(edges),
- // Name('d'),
- // ]
- todo!()
+ // Expand blooms out, then contracting the face-figures collapses each face to a point.
+ let edges = self.dual();
+ vec![
+ Wait(Instant::now() + Duration::from_millis(500)),
+ Contraction(edges),
+ Name('d'),
+ ]
}
Join => {
// let edges = self.graph.kis(Option::None);
diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs
index 014740e6..b7aba281 100644
--- a/src/polyhedron/platonic.rs
+++ b/src/polyhedron/platonic.rs
@@ -6,7 +6,7 @@ impl Polyhedron {
use PresetMessage::*;
let mut polyhedron = match preset {
Octahedron => Self::octahedron(),
- Dodecahedron => todo!(),
+ Dodecahedron => Self::dodecahedron(),
Icosahedron => Self::icosahedron(),
_ => {
let shape = match preset {
@@ -39,6 +39,14 @@ impl Polyhedron {
polyhedron.ambo_contract();
polyhedron
}
+
+ pub fn dodecahedron() -> Polyhedron {
+ let mut graph = Polyhedron::preset(&AntiPrism(5));
+ graph.dual();
+ graph.truncate(5);
+ graph
+ }
+
pub fn icosahedron() -> Polyhedron {
let mut graph = Polyhedron::preset(&AntiPrism(5));
graph.shape.kis(Some(5));
diff --git a/src/polyhedron/render.rs b/src/polyhedron/render.rs
index fea9ea01..1feaa626 100644
--- a/src/polyhedron/render.rs
+++ b/src/polyhedron/render.rs
@@ -108,15 +108,24 @@ impl Render {
while !edges.is_empty() {
// Pop an edge
let [w, x] = edges.remove(0);
+ // Endpoints already merged (e.g. the last edge of a contracted cycle); keep in lockstep with the graph.
+ if w == x {
+ continue;
+ }
let v = w.max(x);
- let _u = w.min(x);
- // if transformed.contains(&v) && transformed.contains(&u) {}
+ let u = w.min(x);
self.positions.remove(v);
self.speeds.remove(v);
- // transformed.insert(v);
+ // Remap the deleted vertex onto the survivor, then close the index gap.
for [x, w] in &mut edges {
+ if *x == v {
+ *x = u;
+ }
+ if *w == v {
+ *w = u;
+ }
if *x > v {
*x -= 1;
}
diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs
index df625f3e..5e939cca 100644
--- a/src/polyhedron/shape/conway.rs
+++ b/src/polyhedron/shape/conway.rs
@@ -92,8 +92,10 @@ impl Shape {
}
/// `e` expand (cantellation): one new vertex per original vertex-face corner.
- /// Returns each new vertex's originating vertex, so render can re-seed positions.
- pub fn expand(&mut self) -> Vec {
+ /// Returns each new vertex's originating vertex (so render can re-seed positions)
+ /// and the face-figure edges (contracting them collapses each face to a point,
+ /// yielding the dual).
+ pub fn expand(&mut self) -> (Vec, Vec<[VertexId; 2]>) {
let cycles: Vec> = self
.cycles
.iter()
@@ -127,10 +129,14 @@ impl Shape {
let mut distance = Distance::new(parents.len());
// Face-figure edges: the original n-gon, using this face's corner copies.
+ // Contracting these collapses each face to a point, giving the dual.
+ let mut face_edges = Vec::new();
for (f, cycle) in cycles.iter().enumerate() {
let n = cycle.len();
for k in 0..n {
- distance.connect([c[f][k], c[f][(k + 1) % n]]);
+ let edge = [c[f][k], c[f][(k + 1) % n]];
+ distance.connect(edge);
+ face_edges.push(edge);
}
}
// Vertex-figure rungs: link the two faces' copies of each endpoint.
@@ -150,7 +156,7 @@ impl Shape {
distance.inherit_ancestry(&self.distance, &parents);
self.distance = distance;
self.recompute();
- parents
+ (parents, face_edges)
}
pub fn chamfer(&mut self) {
diff --git a/src/polyhedron/shape/distance/conway.rs b/src/polyhedron/shape/distance/conway.rs
index b6c0ce77..315e51b0 100644
--- a/src/polyhedron/shape/distance/conway.rs
+++ b/src/polyhedron/shape/distance/conway.rs
@@ -20,13 +20,23 @@ impl Distance {
while !edges.is_empty() {
// Pop an edge
let [w, x] = edges.remove(0);
+ // Endpoints already merged (e.g. the last edge of a contracted cycle); nothing to do.
+ if w == x {
+ continue;
+ }
let v = w.max(x);
let u = w.min(x);
// Contract [v, u], deleting v
self.contract_edge([v, u]);
- // Decrement the value of every vertex
+ // Remap the deleted vertex onto the survivor, then close the index gap.
for [x, w] in &mut edges {
+ if *x == v {
+ *x = u;
+ }
+ if *w == v {
+ *w = u;
+ }
if *x > v {
*x -= 1;
}
diff --git a/src/polyhedron/shape/distance/test.rs b/src/polyhedron/shape/distance/test.rs
index 93c36864..2c108f7e 100644
--- a/src/polyhedron/shape/distance/test.rs
+++ b/src/polyhedron/shape/distance/test.rs
@@ -90,6 +90,25 @@ fn contract_edge() {
assert_eq!(graph, triangle);
}
+#[test]
+fn contract_cycle_collapses_to_point() {
+ // A square attached to an outside vertex; contracting the whole 4-cycle must
+ // collapse it to one vertex still joined to the outsider, with no self-loop.
+ let mut graph = Distance::new(5);
+ graph.connect([0, 1]);
+ graph.connect([1, 2]);
+ graph.connect([2, 3]);
+ graph.connect([3, 0]);
+ graph.connect([0, 4]); // tail to an outside vertex
+
+ graph.contract_edges(vec![[0, 1], [1, 2], [2, 3], [3, 0]]);
+
+ // Four cycle vertices merged into one; the outsider survives as its neighbor.
+ assert_eq!(graph.order(), 2);
+ assert_eq!(graph.edges().count(), 1);
+ assert_eq!(graph[[0, 0]], 0, "no self-loop on the survivor");
+}
+
#[test]
fn bfs_apsp() {
let mut distance = Distance::new(4);
diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs
index 199ee7ef..ef16b759 100644
--- a/src/polyhedron/test.rs
+++ b/src/polyhedron/test.rs
@@ -205,6 +205,40 @@ fn truncate_preserves_facetype_colors() {
assert_ne!(color_for_signature(&polyhedron, &triangle), square_color);
}
+#[test]
+fn dual_cube_gives_octahedron() {
+ // Dual = expand, then contract the returned face-figure edges.
+ let mut polyhedron = Polyhedron::preset(&Prism(4));
+ let edges = polyhedron.dual();
+ polyhedron.contract(edges);
+
+ // Octahedron: V=6, E=12, F=8, all triangles.
+ assert_eq!(polyhedron.shape.order(), 6, "vertex count");
+ assert_eq!(polyhedron.shape.edges().count(), 12, "edge count");
+ assert_eq!(polyhedron.shape.cycles.len(), 8, "face count");
+ for c in polyhedron.shape.cycles.iter() {
+ assert_eq!(c.len(), 3, "all faces are triangles");
+ }
+ assert_eq!(polyhedron.render.positions.len(), 6, "render stays in sync");
+}
+
+#[test]
+fn dual_twice_is_identity() {
+ // dd == identity: cube -> octahedron -> cube.
+ let mut polyhedron = Polyhedron::preset(&Prism(4));
+ let edges = polyhedron.dual();
+ polyhedron.contract(edges);
+ let edges = polyhedron.dual();
+ polyhedron.contract(edges);
+
+ assert_eq!(polyhedron.shape.order(), 8, "vertex count");
+ assert_eq!(polyhedron.shape.edges().count(), 12, "edge count");
+ assert_eq!(polyhedron.shape.cycles.len(), 6, "face count");
+ for c in polyhedron.shape.cycles.iter() {
+ assert_eq!(c.len(), 4, "all faces are squares");
+ }
+}
+
#[test]
fn ambo_octahedron_gives_distinct_facetype_colors() {
// octahedron ("O")
From 91d29c477b0486481f0e491e4d91534024ff9836 Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 15:39:35 -0400
Subject: [PATCH 04/24] fix dodecahedron
---
src/polyhedron/conway.rs | 7 +++++++
src/polyhedron/platonic.rs | 2 +-
src/polyhedron/test.rs | 16 ++++++++++++++++
3 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/src/polyhedron/conway.rs b/src/polyhedron/conway.rs
index c39a7c77..52afae55 100644
--- a/src/polyhedron/conway.rs
+++ b/src/polyhedron/conway.rs
@@ -64,9 +64,16 @@ impl Polyhedron {
}
/// Expands, then returns the face-figure edges to contract for the dual.
+ /// The animated `Dual` transaction drives the contraction; use `dual_contract`
+ /// when you want the dual applied immediately.
pub fn dual(&mut self) -> Vec<[VertexId; 2]> {
let (parents, face_edges) = self.shape.expand();
self.render.rebuild_from_parents(&parents);
face_edges
}
+
+ pub fn dual_contract(&mut self) {
+ let edges = self.dual();
+ self.contract(edges);
+ }
}
diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs
index b7aba281..f6c05ec0 100644
--- a/src/polyhedron/platonic.rs
+++ b/src/polyhedron/platonic.rs
@@ -42,7 +42,7 @@ impl Polyhedron {
pub fn dodecahedron() -> Polyhedron {
let mut graph = Polyhedron::preset(&AntiPrism(5));
- graph.dual();
+ graph.dual_contract();
graph.truncate(5);
graph
}
diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs
index ef16b759..8d0d246a 100644
--- a/src/polyhedron/test.rs
+++ b/src/polyhedron/test.rs
@@ -205,6 +205,22 @@ fn truncate_preserves_facetype_colors() {
assert_ne!(color_for_signature(&polyhedron, &triangle), square_color);
}
+#[test]
+fn dodecahedron_is_well_formed() {
+ // dual(antiprism 5) then truncate its two degree-5 apexes -> dodecahedron.
+ let polyhedron = Polyhedron::preset(&Dodecahedron);
+ assert_eq!(polyhedron.shape.order(), 20, "vertex count");
+ assert_eq!(polyhedron.shape.edges().count(), 30, "edge count");
+ assert_eq!(polyhedron.shape.cycles.len(), 12, "face count");
+ for c in polyhedron.shape.cycles.iter() {
+ assert_eq!(c.len(), 5, "all faces are pentagons");
+ }
+ for v in polyhedron.shape.vertices() {
+ assert_eq!(polyhedron.shape.degree(v), 3, "vertex {v} degree");
+ }
+ assert_eq!(polyhedron.render.positions.len(), 20, "render stays in sync");
+}
+
#[test]
fn dual_cube_gives_octahedron() {
// Dual = expand, then contract the returned face-figure edges.
From a84e6ba035de4f5505ef11a6e4dee59f83e24edf Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 15:39:55 -0400
Subject: [PATCH 05/24] fix dodecahedron
---
src/polyhedron/test.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs
index 8d0d246a..a39dd20c 100644
--- a/src/polyhedron/test.rs
+++ b/src/polyhedron/test.rs
@@ -8,7 +8,7 @@ use test_case::test_case;
#[test_case(Polyhedron::preset(&Pyramid(3)); "T")]
#[test_case(Polyhedron::preset(&Prism(4)); "C")]
#[test_case(Polyhedron::preset(&Octahedron); "O")]
-// #[test_case(Polyhedron::preset(&Dodecahedron); "D")]
+#[test_case(Polyhedron::preset(&Dodecahedron); "D")]
#[test_case(Polyhedron::preset(&Icosahedron); "I")]
// #[test_case({ let mut g = Polyhedron::preset(&Prism(4)); g.truncate(0); g} ; "tC")]
// #[test_case({ let mut g = Polyhedron::preset(&Octahedron); g.truncate(0); g} ; "tO")]
From a2e4ec4da63dc74200594462cc0163def5228cd0 Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 16:04:59 -0400
Subject: [PATCH 06/24] colors
---
src/polyhedron/face.rs | 86 +++++++++++++++++++++++---------
src/polyhedron/mod.rs | 4 +-
src/polyhedron/test.rs | 109 ++++++++++++++++++++++++++++++++++++++---
3 files changed, 170 insertions(+), 29 deletions(-)
diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs
index de13e5c6..4aa080c3 100644
--- a/src/polyhedron/face.rs
+++ b/src/polyhedron/face.rs
@@ -1,9 +1,11 @@
-use std::collections::HashSet;
+use std::collections::{BTreeSet, HashMap, HashSet};
#[derive(Debug, Default, Clone, PartialEq)]
struct FaceCache {
ancestors: Vec>,
colors: Vec,
+ /// Side count of each snapshotted face, so `reconcile` can prefer same-facetype matches.
+ side_counts: Vec,
}
/// Per-face color bookkeeping, kept separate from `Render` since it tracks facetype identity, not physical simulation state.
@@ -13,8 +15,12 @@ pub struct FaceColoring {
pub colors: Vec,
/// Next unused color slot; monotonically increasing so new facetypes get distinct colors.
next_color_slot: usize,
- /// Dense render index per face, derived from `colors`; kept in sync wherever `colors` is set.
+ /// Palette index per face, derived from `colors`; kept in sync wherever `colors` is set.
pub render_indices: Vec,
+ /// Palette index assigned to each currently-present color slot. A surviving slot keeps its
+ /// entry; a slot that disappears frees its entry for reuse. This keeps a facetype's rendered
+ /// color stable both when the facetype survives and when it is recreated by a later operation.
+ palette_of_slot: HashMap,
/// Pre-mutation snapshot of ancestors/colors, used to reconcile colors across a structural change.
cache: FaceCache,
}
@@ -38,10 +44,11 @@ pub struct FaceTypeOption {
impl FaceColoring {
/// Snapshots the current colors against a fresh ancestor set, as the baseline for the next `reconcile`.
- pub fn snapshot(&mut self, ancestors: Vec>) {
+ pub fn snapshot(&mut self, ancestors: Vec>, side_counts: Vec) {
self.cache = FaceCache {
ancestors,
colors: self.colors.clone(),
+ side_counts,
};
}
@@ -49,7 +56,7 @@ impl FaceColoring {
pub fn bootstrap(&mut self, colors: Vec, next_color_slot: usize) {
self.colors = colors;
self.next_color_slot = next_color_slot;
- self.render_indices = dense_color_indices(&self.colors);
+ self.assign_render_indices();
}
/// Matches faces to a pre-mutation ancestry snapshot by Jaccard similarity.
@@ -60,22 +67,30 @@ impl FaceColoring {
pub fn reconcile(&mut self, ancestors: Vec>, signatures: &[FaceTypeSignature]) {
let old = &self.cache;
- // (new_face, old_face, intersection, union) per candidate pair with any overlap.
- let mut candidates: Vec<(usize, usize, usize, usize)> = Vec::new();
+ // (new_face, old_face, intersection, union, same_side) per candidate pair with any overlap.
+ let mut candidates: Vec<(usize, usize, usize, usize, bool)> = Vec::new();
for (i, a) in ancestors.iter().enumerate() {
for (j, o) in old.ancestors.iter().enumerate() {
let intersection = o.intersection(a).count();
if intersection > 0 {
let union = o.union(a).count();
- candidates.push((i, j, intersection, union));
+ let same_side = old
+ .side_counts
+ .get(j)
+ .is_some_and(|&s| s == signatures[i].side_count);
+ candidates.push((i, j, intersection, union, same_side));
}
}
}
- // Rank by Jaccard similarity (descending), breaking ties by raw overlap count.
- candidates.sort_by(|&(_, _, ia, ua), &(_, _, ib, ub)| {
+ // Prefer a same-facetype ancestor first, then Jaccard similarity, then raw overlap count.
+ // Same-side ranking keeps a surviving face (e.g. a triangle whose ancestry got flooded by
+ // contracted neighbors) matched to its own facetype instead of a larger, better-overlapping one.
+ candidates.sort_by(|&(_, _, ia, ua, sa), &(_, _, ib, ub, sb)| {
let jaccard_a = ia as f64 / ua as f64;
let jaccard_b = ib as f64 / ub as f64;
- jaccard_b.total_cmp(&jaccard_a).then(ib.cmp(&ia))
+ sb.cmp(&sa)
+ .then_with(|| jaccard_b.total_cmp(&jaccard_a))
+ .then(ib.cmp(&ia))
});
let mut matched_color: Vec
-## WARNING
-This software is currently broken. Use the release published on crates.io or 0.1.0 while I fix it, which can be installed using the method below.
-The main branch is not as functional as it once was, but this will be remedied soon post-refactor.
-
-## Installation
-```cargo install polyblade```
+## Running
Note that the `webGPU` demo is available [here](https://polyblade.app). It runs just as smoothly as the native application.
@@ -58,7 +53,8 @@ Rest assured that in due time we will conquer all shapes.
- [x] Truncate
- [ ] Ortho
- [x] Bevel
-- [ ] Expand
+- [x] Expand
+- [x] Dual
- [ ] Snub
- [ ] Join
- [ ] Zip
diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs
index 4aa080c3..79e47edc 100644
--- a/src/polyhedron/face.rs
+++ b/src/polyhedron/face.rs
@@ -18,9 +18,12 @@ pub struct FaceColoring {
/// Palette index per face, derived from `colors`; kept in sync wherever `colors` is set.
pub render_indices: Vec,
/// Palette index assigned to each currently-present color slot. A surviving slot keeps its
- /// entry; a slot that disappears frees its entry for reuse. This keeps a facetype's rendered
- /// color stable both when the facetype survives and when it is recreated by a later operation.
+ /// entry, so a facetype's rendered color never changes while it stays on screen.
palette_of_slot: HashMap,
+ /// Palette indices in allocation-preference order (front = used first), and implicitly the
+ /// palette length. When a facetype disappears its entry moves to the back, so new facetypes
+ /// advance to fresh colors instead of recycling a just-freed one.
+ palette_order: Vec,
/// Pre-mutation snapshot of ancestors/colors, used to reconcile colors across a structural change.
cache: FaceCache,
}
@@ -43,6 +46,21 @@ pub struct FaceTypeOption {
}
impl FaceColoring {
+ /// Tells the coloring how many palette entries exist. Preserves the current preference order
+ /// for still-valid entries and appends any newly-available ones at the end.
+ pub fn set_palette_len(&mut self, len: usize) {
+ if self.palette_order.len() == len {
+ return;
+ }
+ let mut order: Vec = self.palette_order.iter().copied().filter(|&p| p < len).collect();
+ for p in 0..len {
+ if !order.contains(&p) {
+ order.push(p);
+ }
+ }
+ self.palette_order = order;
+ }
+
/// Snapshots the current colors against a fresh ancestor set, as the baseline for the next `reconcile`.
pub fn snapshot(&mut self, ancestors: Vec>, side_counts: Vec) {
self.cache = FaceCache {
@@ -144,30 +162,51 @@ impl FaceColoring {
self.assign_render_indices();
}
- /// Maps each face's color slot to a palette index, keeping present slots on their current
- /// entry and giving each newly-present slot the lowest palette index no present slot holds.
- /// Survivors never move (continuity), and a recreated facetype deterministically reclaims the
- /// same free entry (stability across repeated operations), independent of the ever-growing
- /// `next_color_slot`.
+ /// Maps each face's color slot to a palette index. A slot that is still present keeps its
+ /// entry (a facetype never changes color while on screen). Each palette entry freed by a
+ /// disappearing facetype moves to the back of `palette_order`, so newly-present slots draw the
+ /// freshest colors first and only recycle a freed one once the rest are exhausted.
fn assign_render_indices(&mut self) {
let present: BTreeSet = self.colors.iter().copied().collect();
+ // Send every palette entry freed this round to the back of the preference order.
+ let mut freed: Vec = self
+ .palette_of_slot
+ .iter()
+ .filter(|(slot, _)| !present.contains(slot))
+ .map(|(_, &palette)| palette)
+ .collect();
+ freed.sort_unstable();
+ for palette in freed {
+ self.palette_order.retain(|&p| p != palette);
+ self.palette_order.push(palette);
+ }
+ // Make sure there is always at least one entry per present facetype to hand out.
+ for extra in self.palette_order.len()..present.len() {
+ self.palette_order.push(extra);
+ }
+
let mut new_map: HashMap = HashMap::new();
let mut used: BTreeSet = BTreeSet::new();
- // Keep the palette entry of any slot that is still present.
+ // Survivors keep their palette entry.
for &slot in &present {
if let Some(&palette) = self.palette_of_slot.get(&slot) {
new_map.insert(slot, palette);
used.insert(palette);
}
}
- // Assign each newly-present slot the lowest free palette entry.
+ // New facetypes take the first not-in-use entry in preference order.
for &slot in &present {
- new_map.entry(slot).or_insert_with(|| {
- let palette = (0..).find(|p| !used.contains(p)).unwrap();
+ if let std::collections::hash_map::Entry::Vacant(entry) = new_map.entry(slot) {
+ let palette = self
+ .palette_order
+ .iter()
+ .copied()
+ .find(|p| !used.contains(p))
+ .unwrap_or(0);
used.insert(palette);
- palette
- });
+ entry.insert(palette);
+ }
}
self.render_indices = self.colors.iter().map(|slot| new_map[slot]).collect();
diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs
index 84f3f54c..8c03f6f1 100644
--- a/src/polyhedron/test.rs
+++ b/src/polyhedron/test.rs
@@ -288,11 +288,13 @@ fn dual_preserves_triangle_color_continuity() {
}
#[test]
-fn repeated_dual_is_palette_stable() {
- // The tetrahedron is self-dual, so dualing it repeatedly must not drift the palette.
- // Each dual passes through a cuboctahedron whose square facetype is recreated from
- // scratch; its rendered palette entry must be identical every time.
+fn survivor_keeps_color_while_freed_colors_rotate_to_the_back() {
+ // The tetrahedron is self-dual. Its surviving face color must never change, but the
+ // transient facetypes created along the way should advance through the palette: a color
+ // freed by a disappearing facetype goes to the back, so the next new facetype picks a
+ // fresh entry rather than recycling the one just freed.
let mut polyhedron = Polyhedron::preset(&Pyramid(3));
+ polyhedron.face_coloring.set_palette_len(6);
let triangle = FaceTypeSignature {
side_count: 3,
neighbor_sides: vec![3, 3, 3],
@@ -301,36 +303,41 @@ fn repeated_dual_is_palette_stable() {
side_count: 4,
neighbor_sides: vec![3, 3, 3, 3],
};
- let start = render_index_for_signature(&polyhedron, &triangle);
+ let tetra_color = render_index_for_signature(&polyhedron, &triangle);
// First dual: capture the intermediate cuboctahedron's square palette entry.
polyhedron.cache_faces();
let edges = polyhedron.dual();
polyhedron.reconcile_face_colors();
- let square_render = render_index_for_signature(&polyhedron, &square);
+ let first_square = render_index_for_signature(&polyhedron, &square);
polyhedron.cache_faces();
polyhedron.contract(edges);
polyhedron.reconcile_face_colors();
assert_eq!(
render_index_for_signature(&polyhedron, &triangle),
- start,
+ tetra_color,
"tetrahedron keeps its color after one dual"
);
- // Second dual: the recreated square must land on the same palette entry.
+ // Second dual: the recreated square advances to a fresh palette entry (the freed one is
+ // now at the back), and the surviving tetrahedron still holds its original color.
polyhedron.cache_faces();
let edges = polyhedron.dual();
polyhedron.reconcile_face_colors();
- assert_eq!(
- render_index_for_signature(&polyhedron, &square),
- square_render,
- "recreated square facetype reuses the same palette entry across duals"
+ let second_square = render_index_for_signature(&polyhedron, &square);
+ assert_ne!(
+ second_square, first_square,
+ "recreated square advances instead of recycling the just-freed color"
+ );
+ assert_ne!(
+ second_square, tetra_color,
+ "recreated square never collides with the surviving facetype's color"
);
polyhedron.contract(edges);
polyhedron.reconcile_face_colors();
assert_eq!(
render_index_for_signature(&polyhedron, &triangle),
- start,
+ tetra_color,
"tetrahedron keeps its color after a second dual"
);
}
diff --git a/src/render/state.rs b/src/render/state.rs
index 420482eb..d0d14f36 100644
--- a/src/render/state.rs
+++ b/src/render/state.rs
@@ -115,6 +115,10 @@ impl AppState {
frame_difference
};
+ self.model
+ .polyhedron
+ .face_coloring
+ .set_palette_len(self.render.picker.palette.colors.len());
self.model.polyhedron.update(self.render.speed, second);
self.render.frame = time;
From 075fcdb8e50da65c8cdc403be8dfd28fb2973d74 Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 18:09:12 -0400
Subject: [PATCH 08/24] cmt
---
src/polyhedron/face.rs | 29 ++++++++++++++++++++---------
src/polyhedron/shape/conway.rs | 8 ++++----
2 files changed, 24 insertions(+), 13 deletions(-)
diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs
index 79e47edc..91fa2139 100644
--- a/src/polyhedron/face.rs
+++ b/src/polyhedron/face.rs
@@ -46,13 +46,18 @@ pub struct FaceTypeOption {
}
impl FaceColoring {
- /// Tells the coloring how many palette entries exist. Preserves the current preference order
- /// for still-valid entries and appends any newly-available ones at the end.
+ /// Tells the coloring how many palette entries exist.
+ /// Preserves the current preference order for still-valid entries and appends any newly-available ones at the end.
pub fn set_palette_len(&mut self, len: usize) {
if self.palette_order.len() == len {
return;
}
- let mut order: Vec = self.palette_order.iter().copied().filter(|&p| p < len).collect();
+ let mut order: Vec = self
+ .palette_order
+ .iter()
+ .copied()
+ .filter(|&p| p < len)
+ .collect();
for p in 0..len {
if !order.contains(&p) {
order.push(p);
@@ -100,6 +105,7 @@ impl FaceColoring {
}
}
}
+
// Prefer a same-facetype ancestor first, then Jaccard similarity, then raw overlap count.
// Same-side ranking keeps a surviving face (e.g. a triangle whose ancestry got flooded by
// contracted neighbors) matched to its own facetype instead of a larger, better-overlapping one.
@@ -138,8 +144,9 @@ impl FaceColoring {
None => votes.push((matched_color[i], 1)),
}
}
- // Prefer the most common real (matched) color; only mint a new slot if no face in
- // this group matched anything. Otherwise a facetype with more new faces than old ones
+ // Prefer the most common real (matched) color;
+ // only mint a new slot if no face in this group matched anything.
+ // Otherwise a facetype with more new faces than old ones
// (e.g. expand's 8 triangles onto 4) could tie against `None` and lose its color.
let winner = votes
.iter()
@@ -162,10 +169,10 @@ impl FaceColoring {
self.assign_render_indices();
}
- /// Maps each face's color slot to a palette index. A slot that is still present keeps its
- /// entry (a facetype never changes color while on screen). Each palette entry freed by a
- /// disappearing facetype moves to the back of `palette_order`, so newly-present slots draw the
- /// freshest colors first and only recycle a freed one once the rest are exhausted.
+ /// Maps each face's color slot to a palette index.
+ /// A slot that is still present keeps its entry (a facetype never changes color while on screen).
+ /// Each palette entry freed by a disappearing facetype moves to the back of `palette_order`,
+ /// so newly-present slots draw the freshest colors first and only recycle a freed one once the rest are exhausted.
fn assign_render_indices(&mut self) {
let present: BTreeSet = self.colors.iter().copied().collect();
@@ -177,10 +184,12 @@ impl FaceColoring {
.map(|(_, &palette)| palette)
.collect();
freed.sort_unstable();
+
for palette in freed {
self.palette_order.retain(|&p| p != palette);
self.palette_order.push(palette);
}
+
// Make sure there is always at least one entry per present facetype to hand out.
for extra in self.palette_order.len()..present.len() {
self.palette_order.push(extra);
@@ -188,6 +197,7 @@ impl FaceColoring {
let mut new_map: HashMap = HashMap::new();
let mut used: BTreeSet = BTreeSet::new();
+
// Survivors keep their palette entry.
for &slot in &present {
if let Some(&palette) = self.palette_of_slot.get(&slot) {
@@ -195,6 +205,7 @@ impl FaceColoring {
used.insert(palette);
}
}
+
// New facetypes take the first not-in-use entry in preference order.
for &slot in &present {
if let std::collections::hash_map::Entry::Vacant(entry) = new_map.entry(slot) {
diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs
index 5e939cca..460cac16 100644
--- a/src/polyhedron/shape/conway.rs
+++ b/src/polyhedron/shape/conway.rs
@@ -91,10 +91,10 @@ impl Shape {
edges
}
- /// `e` expand (cantellation): one new vertex per original vertex-face corner.
- /// Returns each new vertex's originating vertex (so render can re-seed positions)
- /// and the face-figure edges (contracting them collapses each face to a point,
- /// yielding the dual).
+ /// `e` expand / cantellation: one new vertex per original vertex-face corner.
+ /// Returns:
+ /// - each new vertex's originating vertex, so that render can re-seed positions.
+ /// - the face-figure edges which we contract to make a dual
pub fn expand(&mut self) -> (Vec, Vec<[VertexId; 2]>) {
let cycles: Vec> = self
.cycles
From e994a17c594a1ff465d00f353607b244543a36f7 Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 18:25:19 -0400
Subject: [PATCH 09/24] cleaning up & refactoring
---
src/polyhedron/conway.rs | 9 +-
src/polyhedron/face.rs | 108 +++++-------------------
src/polyhedron/mod.rs | 36 +++++++-
src/polyhedron/palette.rs | 57 +++++++++++++
src/polyhedron/platonic.rs | 2 +-
src/polyhedron/render.rs | 32 +------
src/polyhedron/shape/conway.rs | 7 +-
src/polyhedron/shape/distance/conway.rs | 31 +------
src/polyhedron/test.rs | 12 +--
9 files changed, 134 insertions(+), 160 deletions(-)
create mode 100644 src/polyhedron/palette.rs
diff --git a/src/polyhedron/conway.rs b/src/polyhedron/conway.rs
index 52afae55..fba76849 100644
--- a/src/polyhedron/conway.rs
+++ b/src/polyhedron/conway.rs
@@ -64,16 +64,15 @@ impl Polyhedron {
}
/// Expands, then returns the face-figure edges to contract for the dual.
- /// The animated `Dual` transaction drives the contraction; use `dual_contract`
- /// when you want the dual applied immediately.
- pub fn dual(&mut self) -> Vec<[VertexId; 2]> {
+ /// The animated `Dual` transaction drives the contraction; call `dual` to apply it immediately.
+ pub fn begin_dual(&mut self) -> Vec<[VertexId; 2]> {
let (parents, face_edges) = self.shape.expand();
self.render.rebuild_from_parents(&parents);
face_edges
}
- pub fn dual_contract(&mut self) {
- let edges = self.dual();
+ pub fn dual(&mut self) {
+ let edges = self.begin_dual();
self.contract(edges);
}
}
diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs
index 91fa2139..976d7229 100644
--- a/src/polyhedron/face.rs
+++ b/src/polyhedron/face.rs
@@ -1,4 +1,5 @@
-use std::collections::{BTreeSet, HashMap, HashSet};
+use super::palette::PaletteAllocator;
+use std::collections::{BTreeSet, HashSet};
#[derive(Debug, Default, Clone, PartialEq)]
struct FaceCache {
@@ -11,19 +12,14 @@ struct FaceCache {
/// Per-face color bookkeeping, kept separate from `Render` since it tracks facetype identity, not physical simulation state.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FaceColoring {
- /// Palette-relative color slot per current face, parallel to `shape.cycles`.
+ /// Color slot per current face, parallel to `shape.cycles`.
pub colors: Vec,
/// Next unused color slot; monotonically increasing so new facetypes get distinct colors.
next_color_slot: usize,
- /// Palette index per face, derived from `colors`; kept in sync wherever `colors` is set.
+ /// Cached palette index per face; derived from `colors` via `allocator`, read every frame by the renderer.
pub render_indices: Vec,
- /// Palette index assigned to each currently-present color slot. A surviving slot keeps its
- /// entry, so a facetype's rendered color never changes while it stays on screen.
- palette_of_slot: HashMap,
- /// Palette indices in allocation-preference order (front = used first), and implicitly the
- /// palette length. When a facetype disappears its entry moves to the back, so new facetypes
- /// advance to fresh colors instead of recycling a just-freed one.
- palette_order: Vec,
+ /// Maps color slots to stable, recyclable palette indices.
+ allocator: PaletteAllocator,
/// Pre-mutation snapshot of ancestors/colors, used to reconcile colors across a structural change.
cache: FaceCache,
}
@@ -46,24 +42,11 @@ pub struct FaceTypeOption {
}
impl FaceColoring {
- /// Tells the coloring how many palette entries exist.
- /// Preserves the current preference order for still-valid entries and appends any newly-available ones at the end.
+ /// Tells the coloring how many palette entries exist, refreshing render indices if it changed.
pub fn set_palette_len(&mut self, len: usize) {
- if self.palette_order.len() == len {
- return;
+ if self.allocator.set_len(len) {
+ self.refresh_render_indices();
}
- let mut order: Vec = self
- .palette_order
- .iter()
- .copied()
- .filter(|&p| p < len)
- .collect();
- for p in 0..len {
- if !order.contains(&p) {
- order.push(p);
- }
- }
- self.palette_order = order;
}
/// Snapshots the current colors against a fresh ancestor set, as the baseline for the next `reconcile`.
@@ -79,7 +62,9 @@ impl FaceColoring {
pub fn bootstrap(&mut self, colors: Vec, next_color_slot: usize) {
self.colors = colors;
self.next_color_slot = next_color_slot;
- self.assign_render_indices();
+ // Seed a palette floor so the initial render is dense before any `set_palette_len`.
+ self.allocator.set_len(next_color_slot);
+ self.refresh_render_indices();
}
/// Matches faces to a pre-mutation ancestry snapshot by Jaccard similarity.
@@ -106,9 +91,8 @@ impl FaceColoring {
}
}
- // Prefer a same-facetype ancestor first, then Jaccard similarity, then raw overlap count.
- // Same-side ranking keeps a surviving face (e.g. a triangle whose ancestry got flooded by
- // contracted neighbors) matched to its own facetype instead of a larger, better-overlapping one.
+ // Prefer a same-facetype ancestor, then Jaccard similarity, then raw overlap count.
+ // Same-side ranking keeps a survivor matched to its own facetype, not a larger better-overlapping one.
candidates.sort_by(|&(_, _, ia, ua, sa), &(_, _, ib, ub, sb)| {
let jaccard_a = ia as f64 / ua as f64;
let jaccard_b = ib as f64 / ub as f64;
@@ -144,10 +128,8 @@ impl FaceColoring {
None => votes.push((matched_color[i], 1)),
}
}
- // Prefer the most common real (matched) color;
- // only mint a new slot if no face in this group matched anything.
- // Otherwise a facetype with more new faces than old ones
- // (e.g. expand's 8 triangles onto 4) could tie against `None` and lose its color.
+ // Prefer the most common matched color; mint a new slot only if nothing matched.
+ // Otherwise a facetype with more new faces than old (e.g. expand's 8 triangles onto 4) ties against `None` and loses its color.
let winner = votes
.iter()
.filter(|(v, _)| v.is_some())
@@ -166,61 +148,17 @@ impl FaceColoring {
}
self.colors = new_colors;
- self.assign_render_indices();
+ self.refresh_render_indices();
}
- /// Maps each face's color slot to a palette index.
- /// A slot that is still present keeps its entry (a facetype never changes color while on screen).
- /// Each palette entry freed by a disappearing facetype moves to the back of `palette_order`,
- /// so newly-present slots draw the freshest colors first and only recycle a freed one once the rest are exhausted.
- fn assign_render_indices(&mut self) {
+ /// Reassigns palette indices for the current slots and rebuilds the cached render indices.
+ fn refresh_render_indices(&mut self) {
let present: BTreeSet = self.colors.iter().copied().collect();
-
- // Send every palette entry freed this round to the back of the preference order.
- let mut freed: Vec = self
- .palette_of_slot
+ self.allocator.reassign(&present);
+ self.render_indices = self
+ .colors
.iter()
- .filter(|(slot, _)| !present.contains(slot))
- .map(|(_, &palette)| palette)
+ .map(|&slot| self.allocator.palette_of(slot))
.collect();
- freed.sort_unstable();
-
- for palette in freed {
- self.palette_order.retain(|&p| p != palette);
- self.palette_order.push(palette);
- }
-
- // Make sure there is always at least one entry per present facetype to hand out.
- for extra in self.palette_order.len()..present.len() {
- self.palette_order.push(extra);
- }
-
- let mut new_map: HashMap = HashMap::new();
- let mut used: BTreeSet = BTreeSet::new();
-
- // Survivors keep their palette entry.
- for &slot in &present {
- if let Some(&palette) = self.palette_of_slot.get(&slot) {
- new_map.insert(slot, palette);
- used.insert(palette);
- }
- }
-
- // New facetypes take the first not-in-use entry in preference order.
- for &slot in &present {
- if let std::collections::hash_map::Entry::Vacant(entry) = new_map.entry(slot) {
- let palette = self
- .palette_order
- .iter()
- .copied()
- .find(|p| !used.contains(p))
- .unwrap_or(0);
- used.insert(palette);
- entry.insert(palette);
- }
- }
-
- self.render_indices = self.colors.iter().map(|slot| new_map[slot]).collect();
- self.palette_of_slot = new_map;
}
}
diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs
index c53c50e9..3808a39f 100644
--- a/src/polyhedron/mod.rs
+++ b/src/polyhedron/mod.rs
@@ -1,5 +1,6 @@
mod conway;
pub mod face;
+mod palette;
mod platonic;
mod render;
mod shape;
@@ -38,6 +39,39 @@ const SCHLEGEL_CONTAINMENT_MARGIN: f32 = 0.9;
/// Depth epsilon for the containment check, scaled to the face's inradius to avoid flicker.
const SCHLEGEL_DEPTH_EPSILON_FACTOR: f32 = 0.02;
+/// Contracts each edge in turn, remapping later edges onto the surviving lower index and closing the gap.
+/// `delete(v, u)` performs the per-structure removal of the higher endpoint `v` merged into survivor `u`.
+pub(crate) fn contract_edge_indices(
+ mut edges: Vec<[VertexId; 2]>,
+ mut delete: impl FnMut(VertexId, VertexId),
+) {
+ while !edges.is_empty() {
+ let [w, x] = edges.remove(0);
+ // Endpoints already merged (e.g. the last edge of a contracted cycle); nothing to do.
+ if w == x {
+ continue;
+ }
+ let v = w.max(x);
+ let u = w.min(x);
+ delete(v, u);
+ // Remap the deleted vertex onto the survivor, then close the index gap.
+ for [x, w] in &mut edges {
+ if *x == v {
+ *x = u;
+ }
+ if *w == v {
+ *w = u;
+ }
+ if *x > v {
+ *x -= 1;
+ }
+ if *w > v {
+ *w -= 1;
+ }
+ }
+ }
+}
+
#[derive(Debug, Clone)]
pub struct Polyhedron {
/// Conway Polyhedron Notation
@@ -118,7 +152,7 @@ impl Polyhedron {
let new_transactions = match conway {
Dual => {
// Expand blooms out, then contracting the face-figures collapses each face to a point.
- let edges = self.dual();
+ let edges = self.begin_dual();
vec![
Wait(Instant::now() + Duration::from_millis(500)),
Contraction(edges),
diff --git a/src/polyhedron/palette.rs b/src/polyhedron/palette.rs
new file mode 100644
index 00000000..9fcc06ee
--- /dev/null
+++ b/src/polyhedron/palette.rs
@@ -0,0 +1,57 @@
+use std::collections::{BTreeSet, HashMap, VecDeque};
+
+/// Maps ever-growing color slots onto a bounded palette of display indices.
+/// A live slot keeps its index while present, and a freed index recycles last so churned facetypes advance to fresh colors.
+#[derive(Debug, Default, Clone, PartialEq)]
+pub struct PaletteAllocator {
+ /// Palette index currently held by each live color slot.
+ assigned: HashMap,
+ /// Available palette indices in hand-out order; the front is freshest and freed entries go to the back.
+ free: VecDeque,
+}
+
+impl PaletteAllocator {
+ /// Sets how many palette entries exist, keeping existing assignments and preference order.
+ /// Returns whether anything changed; growth appends new indices, shrink drops out-of-range ones for reassignment.
+ pub fn set_len(&mut self, len: usize) -> bool {
+ let total = self.assigned.len() + self.free.len();
+ if total == len {
+ return false;
+ }
+ if len > total {
+ self.free.extend(total..len);
+ } else {
+ self.free.retain(|&p| p < len);
+ self.assigned.retain(|_, &mut p| p < len);
+ }
+ true
+ }
+
+ /// Reassigns palette indices for a new set of live slots.
+ /// Survivors keep their index, vanished slots free theirs to the back, and newcomers take the front.
+ pub fn reassign(&mut self, present: &BTreeSet) {
+ let gone: Vec = self
+ .assigned
+ .keys()
+ .copied()
+ .filter(|s| !present.contains(s))
+ .collect();
+ for slot in gone {
+ let palette = self.assigned.remove(&slot).unwrap();
+ self.free.push_back(palette);
+ }
+ for &slot in present {
+ if !self.assigned.contains_key(&slot) {
+ // Exhausted only when live facetypes outnumber the palette; degrade to index 0.
+ debug_assert!(!self.free.is_empty(), "palette exhausted: more facetypes than colors");
+ let palette = self.free.pop_front().unwrap_or(0);
+ self.assigned.insert(slot, palette);
+ }
+ }
+ }
+
+ /// Palette index a slot maps to, falling back to 0 for an unassigned slot.
+ pub fn palette_of(&self, slot: usize) -> usize {
+ self.assigned.get(&slot).copied().unwrap_or(0)
+ }
+}
diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs
index f6c05ec0..b7aba281 100644
--- a/src/polyhedron/platonic.rs
+++ b/src/polyhedron/platonic.rs
@@ -42,7 +42,7 @@ impl Polyhedron {
pub fn dodecahedron() -> Polyhedron {
let mut graph = Polyhedron::preset(&AntiPrism(5));
- graph.dual_contract();
+ graph.dual();
graph.truncate(5);
graph
}
diff --git a/src/polyhedron/render.rs b/src/polyhedron/render.rs
index 1feaa626..6c93f185 100644
--- a/src/polyhedron/render.rs
+++ b/src/polyhedron/render.rs
@@ -103,36 +103,10 @@ impl Render {
self.positions[u] += self.speeds[u];
}
- pub fn contract_edges(&mut self, mut edges: Vec<[VertexId; 2]>) {
- // let mut transformed = HashSet::default();
- while !edges.is_empty() {
- // Pop an edge
- let [w, x] = edges.remove(0);
- // Endpoints already merged (e.g. the last edge of a contracted cycle); keep in lockstep with the graph.
- if w == x {
- continue;
- }
- let v = w.max(x);
- let u = w.min(x);
-
+ pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) {
+ crate::polyhedron::contract_edge_indices(edges, |v, _| {
self.positions.remove(v);
self.speeds.remove(v);
-
- // Remap the deleted vertex onto the survivor, then close the index gap.
- for [x, w] in &mut edges {
- if *x == v {
- *x = u;
- }
- if *w == v {
- *w = u;
- }
- if *x > v {
- *x -= 1;
- }
- if *w > v {
- *w -= 1;
- }
- }
- }
+ });
}
}
diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs
index 460cac16..259805f7 100644
--- a/src/polyhedron/shape/conway.rs
+++ b/src/polyhedron/shape/conway.rs
@@ -11,8 +11,7 @@ impl Shape {
}
/// `t` full truncation: one new vertex per (vertex, incident-edge) corner.
- /// Returns the vertex-figure edges (so `ambo` contracts the rest) and each
- /// new vertex's originating vertex (so render can re-seed positions).
+ /// Returns the vertex-figure edges (so `ambo` contracts the rest) and each new vertex's origin for render re-seeding.
pub fn truncate(&mut self) -> (Vec<[VertexId; 2]>, Vec) {
// Index every (vertex, neighbor) corner; `corner[(v, u)]` is the new vertex there.
let mut corner: HashMap<(VertexId, VertexId), VertexId> = HashMap::new();
@@ -92,9 +91,7 @@ impl Shape {
}
/// `e` expand / cantellation: one new vertex per original vertex-face corner.
- /// Returns:
- /// - each new vertex's originating vertex, so that render can re-seed positions.
- /// - the face-figure edges which we contract to make a dual
+ /// Returns each new vertex's origin (for render re-seeding) and the face-figure edges to contract for the dual.
pub fn expand(&mut self) -> (Vec, Vec<[VertexId; 2]>) {
let cycles: Vec> = self
.cycles
diff --git a/src/polyhedron/shape/distance/conway.rs b/src/polyhedron/shape/distance/conway.rs
index 315e51b0..bf96ec9e 100644
--- a/src/polyhedron/shape/distance/conway.rs
+++ b/src/polyhedron/shape/distance/conway.rs
@@ -16,35 +16,10 @@ impl Distance {
self.delete(v);
}
- pub fn contract_edges(&mut self, mut edges: Vec<[VertexId; 2]>) {
- while !edges.is_empty() {
- // Pop an edge
- let [w, x] = edges.remove(0);
- // Endpoints already merged (e.g. the last edge of a contracted cycle); nothing to do.
- if w == x {
- continue;
- }
- let v = w.max(x);
- let u = w.min(x);
-
- // Contract [v, u], deleting v
+ pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) {
+ crate::polyhedron::contract_edge_indices(edges, |v, u| {
self.contract_edge([v, u]);
- // Remap the deleted vertex onto the survivor, then close the index gap.
- for [x, w] in &mut edges {
- if *x == v {
- *x = u;
- }
- if *w == v {
- *w = u;
- }
- if *x > v {
- *x -= 1;
- }
- if *w > v {
- *w -= 1;
- }
- }
- }
+ });
}
pub fn split_vertex(&mut self, v: VertexId, connections: Vec) -> Vec<[VertexId; 2]> {
diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs
index 8c03f6f1..9bf2b9f4 100644
--- a/src/polyhedron/test.rs
+++ b/src/polyhedron/test.rs
@@ -234,7 +234,7 @@ fn dodecahedron_is_well_formed() {
fn dual_cube_gives_octahedron() {
// Dual = expand, then contract the returned face-figure edges.
let mut polyhedron = Polyhedron::preset(&Prism(4));
- let edges = polyhedron.dual();
+ let edges = polyhedron.begin_dual();
polyhedron.contract(edges);
// Octahedron: V=6, E=12, F=8, all triangles.
@@ -255,7 +255,7 @@ fn dual_preserves_triangle_color_continuity() {
let mut polyhedron = Polyhedron::preset(&Prism(4));
polyhedron.cache_faces();
- let edges = polyhedron.dual();
+ let edges = polyhedron.begin_dual();
polyhedron.reconcile_face_colors();
let triangle = FaceTypeSignature {
side_count: 3,
@@ -307,7 +307,7 @@ fn survivor_keeps_color_while_freed_colors_rotate_to_the_back() {
// First dual: capture the intermediate cuboctahedron's square palette entry.
polyhedron.cache_faces();
- let edges = polyhedron.dual();
+ let edges = polyhedron.begin_dual();
polyhedron.reconcile_face_colors();
let first_square = render_index_for_signature(&polyhedron, &square);
polyhedron.cache_faces();
@@ -322,7 +322,7 @@ fn survivor_keeps_color_while_freed_colors_rotate_to_the_back() {
// Second dual: the recreated square advances to a fresh palette entry (the freed one is
// now at the back), and the surviving tetrahedron still holds its original color.
polyhedron.cache_faces();
- let edges = polyhedron.dual();
+ let edges = polyhedron.begin_dual();
polyhedron.reconcile_face_colors();
let second_square = render_index_for_signature(&polyhedron, &square);
assert_ne!(
@@ -346,9 +346,9 @@ fn survivor_keeps_color_while_freed_colors_rotate_to_the_back() {
fn dual_twice_is_identity() {
// dd == identity: cube -> octahedron -> cube.
let mut polyhedron = Polyhedron::preset(&Prism(4));
- let edges = polyhedron.dual();
+ let edges = polyhedron.begin_dual();
polyhedron.contract(edges);
- let edges = polyhedron.dual();
+ let edges = polyhedron.begin_dual();
polyhedron.contract(edges);
assert_eq!(polyhedron.shape.order(), 8, "vertex count");
From bdae4276b981861e87e362d9eaffeb1a2905fd56 Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 18:28:11 -0400
Subject: [PATCH 10/24] cleanup
---
src/polyhedron/mod.rs | 2 ++
src/polyhedron/palette.rs | 8 +++++---
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs
index 3808a39f..8b789d1f 100644
--- a/src/polyhedron/mod.rs
+++ b/src/polyhedron/mod.rs
@@ -528,6 +528,8 @@ impl Polyhedron {
}
};
+ // Exhausted only when live facetypes outnumber the palette; the `%` then wraps to a reused color.
+ debug_assert!(render_colors[i] < colors.len(), "palette exhausted: more facetypes than colors");
let color: Vec4 = colors[render_colors[i] % colors.len()].into();
// Map into MomentVertices
positions
diff --git a/src/polyhedron/palette.rs b/src/polyhedron/palette.rs
index 9fcc06ee..2c2c18d7 100644
--- a/src/polyhedron/palette.rs
+++ b/src/polyhedron/palette.rs
@@ -42,9 +42,11 @@ impl PaletteAllocator {
}
for &slot in present {
if !self.assigned.contains_key(&slot) {
- // Exhausted only when live facetypes outnumber the palette; degrade to index 0.
- debug_assert!(!self.free.is_empty(), "palette exhausted: more facetypes than colors");
- let palette = self.free.pop_front().unwrap_or(0);
+ // Grow on demand when the palette floor is exhausted; the render site bounds this to the real palette.
+ let palette = match self.free.pop_front() {
+ Some(p) => p,
+ None => self.assigned.len(),
+ };
self.assigned.insert(slot, palette);
}
}
From f0701954770701ba2bf7b0480a03d53ac1429590 Mon Sep 17 00:00:00 2001
From: Vera Gonzalez
Date: Tue, 21 Jul 2026 18:44:38 -0400
Subject: [PATCH 11/24] refine logic
---
src/polyhedron/face.rs | 24 ++++++++++++------------
src/polyhedron/test.rs | 31 +++++++++++++++++++++++++++++++
src/render/palette.rs | 7 +++++++
src/render/state.rs | 2 +-
4 files changed, 51 insertions(+), 13 deletions(-)
diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs
index 976d7229..c55a85c5 100644
--- a/src/polyhedron/face.rs
+++ b/src/polyhedron/face.rs
@@ -75,30 +75,30 @@ impl FaceColoring {
pub fn reconcile(&mut self, ancestors: Vec>, signatures: &[FaceTypeSignature]) {
let old = &self.cache;
- // (new_face, old_face, intersection, union, same_side) per candidate pair with any overlap.
- let mut candidates: Vec<(usize, usize, usize, usize, bool)> = Vec::new();
+ // (new_face, old_face, coverage, jaccard, same_side) per candidate pair with any overlap.
+ // `coverage` is the fraction of the old face's ancestry inherited by the new face.
+ let mut candidates: Vec<(usize, usize, f64, f64, bool)> = Vec::new();
for (i, a) in ancestors.iter().enumerate() {
for (j, o) in old.ancestors.iter().enumerate() {
let intersection = o.intersection(a).count();
if intersection > 0 {
- let union = o.union(a).count();
+ let coverage = intersection as f64 / o.len() as f64;
+ let jaccard = intersection as f64 / o.union(a).count() as f64;
let same_side = old
.side_counts
.get(j)
.is_some_and(|&s| s == signatures[i].side_count);
- candidates.push((i, j, intersection, union, same_side));
+ candidates.push((i, j, coverage, jaccard, same_side));
}
}
}
- // Prefer a same-facetype ancestor, then Jaccard similarity, then raw overlap count.
- // Same-side ranking keeps a survivor matched to its own facetype, not a larger better-overlapping one.
- candidates.sort_by(|&(_, _, ia, ua, sa), &(_, _, ib, ub, sb)| {
- let jaccard_a = ia as f64 / ua as f64;
- let jaccard_b = ib as f64 / ub as f64;
- sb.cmp(&sa)
- .then_with(|| jaccard_b.total_cmp(&jaccard_a))
- .then(ib.cmp(&ia))
+ // Rank by how fully the new face inherits the old face's ancestry, then same-side, then Jaccard.
+ // Coverage keeps a face's color when its side count changes (truncation's square -> octagon), where side-count matching would misassign it.
+ candidates.sort_by(|&(_, _, ca, ja, sa), &(_, _, cb, jb, sb)| {
+ cb.total_cmp(&ca)
+ .then_with(|| sb.cmp(&sa))
+ .then_with(|| jb.total_cmp(&ja))
});
let mut matched_color: Vec