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> = vec![None; ancestors.len()]; @@ -105,7 +120,15 @@ impl FaceColoring { None => votes.push((matched_color[i], 1)), } } - let winner = votes.iter().max_by_key(|(_, count)| *count).unwrap().0; + // 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() + .filter(|(v, _)| v.is_some()) + .max_by_key(|(_, count)| *count) + .map(|(v, _)| *v) + .unwrap_or(None); let color = winner.unwrap_or_else(|| { let slot = self.next_color_slot; @@ -118,17 +141,36 @@ impl FaceColoring { } self.colors = new_colors; - self.render_indices = dense_color_indices(&self.colors); + self.assign_render_indices(); } -} -/// Maps `colors`'s ever-growing values to a dense render index, so two facetypes never collide merely by being congruent mod `colors.len()`. -fn dense_color_indices(colors: &[usize]) -> Vec { - let mut distinct = colors.to_vec(); - distinct.sort_unstable(); - distinct.dedup(); - colors - .iter() - .map(|slot| distinct.binary_search(slot).unwrap()) - .collect() + /// 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`. + fn assign_render_indices(&mut self) { + let present: BTreeSet = self.colors.iter().copied().collect(); + + let mut new_map: HashMap = HashMap::new(); + let mut used: BTreeSet = BTreeSet::new(); + // Keep the palette entry of any slot that is still present. + 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. + for &slot in &present { + new_map.entry(slot).or_insert_with(|| { + let palette = (0..).find(|p| !used.contains(p)).unwrap(); + used.insert(palette); + 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 e5727be0..c53c50e9 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -78,7 +78,9 @@ impl Polyhedron { } pub fn cache_faces(&mut self) { - self.face_coloring.snapshot(self.shape.ancestors()); + let side_counts = self.shape.cycles.iter().map(|c| c.len()).collect(); + self.face_coloring + .snapshot(self.shape.ancestors(), side_counts); } pub fn process_transactions(&mut self, _speed: f32) { diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index a39dd20c..84f3f54c 100644 --- a/src/polyhedron/test.rs +++ b/src/polyhedron/test.rs @@ -87,14 +87,23 @@ fn assert_uniform_colors_per_facetype(polyhedron: &Polyhedron) { } } -/// The current color for a specific facetype; panics if no face currently has that signature. -fn color_for_signature(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> usize { - let signatures = polyhedron.face_signatures(); - let i = signatures +/// Index of the first face with the given signature; panics if none matches. +fn signature_index(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> usize { + polyhedron + .face_signatures() .iter() .position(|sig| sig == target) - .unwrap_or_else(|| panic!("no face with signature {target:?}")); - polyhedron.face_coloring.colors[i] + .unwrap_or_else(|| panic!("no face with signature {target:?}")) +} + +/// The current color slot for a specific facetype. +fn color_for_signature(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> usize { + polyhedron.face_coloring.colors[signature_index(polyhedron, target)] +} + +/// The rendered palette index (what the UI actually shows) for a specific facetype. +fn render_index_for_signature(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> usize { + polyhedron.face_coloring.render_indices[signature_index(polyhedron, target)] } #[test] @@ -238,6 +247,94 @@ fn dual_cube_gives_octahedron() { assert_eq!(polyhedron.render.positions.len(), 6, "render stays in sync"); } +#[test] +fn dual_preserves_triangle_color_continuity() { + // Mirrors the Dual transaction: expand (cube -> rhombicuboctahedron), then + // contract the face-figures (-> octahedron). The surviving vertex-figure + // triangles must keep their color across the contraction. + let mut polyhedron = Polyhedron::preset(&Prism(4)); + + polyhedron.cache_faces(); + let edges = polyhedron.dual(); + polyhedron.reconcile_face_colors(); + let triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![4, 4, 4], + }; + let pink_slot = color_for_signature(&polyhedron, &triangle); + let pink_render = render_index_for_signature(&polyhedron, &triangle); + + polyhedron.cache_faces(); + polyhedron.contract(edges); + polyhedron.reconcile_face_colors(); + assert_uniform_colors_per_facetype(&polyhedron); + + let octahedron_triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![3, 3, 3], + }; + // Both the color slot and — crucially — the rendered palette index must carry over, + // since the UI shows `palette[render_index]`, not the slot. + assert_eq!( + color_for_signature(&polyhedron, &octahedron_triangle), + pink_slot, + "octahedron triangles keep the rhombicuboctahedron triangle color slot" + ); + assert_eq!( + render_index_for_signature(&polyhedron, &octahedron_triangle), + pink_render, + "octahedron triangles render the same palette color as before" + ); +} + +#[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. + let mut polyhedron = Polyhedron::preset(&Pyramid(3)); + let triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![3, 3, 3], + }; + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 3, 3], + }; + let start = 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); + polyhedron.cache_faces(); + polyhedron.contract(edges); + polyhedron.reconcile_face_colors(); + assert_eq!( + render_index_for_signature(&polyhedron, &triangle), + start, + "tetrahedron keeps its color after one dual" + ); + + // Second dual: the recreated square must land on the same palette entry. + 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" + ); + polyhedron.contract(edges); + polyhedron.reconcile_face_colors(); + assert_eq!( + render_index_for_signature(&polyhedron, &triangle), + start, + "tetrahedron keeps its color after a second dual" + ); +} + #[test] fn dual_twice_is_identity() { // dd == identity: cube -> octahedron -> cube. From 31d2c05e6f748ce781ca3de40cc2e4f8928e5b64 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Tue, 21 Jul 2026 16:17:38 -0400 Subject: [PATCH 07/24] rewrite color logic again --- README.md | 10 ++----- src/polyhedron/face.rs | 65 +++++++++++++++++++++++++++++++++--------- src/polyhedron/test.rs | 33 ++++++++++++--------- src/render/state.rs | 4 +++ 4 files changed, 79 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 9a337e09..66f4c14c 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,7 @@

-## 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> = vec![None; ancestors.len()]; diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index 9bf2b9f4..3228d9e3 100644 --- a/src/polyhedron/test.rs +++ b/src/polyhedron/test.rs @@ -214,6 +214,37 @@ fn truncate_preserves_facetype_colors() { assert_ne!(color_for_signature(&polyhedron, &triangle), square_color); } +/// The color slot of the first face with the given side count; panics if none matches. +fn color_by_side(polyhedron: &Polyhedron, side: usize) -> usize { + let i = polyhedron + .shape + .cycles + .iter() + .position(|c| c.len() == side) + .unwrap_or_else(|| panic!("no face with {side} sides")); + polyhedron.face_coloring.colors[i] +} + +#[test] +fn truncate_cuboctahedron_keeps_square_color_on_octagons() { + // cube -> ambo -> cuboctahedron (6 squares + 8 triangles). + let mut polyhedron = Polyhedron::preset(&Prism(4)); + apply_ambo(&mut polyhedron); + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 3, 3], + }; + let square_color = color_for_signature(&polyhedron, &square); + + // truncate -> truncated cuboctahedron; each square becomes an octagon and must keep its color. + apply_truncate(&mut polyhedron); + assert_uniform_colors_per_facetype(&polyhedron); + + // The octagons descend from the squares, so they inherit the color; the new vertex-figure squares must not steal it. + assert_eq!(color_by_side(&polyhedron, 8), square_color, "octagons keep the square color"); + assert_ne!(color_by_side(&polyhedron, 4), square_color, "new squares must not steal the square color"); +} + #[test] fn dodecahedron_is_well_formed() { // dual(antiprism 5) then truncate its two degree-5 apexes -> dodecahedron. diff --git a/src/render/palette.rs b/src/render/palette.rs index 2aca21b1..a1f88029 100644 --- a/src/render/palette.rs +++ b/src/render/palette.rs @@ -39,6 +39,13 @@ impl Palette { "#639bff", "#8854f3", "#ff79ae", "#ff8c5c", "#fff982", "#63ffba", ]) } + pub fn clement_extended() -> Self { + Self::new(&[ + "#639bff", "#8854f3", "#ff79ae", "#ff8c5c", "#fff982", "#63ffba", "#a0ff70", "#70f3ff", + "#ff70ff", + ]) + } + pub fn dream_haze() -> Self { Self::new(&[ "#3c42c4", "#6e51c8", "#a065cd", "#ce79d2", "#d68fb8", "#dda2a3", "#eac4ae", "#f4dfbe", diff --git a/src/render/state.rs b/src/render/state.rs index d0d14f36..c548db4d 100644 --- a/src/render/state.rs +++ b/src/render/state.rs @@ -76,7 +76,7 @@ impl Default for RenderState { impl Default for ColorPickerState { fn default() -> Self { Self { - palette: Palette::clement(), + palette: Palette::clement_extended(), color_index: None, picked_color: RGBA::new(0, 0, 0, 255), colors: 1, From 969664505ba50e41381030b834ca111fc9adbce2 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Tue, 21 Jul 2026 18:44:46 -0400 Subject: [PATCH 12/24] fmt --- src/polyhedron/mod.rs | 5 ++++- src/polyhedron/shape/distance/mod.rs | 5 ++++- src/polyhedron/test.rs | 29 +++++++++++++++++++++++----- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 8b789d1f..bb250efd 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -529,7 +529,10 @@ 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"); + 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/shape/distance/mod.rs b/src/polyhedron/shape/distance/mod.rs index c9c7bee9..96790278 100644 --- a/src/polyhedron/shape/distance/mod.rs +++ b/src/polyhedron/shape/distance/mod.rs @@ -96,7 +96,10 @@ impl Distance { /// 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.ancestors = parents + .iter() + .map(|&p| source.ancestors[p].clone()) + .collect(); self.next_tag = source.next_tag; } diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index 3228d9e3..c5aa08a4 100644 --- a/src/polyhedron/test.rs +++ b/src/polyhedron/test.rs @@ -48,7 +48,11 @@ fn ambo_cube_gives_cuboctahedron() { 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"); + assert_eq!( + polyhedron.render.positions.len(), + 12, + "render stays in sync" + ); } fn apply_ambo(polyhedron: &mut Polyhedron) { @@ -180,7 +184,10 @@ fn expand_preserves_facetype_colors() { 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, &vertex_figure), + square_color + ); assert_ne!(color_for_signature(&polyhedron, &edge_quad), square_color); } @@ -241,8 +248,16 @@ fn truncate_cuboctahedron_keeps_square_color_on_octagons() { assert_uniform_colors_per_facetype(&polyhedron); // The octagons descend from the squares, so they inherit the color; the new vertex-figure squares must not steal it. - assert_eq!(color_by_side(&polyhedron, 8), square_color, "octagons keep the square color"); - assert_ne!(color_by_side(&polyhedron, 4), square_color, "new squares must not steal the square color"); + assert_eq!( + color_by_side(&polyhedron, 8), + square_color, + "octagons keep the square color" + ); + assert_ne!( + color_by_side(&polyhedron, 4), + square_color, + "new squares must not steal the square color" + ); } #[test] @@ -258,7 +273,11 @@ fn dodecahedron_is_well_formed() { 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"); + assert_eq!( + polyhedron.render.positions.len(), + 20, + "render stays in sync" + ); } #[test] From a5cbbfbce0cf3eb79661f345aed57c746a3f427b Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Tue, 21 Jul 2026 18:45:14 -0400 Subject: [PATCH 13/24] bump version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 19d2bb06..ec8f477e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5018,7 +5018,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyblade" -version = "0.3.4" +version = "0.4.0" dependencies = [ "bytemuck", "cfg-if", diff --git a/Cargo.toml b/Cargo.toml index b8804f32..3c420462 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "polyblade" -version = "0.3.4" +version = "0.4.0" edition = "2024" description = "Make shapes dance." readme = "README.md" From b8b6722e2465d46e9d1aa093f0a3f6b5743ce6ad Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Wed, 22 Jul 2026 16:41:02 -0400 Subject: [PATCH 14/24] implement Face Id System --- assets/tailwind.css | 6 + src/polyhedron/conway.rs | 2 +- src/polyhedron/face.rs | 166 +++++++------- src/polyhedron/mod.rs | 39 ++-- src/polyhedron/palette.rs | 3 +- src/polyhedron/platonic.rs | 5 +- src/polyhedron/shape/conway.rs | 279 +++++++++++++++++++----- src/polyhedron/shape/cycles/cycle.rs | 87 +++----- src/polyhedron/shape/cycles/mod.rs | 165 ++++++++------ src/polyhedron/shape/distance/conway.rs | 7 +- src/polyhedron/shape/distance/mod.rs | 55 +---- src/polyhedron/shape/mod.rs | 61 ++++-- src/polyhedron/shape/test.rs | 148 +++++++++++++ src/polyhedron/test.rs | 164 ++++++++++++-- 14 files changed, 794 insertions(+), 393 deletions(-) diff --git a/assets/tailwind.css b/assets/tailwind.css index 69c5036a..b2e4324e 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -634,6 +634,12 @@ video { outline-style: solid; } +.ring { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } \ No newline at end of file diff --git a/src/polyhedron/conway.rs b/src/polyhedron/conway.rs index fba76849..a2e884f3 100644 --- a/src/polyhedron/conway.rs +++ b/src/polyhedron/conway.rs @@ -21,7 +21,7 @@ impl Polyhedron { for v in self.shape.vertices().rev() { if self.shape.degree(v) == d { new_edges.extend(self.split_vertex(v)); - self.shape.recompute(); + self.shape.recompute_metrics(); } } new_edges diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs index c55a85c5..fe529050 100644 --- a/src/polyhedron/face.rs +++ b/src/polyhedron/face.rs @@ -1,15 +1,9 @@ use super::palette::PaletteAllocator; -use std::collections::{BTreeSet, 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, -} +use crate::polyhedron::FaceId; +use std::collections::{BTreeSet, HashMap}; /// Per-face color bookkeeping, kept separate from `Render` since it tracks facetype identity, not physical simulation state. +/// Continuity is definitional, not matched: a face id keeps its color slot for as long as it lives. #[derive(Debug, Default, Clone, PartialEq)] pub struct FaceColoring { /// Color slot per current face, parallel to `shape.cycles`. @@ -20,8 +14,8 @@ pub struct FaceColoring { pub render_indices: 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, + /// Persistent color slot per live face id; the single source of continuity across operations. + slots: HashMap, } /// A face's "type": side count plus its neighbors' sorted side-count multiset. @@ -49,105 +43,103 @@ 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>, side_counts: Vec) { - self.cache = FaceCache { - ancestors, - colors: self.colors.clone(), - side_counts, - }; - } - - /// Assigns colors fresh, one slot per distinct signature; used when there's no prior state to preserve continuity from. - pub fn bootstrap(&mut self, colors: Vec, next_color_slot: usize) { + /// Assigns colors fresh, one slot per distinct signature, when there is no prior state to preserve. + /// Wipes leftover id and palette state, since presets run operations internally before bootstrapping. + pub fn bootstrap(&mut self, face_ids: &[FaceId], colors: Vec, next_color_slot: usize) { + self.slots = face_ids + .iter() + .copied() + .zip(colors.iter().copied()) + .collect(); self.colors = colors; self.next_color_slot = next_color_slot; + self.allocator = PaletteAllocator::default(); // 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. - /// Results are then majority-voted per `FaceTypeSignature` to guarantee one color per facetype. - /// - /// `ancestors` is the post-mutation ancestry, one entry per current face. - /// The pre-mutation baseline it's matched against is whatever `snapshot` last recorded. - pub fn reconcile(&mut self, ancestors: Vec>, signatures: &[FaceTypeSignature]) { - let old = &self.cache; - - // (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 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, coverage, jaccard, same_side)); - } - } - } - - // 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)) - }); + /// Carries colors across a structural change: surviving ids keep their slot, parented fresh ids inherit it, and remaining fresh ids get one new slot per signature. + /// Normalization then enforces one color per signature, preferring inherited slots over ones minted this call, then most members, then smallest slot. + pub fn finalize( + &mut self, + face_ids: &[FaceId], + birth_parents: &HashMap, + signatures: &[FaceTypeSignature], + ) { + // Exact transfer by id, then by parent id. + let mut colors: Vec> = face_ids + .iter() + .map(|id| { + self.slots.get(id).copied().or_else(|| { + birth_parents + .get(id) + .and_then(|parent| self.slots.get(parent)) + .copied() + }) + }) + .collect(); - let mut matched_color: Vec> = vec![None; ancestors.len()]; - let mut old_claimed = vec![false; old.ancestors.len()]; - for (i, j, ..) in candidates { - if matched_color[i].is_none() && !old_claimed[j] { - matched_color[i] = Some(old.colors[j]); - old_claimed[j] = true; + // Mint for the genuinely new facetypes. + let mut minted: Vec = Vec::new(); + let mut minted_by_signature: Vec<(&FaceTypeSignature, usize)> = Vec::new(); + for (i, color) in colors.iter_mut().enumerate() { + if color.is_none() { + let slot = match minted_by_signature + .iter() + .find(|(sig, _)| **sig == signatures[i]) + { + Some((_, slot)) => *slot, + None => { + let slot = self.next_color_slot; + self.next_color_slot += 1; + minted.push(slot); + minted_by_signature.push((&signatures[i], slot)); + slot + } + }; + *color = Some(slot); } } + let mut colors: Vec = colors.into_iter().map(Option::unwrap).collect(); - // Group by facetype and majority-vote one color per group. - let mut groups: Vec<(FaceTypeSignature, Vec)> = Vec::new(); + // Normalization: one color per signature. + let mut groups: Vec<(&FaceTypeSignature, Vec)> = Vec::new(); for (i, sig) in signatures.iter().enumerate() { - match groups.iter_mut().find(|(s, _)| s == sig) { + match groups.iter_mut().find(|(s, _)| *s == sig) { Some((_, members)) => members.push(i), - None => groups.push((sig.clone(), vec![i])), + None => groups.push((sig, vec![i])), } } - - let mut new_colors = vec![0; ancestors.len()]; for (_, members) in &groups { - let mut votes: Vec<(Option, usize)> = Vec::new(); + let mut votes: Vec<(usize, usize)> = Vec::new(); for &i in members { - match votes.iter_mut().find(|(v, _)| *v == matched_color[i]) { + match votes.iter_mut().find(|(slot, _)| *slot == colors[i]) { Some((_, count)) => *count += 1, - None => votes.push((matched_color[i], 1)), + None => votes.push((colors[i], 1)), } } - // 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()) - .max_by_key(|(_, count)| *count) - .map(|(v, _)| *v) - .unwrap_or(None); - - let color = winner.unwrap_or_else(|| { - let slot = self.next_color_slot; - self.next_color_slot += 1; - slot - }); - for &i in members { - new_colors[i] = color; + if votes.len() > 1 { + let winner = votes + .iter() + .min_by_key(|&&(slot, count)| { + (minted.contains(&slot), usize::MAX - count, slot) + }) + .unwrap() + .0; + for &i in members { + colors[i] = winner; + } } } - self.colors = new_colors; + // Live ids adopt their final (possibly normalized) slots; dead ids fall away here. + self.slots = face_ids + .iter() + .copied() + .zip(colors.iter().copied()) + .collect(); + self.colors = colors; self.refresh_render_indices(); } diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index bb250efd..57c0df19 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -25,6 +25,9 @@ use ultraviolet::{Vec3, Vec4}; pub type VertexId = usize; +/// Stable, never-reused face identity, carried through every operation for color continuity. +pub type FaceId = u64; + pub const SPEED_DAMPENING: f32 = 0.92; /// Margin for the auto-fit Schlegel FOV so extremal vertices don't touch the viewport edge. @@ -111,12 +114,6 @@ impl Polyhedron { (start as u32, end as u32) } - pub fn cache_faces(&mut self) { - let side_counts = self.shape.cycles.iter().map(|c| c.len()).collect(); - self.face_coloring - .snapshot(self.shape.ancestors(), side_counts); - } - pub fn process_transactions(&mut self, _speed: f32) { if let Some(transaction) = self.transactions.first().cloned() { use Transaction::*; @@ -128,14 +125,12 @@ impl Polyhedron { .any(|l| l > 0.05); if all_completed { - self.cache_faces(); - // Contract them in the graph self.shape.contract_edges(edges.clone()); self.render.contract_edges(edges); self.transactions.remove(0); - self.reconcile_face_colors(); + self.finalize_face_colors(); } } Release(edges) => { @@ -147,8 +142,6 @@ impl Polyhedron { use ConwayMessage::*; use Transaction::*; - self.cache_faces(); - let new_transactions = match conway { Dual => { // Expand blooms out, then contracting the face-figures collapses each face to a point. @@ -179,13 +172,12 @@ impl Polyhedron { vec![Name('c')] } Kis => { - // self.graph.kis(Option::None); - // vec![Name('k')] - todo!() + self.shape.kis(Option::None); + vec![Name('k')] } SplitVertex(n) => { self.split_vertex(n); - self.shape.recompute(); + self.shape.recompute_metrics(); vec![] } Truncate => { @@ -222,7 +214,7 @@ impl Polyhedron { self.render.new_capacity(self.shape.order()); self.transactions = [new_transactions, self.transactions.clone()].concat(); - self.reconcile_face_colors(); + self.finalize_face_colors(); } Name(c) => { if c == 'b' { @@ -483,15 +475,18 @@ impl Polyhedron { .iter() .map(|sig| distinct.iter().position(|d| d == sig).unwrap()) .collect(); - self.face_coloring.bootstrap(face_colors, next_color_slot); + // Construction-time operations may have left parent records; bootstrap starts clean. + self.shape.birth_parents.clear(); + self.face_coloring + .bootstrap(self.shape.cycles.ids(), face_colors, next_color_slot); } - fn reconcile_face_colors(&mut self) { - let ancestors = self.shape.ancestors(); + /// Carries face colors across the operation that just completed, keyed purely by face id. + fn finalize_face_colors(&mut self) { let signatures = self.face_signatures(); - self.face_coloring.reconcile(ancestors, &signatures); - // Reset ancestry now so it never accumulates past one operation (see `Distance::reset_ancestry`). - self.shape.reset_ancestry(); + let birth_parents = std::mem::take(&mut self.shape.birth_parents); + self.face_coloring + .finalize(self.shape.cycles.ids(), &birth_parents, &signatures); } pub fn moment_vertices(&self, colors: &[crate::render::color::RGBA]) -> Vec { diff --git a/src/polyhedron/palette.rs b/src/polyhedron/palette.rs index 2c2c18d7..14567bb5 100644 --- a/src/polyhedron/palette.rs +++ b/src/polyhedron/palette.rs @@ -42,7 +42,8 @@ impl PaletteAllocator { } for &slot in present { if !self.assigned.contains_key(&slot) { - // Grow on demand when the palette floor is exhausted; the render site bounds this to the real palette. + // An exhausted palette hands out an out-of-range index that the render site wraps. + // That knowingly reuses a color; the render site's debug_assert flags it in debug builds. let palette = match self.free.pop_front() { Some(p) => p, None => self.assigned.len(), diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs index b7aba281..e9628b5b 100644 --- a/src/polyhedron/platonic.rs +++ b/src/polyhedron/platonic.rs @@ -27,9 +27,8 @@ impl Polyhedron { } } }; - // Bootstrapping is "reconciling from nothing", so reset ancestry here too. - // Otherwise e.g. octahedron's internal construction-time ambo leaks into the user's first op. - polyhedron.shape.reset_ancestry(); + // Bootstrapping assigns fresh colors regardless of construction-time operations. + polyhedron.bootstrap_face_colors(); polyhedron } diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs index 259805f7..87d304f2 100644 --- a/src/polyhedron/shape/conway.rs +++ b/src/polyhedron/shape/conway.rs @@ -1,12 +1,38 @@ -use super::{Cycle, Cycles, Distance, Shape}; -use crate::polyhedron::VertexId; -use std::collections::HashMap; +use super::{Cycles, Distance, Shape}; +use crate::polyhedron::{FaceId, VertexId}; +use std::collections::{HashMap, HashSet}; impl Shape { pub fn split_vertex(&mut self, v: VertexId) -> Vec<[usize; 2]> { let sc = self.cycles.sorted_connections(v); - let edges = self.distance.split_vertex(v, sc); - self.cycles = Cycles::from(&self.distance); + let edges = self.distance.split_vertex(v, sc.clone()); + // The ring edges are [corner_i, corner_i+1], where corner_i stays adjacent to sc[i]. + let corners: Vec = edges.iter().map(|&[a, _]| a).collect(); + + // Faces containing v keep their id, with v replaced by prev's then next's corner. + let mut new_cycles: Vec> = Vec::with_capacity(self.cycles.len() + 1); + let mut new_ids: Vec = Vec::with_capacity(self.cycles.len() + 1); + for (i, cycle) in self.cycles.iter().enumerate() { + let mut face: Vec = cycle.iter().copied().collect(); + if let Some(k) = face.iter().position(|&x| x == v) { + let n = face.len(); + let prev = face[(k + n - 1) % n]; + let next = face[(k + 1) % n]; + let j = sc.iter().position(|&x| x == prev).unwrap(); + let m = sc.iter().position(|&x| x == next).unwrap(); + face.splice(k..=k, [corners[j], corners[m]]); + } + new_cycles.push(face); + new_ids.push(self.cycles.ids()[i]); + } + // The corner ring itself is the new vertex-figure face. + new_cycles.push(corners); + new_ids.push(self.next_face_id); + self.next_face_id += 1; + + self.cycles = Cycles::new(new_cycles, new_ids); + self.cycles.sort(); + self.assert_cycles_match_discovery(); edges } @@ -43,50 +69,75 @@ impl Shape { distance.connect([corner[&(v, u)], corner[&(u, v)]]); } - distance.inherit_ancestry(&self.distance, &parents); + // Each original face persists as the 2n-gon over its corner copies, keeping its id. + // Consecutive corners alternate between original-edge crossings and vertex-figure edges. + let mut new_cycles: Vec> = Vec::new(); + let mut new_ids: Vec = Vec::new(); + for (i, cycle) in self.cycles.iter().enumerate() { + let gon = (0..cycle.len()) + .flat_map(|k| { + let (a, b) = (cycle[k], cycle[k + 1]); + [corner[&(a, b)], corner[&(b, a)]] + }) + .collect(); + new_cycles.push(gon); + new_ids.push(self.cycles.ids()[i]); + } + // Each original vertex spawns its vertex-figure d-gon: a genuinely new face. + for (v, sc) in vertex_order.iter().enumerate() { + new_cycles.push(sc.iter().map(|&u| corner[&(v, u)]).collect()); + new_ids.push(self.next_face_id); + self.next_face_id += 1; + } + self.distance = distance; - self.recompute(); + self.cycles = Cycles::new(new_cycles, new_ids); + self.cycles.sort(); + self.recompute_metrics(); + self.assert_cycles_match_discovery(); (new_edges, parents) } pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) { - self.distance.contract_edges(edges); - // Delete a - // for - // for i in 0..self.cycles.len() { - // self.cycles[i].replace(v, u); - // } - self.recompute(); + self.distance.contract_edges(edges.clone()); + // Both walks replay the same merge sequence, so the face list tracks the matrix exactly. + self.cycles.contract_edges(edges); + self.cycles.sort(); + self.recompute_metrics(); + self.assert_cycles_match_discovery(); } pub fn kis(&mut self, degree: Option) -> Vec<[VertexId; 2]> { let edges = self.distance.edges().collect(); - let cycles: Vec<&Cycle> = self - .cycles - .iter() - .filter(move |cycle| { - if let Some(degree) = degree { - cycle.len() == degree - } else { - true - } - }) - .collect(); - - for cycle in cycles { - let parents: Vec = cycle.iter().copied().collect(); - let v = self.distance.insert_from(&parents); - // let mut vpos = Vec3::zero(); - for &u in cycle.iter() { - self.distance.connect([v, u]); - //vpos += self.positions[&u]; + let mut new_cycles: Vec> = Vec::new(); + let mut new_ids: Vec = Vec::new(); + for i in 0..self.cycles.len() { + let face: Vec = self.cycles[i].iter().copied().collect(); + let id = self.cycles.ids()[i]; + if degree.is_some_and(|d| face.len() != d) { + // Untouched face persists as-is. + new_cycles.push(face); + new_ids.push(id); + continue; + } + // Raise an apex over the face; it splits into n triangles, each carved from `id`. + let v = self.distance.insert(); + let n = face.len(); + for k in 0..n { + self.distance.connect([v, face[k]]); + new_cycles.push(vec![v, face[k], face[(k + 1) % n]]); + new_ids.push(self.next_face_id); + self.birth_parents.insert(self.next_face_id, id); + self.next_face_id += 1; } - - //self.positions.insert(v, vpos / cycle.len() as f32); } - self.recompute(); + self.cycles = Cycles::new(new_cycles, new_ids); + self.cycles.sort(); + self.recompute_metrics(); + // No discovery oracle here because discovery falsely admits the covered original triangles. + // The explicit construction is the ground truth, verified by count assertions in tests. edges } @@ -98,6 +149,7 @@ impl Shape { .iter() .map(|c| c.iter().copied().collect()) .collect(); + let old_ids: Vec = self.cycles.ids().to_vec(); // Index every (face, corner) incidence; `c[f][i]` is the new vertex there. let mut c: Vec> = Vec::with_capacity(cycles.len()); @@ -113,6 +165,8 @@ impl Shape { c.push(row); } + // Position of a vertex within a face's cycle. + let pos = |f: usize, v: VertexId| cycles[f].iter().position(|&x| x == v).unwrap(); // Which two faces each original edge borders. let mut edge_faces: HashMap<[VertexId; 2], Vec> = HashMap::new(); for (f, cycle) in cycles.iter().enumerate() { @@ -137,42 +191,161 @@ impl Shape { } } // 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.connect([c[f][pos(f, v)], c[g][pos(g, v)]]); } } - distance.inherit_ancestry(&self.distance, &parents); + // Each original face persists as its corner-copy n-gon, keeping its id. + let mut new_cycles: Vec> = c.clone(); + let mut new_ids: Vec = old_ids; + // Each original edge spawns a quad, interleaved so each face's copy pair stays adjacent. + let mut seen: HashSet<[VertexId; 2]> = HashSet::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] }; + if !seen.insert(edge) { + continue; + } + let faces = &edge_faces[&edge]; + if faces.len() != 2 { + continue; + } + let g = if faces[0] == f { faces[1] } else { faces[0] }; + new_cycles.push(vec![ + c[f][pos(f, a)], + c[f][pos(f, b)], + c[g][pos(g, b)], + c[g][pos(g, a)], + ]); + new_ids.push(self.next_face_id); + self.next_face_id += 1; + } + } + // Each original vertex spawns its vertex-figure by walking the faces around v. + for v in 0..self.order() { + let f0 = (0..cycles.len()) + .find(|&f| cycles[f].contains(&v)) + .expect("vertex belongs to no face"); + let mut figure = Vec::new(); + let mut f = f0; + // Enter f0 via its edge (prev, v); the walk exits via (v, next) each step. + let mut entry = { + let k = pos(f0, v); + let prev = cycles[f0][(k + cycles[f0].len() - 1) % cycles[f0].len()]; + if prev < v { [prev, v] } else { [v, prev] } + }; + loop { + figure.push(c[f][pos(f, v)]); + let k = pos(f, v); + let next = cycles[f][(k + 1) % cycles[f].len()]; + let prev = cycles[f][(k + cycles[f].len() - 1) % cycles[f].len()]; + // Exit via whichever of v's two edges in f we didn't enter through. + let e_next = if next < v { [next, v] } else { [v, next] }; + let e_prev = if prev < v { [prev, v] } else { [v, prev] }; + let exit = if e_next == entry { e_prev } else { e_next }; + let faces = &edge_faces[&exit]; + debug_assert_eq!(faces.len(), 2, "open edge at vertex figure"); + f = if faces[0] == f { faces[1] } else { faces[0] }; + entry = exit; + if f == f0 { + break; + } + } + new_cycles.push(figure); + new_ids.push(self.next_face_id); + self.next_face_id += 1; + } + self.distance = distance; - self.recompute(); + self.cycles = Cycles::new(new_cycles, new_ids); + self.cycles.sort(); + self.recompute_metrics(); + self.assert_cycles_match_discovery(); (parents, face_edges) } pub fn chamfer(&mut self) { let originals = self.edges().collect::>(); - for cycle in self.cycles.iter() { - let mut new_face = vec![]; - for &v in cycle.iter() { - let u = self.distance.insert_from(&[v]); - new_face.push(u); - self.distance.connect([v, u]); - } - for i in 0..new_face.len() { - self.distance - .connect([new_face[i], new_face[(i + 1) % new_face.len()]]); + let cycles: Vec> = self + .cycles + .iter() + .map(|c| c.iter().copied().collect()) + .collect(); + let old_ids: Vec = self.cycles.ids().to_vec(); + + // Shrink each face: one new vertex per corner, tethered to the original and ringed together. + let mut c: Vec> = Vec::with_capacity(cycles.len()); + for cycle in &cycles { + let row: Vec = cycle + .iter() + .map(|&v| { + let u = self.distance.insert(); + self.distance.connect([v, u]); + u + }) + .collect(); + for k in 0..row.len() { + self.distance.connect([row[k], row[(k + 1) % row.len()]]); } + c.push(row); } for edge in originals { self.distance.disconnect(edge); } - self.recompute(); + + let pos = |f: usize, v: VertexId| cycles[f].iter().position(|&x| x == v).unwrap(); + 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); + } + } + + // Each original face persists as its shrunk copy, keeping its id. + let mut new_cycles: Vec> = c.clone(); + let mut new_ids: Vec = old_ids; + // Each original edge spawns a hexagon through both faces' shrunk copies. + let mut seen: HashSet<[VertexId; 2]> = HashSet::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] }; + if !seen.insert(edge) { + continue; + } + let faces = &edge_faces[&edge]; + if faces.len() != 2 { + continue; + } + let g = if faces[0] == f { faces[1] } else { faces[0] }; + new_cycles.push(vec![ + a, + c[f][pos(f, a)], + c[f][pos(f, b)], + b, + c[g][pos(g, b)], + c[g][pos(g, a)], + ]); + new_ids.push(self.next_face_id); + self.next_face_id += 1; + } + } + + self.cycles = Cycles::new(new_cycles, new_ids); + self.cycles.sort(); + self.recompute_metrics(); + self.assert_cycles_match_discovery(); } } diff --git a/src/polyhedron/shape/cycles/cycle.rs b/src/polyhedron/shape/cycles/cycle.rs index 2ad7962d..2db847df 100644 --- a/src/polyhedron/shape/cycles/cycle.rs +++ b/src/polyhedron/shape/cycles/cycle.rs @@ -28,39 +28,32 @@ impl Cycle { self.0.len() } - #[allow(dead_code)] - pub fn delete(&mut self, v: VertexId) { - self.0 = self - .0 - .clone() - .into_iter() - .filter_map(|u| { - use std::cmp::Ordering::*; - match v.cmp(&u) { - Equal => None, - Less => Some(u - 1), - Greater => Some(u), - } - }) - .collect::>(); - } - - #[allow(dead_code)] - pub fn replace(&mut self, old: VertexId, new: VertexId) { - self.0 = self - .0 - .clone() - .into_iter() - .filter_map(|v| { - if v == new { - None - } else if v == old { - Some(new) - } else { - Some(v) - } - }) - .collect(); + /// Merges deleted `v` into survivor `u < v`, shifting higher indices down and collapsing consecutive duplicates. + /// Returns whether the face survives with at least 3 vertices. + pub fn contract_vertex(&mut self, v: VertexId, u: VertexId) -> bool { + debug_assert!(u < v, "survivor must be the lower index"); + let mut out: Vec = Vec::with_capacity(self.0.len()); + for &x in &self.0 { + let x = match x { + x if x == v => u, + x if x > v => x - 1, + x => x, + }; + if out.last() != Some(&x) { + out.push(x); + } + } + while out.len() > 1 && out.first() == out.last() { + out.pop(); + } + // A non-consecutive duplicate means the contract set pinched a face, which no operation should produce. + debug_assert!( + out.len() < 3 + || out.iter().collect::>().len() == out.len(), + "contraction pinched a face: {out:?}" + ); + self.0 = out; + self.0.len() >= 3 } pub fn iter(&self) -> std::slice::Iter<'_, usize> { @@ -77,31 +70,3 @@ impl Cycle { self.0.push(v); } } - -impl From> for Cycle { - fn from(mut edges: Vec<[VertexId; 2]>) -> Self { - let mut first = false; - let mut face = vec![edges[0][0]]; - while !edges.is_empty() { - let v = if first { - *face.first().unwrap() - } else { - *face.last().unwrap() - }; - if let Some(i) = edges.iter().position(|e| e.contains(&v)) { - let next = if edges[i][0] == v { - edges[i][1] - } else { - edges[i][0] - }; - if !face.contains(&next) { - face.push(next); - } - edges.remove(i); - } else { - first ^= true; - } - } - Self(face) - } -} diff --git a/src/polyhedron/shape/cycles/mod.rs b/src/polyhedron/shape/cycles/mod.rs index d2742f40..18022b8a 100644 --- a/src/polyhedron/shape/cycles/mod.rs +++ b/src/polyhedron/shape/cycles/mod.rs @@ -1,5 +1,6 @@ mod cycle; -use crate::{polyhedron::VertexId, render::pipeline::ShapeVertex}; +use crate::polyhedron::{FaceId, VertexId}; +use crate::render::pipeline::ShapeVertex; pub use cycle::*; use std::{ collections::{HashMap, HashSet}, @@ -13,15 +14,66 @@ use super::Distance; pub(in super::super) struct Cycles { // Circular lists of Vertex Ids representing faces cycles: Vec, + /// Stable identity per face, parallel to `cycles`; survives sorts and operations. + ids: Vec, } impl Cycles { - pub fn new(cycles: Vec>) -> Self { + pub fn new(cycles: Vec>, ids: Vec) -> Self { + debug_assert_eq!(cycles.len(), ids.len()); Self { cycles: cycles.into_iter().map(Cycle).collect(), + ids, } } + /// Stable face ids, parallel to the cycle list. + pub fn ids(&self) -> &[FaceId] { + &self.ids + } + + /// Canonical face order: more sides first, then a more uniform neighborhood, then sorted vertices. + /// Total on these polyhedra (distinct faces have distinct vertex sets), so face 0 is deterministic. + pub fn sort(&mut self) { + let raw: Vec> = self.cycles.iter().map(|c| c.0.clone()).collect(); + let neighbor_uniformity: Vec = neighbor_type_signatures(&raw) + .iter() + .map(|sig| sig.iter().collect::>().len()) + .collect(); + let mut scored: Vec<(Cycle, FaceId, usize)> = std::mem::take(&mut self.cycles) + .into_iter() + .zip(std::mem::take(&mut self.ids)) + .zip(neighbor_uniformity) + .map(|((c, id), u)| (c, id, u)) + .collect(); + scored.sort_by_key(|(c, _, uniformity)| { + let mut sorted_vertices = c.0.clone(); + sorted_vertices.sort(); + (usize::MAX - c.len(), *uniformity, sorted_vertices) + }); + for (c, id, _) in scored { + self.cycles.push(c); + self.ids.push(id); + } + } + + /// Rediscovers faces from the distance matrix, minting fresh ids. + /// Only seed construction and the `release` fallback use this, operations build their cycles explicitly. + pub(super) fn discover(distance: &Distance, next_face_id: &mut FaceId) -> Self { + let raw = chordless_cycles(distance); + let ids = raw + .iter() + .map(|_| { + let id = *next_face_id; + *next_face_id += 1; + id + }) + .collect(); + let mut cycles = Cycles::new(raw, ids); + cycles.sort(); + cycles + } + #[allow(dead_code)] pub fn len(&self) -> usize { self.cycles.len() @@ -125,19 +177,20 @@ impl IndexMut for Cycles { } impl Cycles { - #[allow(dead_code)] - pub fn delete(&mut self, v: VertexId) { - for cycle in &mut self.cycles { - cycle.delete(v); - } - } - - /// Replace all occurrence of one vertex with another - #[allow(dead_code)] - pub fn replace(&mut self, old: VertexId, new: VertexId) { - for cycle in &mut self.cycles { - cycle.replace(old, new); - } + /// Replays the same merge sequence as `Distance::contract_edges` on the face list. + /// Survivors keep their ids and faces that degenerate below 3 vertices are dropped. + pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) { + crate::polyhedron::contract_edge_indices(edges, |v, u| { + let alive: Vec = self + .cycles + .iter_mut() + .map(|cycle| cycle.contract_vertex(v, u)) + .collect(); + let mut it = alive.iter(); + self.cycles.retain(|_| *it.next().unwrap()); + let mut it = alive.iter(); + self.ids.retain(|_| *it.next().unwrap()); + }); } } @@ -173,65 +226,47 @@ fn neighbor_type_signatures(cycles: &[Vec]) -> Vec> { .collect() } -impl From<&Distance> for Cycles { - fn from(distance: &Distance) -> Self { - let mut triplets: Vec> = Default::default(); - let mut cycles: HashSet> = Default::default(); - // find all the triplets - for u in 0..distance.order() { - for x in (u + 1)..distance.order() { - for y in (x + 1)..distance.order() { - if distance[[u, x]] == 1 && distance[[u, y]] == 1 { - if distance[[x, y]] == 1 { - cycles.insert(vec![x, u, y]); - } else { - triplets.push(vec![x, u, y]); - } +/// Chordless-cycle face search over the distance matrix; expensive, unordered output. +fn chordless_cycles(distance: &Distance) -> Vec> { + let mut triplets: Vec> = Default::default(); + let mut cycles: HashSet> = Default::default(); + // find all the triplets + for u in 0..distance.order() { + for x in (u + 1)..distance.order() { + for y in (x + 1)..distance.order() { + if distance[[u, x]] == 1 && distance[[u, y]] == 1 { + if distance[[x, y]] == 1 { + cycles.insert(vec![x, u, y]); + } else { + triplets.push(vec![x, u, y]); } } } } + } - // while there are unparsed triplets - while !triplets.is_empty() && (cycles.len() as i64) < distance.face_count() { - let p = triplets.remove(0); - - // for each v adjacent to u_t - for v in distance.neighbors(p[p.len() - 1]) { - if v > p[1] { - let adj_v = distance.neighbors(v); - // if v is not a neighbor of u_2..u_t-1 - if !p[1..p.len() - 1].iter().any(|i| adj_v.contains(i)) { - let new = [p.clone(), vec![v]].concat(); - if distance.neighbors(p[0]).contains(&v) { - if distance.cycle_is_face(new.clone()) { - cycles.insert(new); - } - } else { - triplets.push(new); + // while there are unparsed triplets + while !triplets.is_empty() && (cycles.len() as i64) < distance.face_count() { + let p = triplets.remove(0); + + // for each v adjacent to u_t + for v in distance.neighbors(p[p.len() - 1]) { + if v > p[1] { + let adj_v = distance.neighbors(v); + // if v is not a neighbor of u_2..u_t-1 + if !p[1..p.len() - 1].iter().any(|i| adj_v.contains(i)) { + let new = [p.clone(), vec![v]].concat(); + if distance.neighbors(p[0]).contains(&v) { + if distance.cycle_is_face(new.clone()) { + cycles.insert(new); } + } else { + triplets.push(new); } } } } - - let cycles = cycles.into_iter().collect::>(); - - // Fewer distinct neighbor side-counts means a more locally symmetric/uniform face. - let neighbor_uniformity: Vec = neighbor_type_signatures(&cycles) - .iter() - .map(|sig| sig.iter().collect::>().len()) - .collect(); - - let mut scored: Vec<(Vec, usize)> = - cycles.into_iter().zip(neighbor_uniformity).collect(); - // Prefer more sides, then a more uniform neighborhood, then a deterministic tie-break. - scored.sort_by_key(|(c, uniformity)| { - let mut sorted_vertices = c.clone(); - sorted_vertices.sort(); - (usize::MAX - c.len(), *uniformity, sorted_vertices) - }); - let cycles: Vec> = scored.into_iter().map(|(c, _)| c).collect(); - Cycles::new(cycles) } + + cycles.into_iter().collect() } diff --git a/src/polyhedron/shape/distance/conway.rs b/src/polyhedron/shape/distance/conway.rs index bf96ec9e..ecf5e8b0 100644 --- a/src/polyhedron/shape/distance/conway.rs +++ b/src/polyhedron/shape/distance/conway.rs @@ -9,10 +9,7 @@ impl Distance { self.connect([w, u]); self.disconnect([w, v]); } - // u now represents both original vertices - let absorbed = self.ancestors(v).clone(); - self.ancestors[u].extend(absorbed); - // Delete v + // Delete v; u now represents both original vertices self.delete(v); } @@ -27,7 +24,7 @@ impl Distance { let new_cycle: Cycle = Cycle::from( vec![v] .into_iter() - .chain((1..connections.len()).map(|_| self.insert_from(&[v]))) + .chain((1..connections.len()).map(|_| self.insert())) .collect(), ); diff --git a/src/polyhedron/shape/distance/mod.rs b/src/polyhedron/shape/distance/mod.rs index 96790278..900e928b 100644 --- a/src/polyhedron/shape/distance/mod.rs +++ b/src/polyhedron/shape/distance/mod.rs @@ -20,10 +20,6 @@ pub(super) struct Distance { /// The order is the number of vertices order: usize, distance: Vec, - /// Tags each vertex descends from; unioned into the survivor on a merge, copied to every copy on a split. - ancestors: Vec>, - /// Next never-yet-used tag, for genuinely new vertices only. - next_tag: u64, } impl PartialEq for Distance { @@ -44,8 +40,6 @@ impl Distance { distance: (0..n) .flat_map(|m| [vec![usize::MAX; m], vec![0]].concat()) .collect(), - ancestors: (0..n as u64).map(|tag| HashSet::from([tag])).collect(), - next_tag: n as u64, } } } @@ -65,53 +59,14 @@ impl Distance { } } - /// Inserts a new, genuinely-unrelated vertex in the matrix, with a fresh ancestor tag. - /// TODO: determine if we still even want this now that we're doing insert_from - #[allow(dead_code)] + /// Inserts a new vertex in the matrix. pub fn insert(&mut self) -> VertexId { - let v = self.insert_from(&[]); - self.ancestors[v].insert(self.next_tag); - self.next_tag += 1; - v - } - - /// Inserts a new vertex that's a copy of `parents`, inheriting the union of their ancestor sets. - pub fn insert_from(&mut self, parents: &[VertexId]) -> VertexId { self.distance .extend([vec![usize::MAX; self.order], vec![0]].concat()); self.order += 1; - let ancestors = parents.iter().fold(HashSet::new(), |mut acc, &p| { - acc.extend(&self.ancestors[p]); - acc - }); - self.ancestors.push(ancestors); self.order - 1 } - /// The set of persistent tags a vertex descends from. - pub fn ancestors(&self, v: VertexId) -> &HashSet { - &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) { - self.ancestors = (0..self.order as u64) - .map(|tag| HashSet::from([tag])) - .collect(); - self.next_tag = self.order as u64; - } - /// Deletes a vertex from the matrix pub fn delete(&mut self, v: VertexId) { let mut distance = Distance::new(self.order - 1); @@ -124,14 +79,6 @@ impl Distance { } } } - distance.ancestors = self - .ancestors - .iter() - .enumerate() - .filter(|&(i, _)| i != v) - .map(|(_, set)| set.clone()) - .collect(); - distance.next_tag = self.next_tag; *self = distance; } diff --git a/src/polyhedron/shape/mod.rs b/src/polyhedron/shape/mod.rs index c6f95419..90103996 100644 --- a/src/polyhedron/shape/mod.rs +++ b/src/polyhedron/shape/mod.rs @@ -2,7 +2,7 @@ mod conway; mod cycles; mod distance; mod platonic; -use std::{collections::HashSet, fmt::Display, ops::Range}; +use std::{fmt::Display, ops::Range}; use cycles::*; use distance::*; @@ -21,6 +21,10 @@ pub(super) struct Shape { pub cycles: Cycles, /// Faces / chordless cycles pub springs: Vec<[VertexId; 2]>, + /// Next never-yet-used face id, for genuinely new faces only. + next_face_id: FaceId, + /// Fresh face id mapped to the face it was carved from, consumed and cleared by the color finalize. + pub birth_parents: std::collections::HashMap, } impl PartialEq for Shape { @@ -58,36 +62,47 @@ impl Shape { self.distance.vertices() } - /// Union of a face's vertices' ancestor sets. - fn face_ancestors(&self, face_index: usize) -> HashSet { - self.cycles[face_index] - .iter() - .fold(HashSet::new(), |mut acc, &v| { - acc.extend(self.distance.ancestors(v)); - acc - }) - } - - pub fn ancestors(&self) -> Vec> { - (0..self.cycles.len()) - .map(|i| self.face_ancestors(i)) - .collect() - } - - /// Wipes vertex ancestry back to a fresh singleton tag per current vertex; see `Distance::reset_ancestry`. - pub fn reset_ancestry(&mut self) { - self.distance.reset_ancestry(); + pub fn recompute(&mut self) { + // Find and save cycles + self.cycles = Cycles::discover(&self.distance, &mut self.next_face_id); + self.recompute_metrics(); } - pub fn recompute(&mut self) { + /// Recomputes distances and springs but not faces, for operations that build their cycles explicitly. + pub fn recompute_metrics(&mut self) { // Update the distance matrix in place self.distance.bfs_apsp(); - // Find and save cycles - self.cycles = Cycles::from(&self.distance); // Find and save springs self.springs = self.distance.springs(); } + /// Debug oracle asserting operation-built cycles equal discovery's, as canonicalized faces in order. + /// This replaces the self-healing that per-op rediscovery used to provide. + pub fn assert_cycles_match_discovery(&self) { + #[cfg(debug_assertions)] + { + let canonical = |cycles: &Cycles| -> Vec> { + cycles + .iter() + .map(|c| { + let mut vs: Vec = c.iter().copied().collect(); + vs.sort_unstable(); + vs + }) + .collect() + }; + let mut scratch_id = 0; + let discovered = Cycles::discover(&self.distance, &mut scratch_id); + assert_eq!( + canonical(&self.cycles), + canonical(&discovered), + "operation-built cycles diverge from discovery" + ); + } + } + + /// Edge removal still falls back to full rediscovery, so face ids and colors reset here. + /// Only the unfinished Join operation uses it; switch to explicit cycle splicing when Join lands. pub fn release(&mut self, edges: &[[VertexId; 2]]) { for &edge in edges { self.distance.disconnect(edge); diff --git a/src/polyhedron/shape/test.rs b/src/polyhedron/shape/test.rs index 9062777f..beec8c6e 100644 --- a/src/polyhedron/shape/test.rs +++ b/src/polyhedron/shape/test.rs @@ -6,6 +6,154 @@ impl Shape { } } +#[test] +fn truncate_keeps_face_ids_on_2n_gons() { + let mut cube = Shape::prism(4); + let old: Vec<(FaceId, usize)> = cube + .cycles + .ids() + .iter() + .zip(cube.cycles.iter()) + .map(|(&id, c)| (id, c.len())) + .collect(); + + cube.truncate(); + + // Every original face survives under its id, with doubled side count. + for (id, n) in old { + let i = cube + .cycles + .ids() + .iter() + .position(|&x| x == id) + .unwrap_or_else(|| panic!("face id {id} lost by truncation")); + assert_eq!(cube.cycles[i].len(), 2 * n, "2n-gon side count for id {id}"); + } +} + +#[test] +fn expand_keeps_face_ids_on_corner_copies() { + let mut cube = Shape::prism(4); + let old: Vec<(FaceId, usize)> = cube + .cycles + .ids() + .iter() + .zip(cube.cycles.iter()) + .map(|(&id, c)| (id, c.len())) + .collect(); + + cube.expand(); + + // Every original face survives under its id, same side count (its corner-copy n-gon). + for (id, n) in old { + let i = cube + .cycles + .ids() + .iter() + .position(|&x| x == id) + .unwrap_or_else(|| panic!("face id {id} lost by expansion")); + assert_eq!( + cube.cycles[i].len(), + n, + "corner-copy side count for id {id}" + ); + } +} + +#[test] +fn op_chains_match_discovery() { + // The debug oracle inside each op asserts cycles match discovery on every chain here. + for seed in [ + Shape::pyramid(3), + Shape::prism(4), + Shape::anti_prism(3), + Shape::anti_prism(5), + ] { + let mut s = seed.clone(); + s.truncate(); + s.expand(); + + // Dual chain: expand, then contract the face-figure edges. + let mut s = seed.clone(); + let (_, face_edges) = s.expand(); + s.contract_edges(face_edges); + + // Double dual returns to the seed's face counts. + let (_, face_edges) = s.expand(); + s.contract_edges(face_edges); + assert_eq!(s.cycles.len(), seed.cycles.len(), "dd face count"); + } +} + +#[test] +fn chamfer_cube_counts_and_ids() { + let mut cube = Shape::prism(4); + let old_ids: Vec = cube.cycles.ids().to_vec(); + + cube.chamfer(); + + // Chamfered cube: V = 8 + 2E = 32, E = 4E = 48, F = 6 + 12 = 18. + assert_eq!(cube.order(), 32, "vertex count"); + assert_eq!(cube.edges().count(), 48, "edge count"); + assert_eq!(cube.cycles.len(), 18, "face count"); + // Original faces persist (shrunk) under their ids, still squares. + for id in old_ids { + let i = cube.cycles.ids().iter().position(|&x| x == id).unwrap(); + assert_eq!(cube.cycles[i].len(), 4, "shrunk face keeps side count"); + } + let hexes = cube.cycles.iter().filter(|c| c.len() == 6).count(); + assert_eq!(hexes, 12, "one hexagon per original edge"); +} + +#[test] +fn kis_children_record_their_parent() { + let mut tetra = Shape::pyramid(3); + let old_ids: Vec = tetra.cycles.ids().to_vec(); + + tetra.kis(None); + + // Kis tetrahedron: every face is a fresh triangle carved from an original face. + assert_eq!(tetra.cycles.len(), 12, "face count"); + for &id in tetra.cycles.ids() { + let parent = tetra.birth_parents.get(&id); + assert!( + parent.is_some_and(|p| old_ids.contains(p)), + "face {id} must record an original parent" + ); + } +} + +#[test] +fn contract_face_ring_keeps_survivor_ids() { + // Contracting a whole cube face ring chains merges until its last edge degenerates to [u, u]. + // The face collapses to a point, leaving a square pyramid. + let mut cube = Shape::prism(4); + let ring: Vec<[VertexId; 2]> = { + let cycle = &cube.cycles[0]; + (0..cycle.len()).map(|i| [cycle[i], cycle[i + 1]]).collect() + }; + let survivor_ids: Vec = cube + .cycles + .ids() + .iter() + .copied() + .skip(1) // face 0 is the one being collapsed + .collect(); + + cube.contract_edges(ring); + + // Square pyramid: V=5, E=8, F=5 (the opposite square + 4 side squares pinched to triangles). + assert_eq!(cube.order(), 5, "vertex count"); + assert_eq!(cube.edges().count(), 8, "edge count"); + assert_eq!(cube.cycles.len(), 5, "face count"); + // The collapsed face's id died; every other face survived with its id intact. + let mut expected = survivor_ids; + expected.sort_unstable(); + let mut actual: Vec = cube.cycles.ids().to_vec(); + actual.sort_unstable(); + assert_eq!(actual, expected, "survivors keep their face ids"); +} + #[test] fn expand_cube() { let mut cube = Shape::prism(4); diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index c5aa08a4..b7259885 100644 --- a/src/polyhedron/test.rs +++ b/src/polyhedron/test.rs @@ -15,7 +15,7 @@ use test_case::test_case; // #[test_case({ let mut g = Polyhedron::preset(&Dodecahedron); g.truncate(0); g} ; "tD")] fn polytope_apsp(poly: Polyhedron) { let mut bfs = poly.clone(); - bfs.shape.recompute(); + bfs.shape.recompute_metrics(); let mut floyd = poly.clone(); floyd.shape.floyd(); assert_eq!(bfs.shape, poly.shape); @@ -56,21 +56,18 @@ fn ambo_cube_gives_cuboctahedron() { } fn apply_ambo(polyhedron: &mut Polyhedron) { - polyhedron.cache_faces(); polyhedron.ambo_contract(); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); } fn apply_expand(polyhedron: &mut Polyhedron) { - polyhedron.cache_faces(); polyhedron.expand(); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); } fn apply_truncate(polyhedron: &mut Polyhedron) { - polyhedron.cache_faces(); polyhedron.truncate(0); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); } /// Every face sharing a `FaceTypeSignature` must share a color. @@ -304,9 +301,8 @@ fn dual_preserves_triangle_color_continuity() { // triangles must keep their color across the contraction. let mut polyhedron = Polyhedron::preset(&Prism(4)); - polyhedron.cache_faces(); let edges = polyhedron.begin_dual(); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); let triangle = FaceTypeSignature { side_count: 3, neighbor_sides: vec![4, 4, 4], @@ -314,9 +310,8 @@ fn dual_preserves_triangle_color_continuity() { let pink_slot = color_for_signature(&polyhedron, &triangle); let pink_render = render_index_for_signature(&polyhedron, &triangle); - polyhedron.cache_faces(); polyhedron.contract(edges); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); assert_uniform_colors_per_facetype(&polyhedron); let octahedron_triangle = FaceTypeSignature { @@ -337,6 +332,84 @@ fn dual_preserves_triangle_color_continuity() { ); } +#[test] +fn expand_tetrahedron_survivors_win_normalization() { + // In the expanded tetrahedron, 4 surviving triangles and 4 fresh ones share one signature. + // The fresh faces must adopt the survivors' slot, since a member-count vote would tie 4-vs-4. + let mut polyhedron = Polyhedron::preset(&Pyramid(3)); + let tetra_triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![3, 3, 3], + }; + let tetra_slot = color_for_signature(&polyhedron, &tetra_triangle); + let tetra_render = render_index_for_signature(&polyhedron, &tetra_triangle); + + apply_expand(&mut polyhedron); + assert_uniform_colors_per_facetype(&polyhedron); + + let cubocta_triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![4, 4, 4], + }; + assert_eq!( + color_for_signature(&polyhedron, &cubocta_triangle), + tetra_slot, + "vertex figures adopt the surviving triangles' slot" + ); + assert_eq!( + render_index_for_signature(&polyhedron, &cubocta_triangle), + tetra_render, + "rendered color is unchanged" + ); + + // The doomed vertex-figure slot dies in normalization before it can touch the palette. + // So the edge quads, the only genuinely new facetype, render the very next entry. + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 3, 3], + }; + assert_eq!( + render_index_for_signature(&polyhedron, &square), + tetra_render + 1, + "a normalization-doomed slot must not consume a palette entry" + ); +} + +#[test] +fn kis_children_inherit_parent_color() { + // Kis splits every cube face into apex triangles, a brand-new signature. + // Parent records make them keep the squares' color instead of minting a fresh one. + let mut polyhedron = Polyhedron::preset(&Prism(4)); + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![4, 4, 4, 4], + }; + let square_slot = color_for_signature(&polyhedron, &square); + let square_render = render_index_for_signature(&polyhedron, &square); + + polyhedron.shape.kis(Option::None); + polyhedron.finalize_face_colors(); + assert_uniform_colors_per_facetype(&polyhedron); + + assert!( + polyhedron + .face_coloring + .colors + .iter() + .all(|&c| c == square_slot), + "every kis child inherits its parent's slot" + ); + let triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![3, 3, 3], + }; + assert_eq!( + render_index_for_signature(&polyhedron, &triangle), + square_render, + "rendered color is unchanged" + ); +} + #[test] 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 @@ -356,13 +429,11 @@ fn survivor_keeps_color_while_freed_colors_rotate_to_the_back() { 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.begin_dual(); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); let first_square = render_index_for_signature(&polyhedron, &square); - polyhedron.cache_faces(); polyhedron.contract(edges); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); assert_eq!( render_index_for_signature(&polyhedron, &triangle), tetra_color, @@ -371,9 +442,8 @@ 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.begin_dual(); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); let second_square = render_index_for_signature(&polyhedron, &square); assert_ne!( second_square, first_square, @@ -384,7 +454,7 @@ fn survivor_keeps_color_while_freed_colors_rotate_to_the_back() { "recreated square never collides with the surviving facetype's color" ); polyhedron.contract(edges); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); assert_eq!( render_index_for_signature(&polyhedron, &triangle), tetra_color, @@ -431,3 +501,61 @@ fn ambo_octahedron_gives_distinct_facetype_colors() { color_for_signature(&polyhedron, &square) ); } + +/// Drives the real per-frame loop of physics plus transactions until the queue drains. +fn run_transactions(polyhedron: &mut Polyhedron) { + let mut iterations: u64 = 0; + while !polyhedron.transactions.is_empty() { + // The app's defaults: speed 10.0, frame time capped at 1/60s (render/state.rs). + polyhedron.update(10.0, 1.0 / 60.0); + iterations += 1; + assert!( + iterations < 200_000, + "transactions failed to converge: {:?}", + polyhedron.transactions + ); + } +} + +#[test] +fn transaction_loop_end_to_end() { + use ConwayMessage::*; + // Each operation exercised through the animated transaction machinery on a fresh cube. + for conway in [Truncate, Dual, Expand, Ambo, Kis, Chamfer] { + let mut polyhedron = Polyhedron::preset(&Prism(4)); + polyhedron.face_coloring.set_palette_len(9); + polyhedron + .transactions + .push(Transaction::Conway(conway.clone())); + run_transactions(&mut polyhedron); + + assert_eq!( + polyhedron.render.positions.len(), + polyhedron.shape.order(), + "render in sync after {conway:?}" + ); + assert_eq!( + polyhedron.face_coloring.colors.len(), + polyhedron.shape.cycles.len(), + "colors parallel to faces after {conway:?}" + ); + assert_eq!( + polyhedron.face_coloring.render_indices.len(), + polyhedron.shape.cycles.len(), + "render indices parallel to faces after {conway:?}" + ); + assert_uniform_colors_per_facetype(&polyhedron); + } + + // Bevel composes truncate + ambo through nested transactions (with real waits). + let mut polyhedron = Polyhedron::preset(&Prism(4)); + polyhedron.face_coloring.set_palette_len(9); + polyhedron + .transactions + .push(Transaction::Conway(ConwayMessage::Bevel)); + run_transactions(&mut polyhedron); + // Bevel here queues truncate then ambo: V = E(tC) = 36, F = F(tC) + V(tC) = 38. + assert_eq!(polyhedron.shape.order(), 36, "bevel vertex count"); + assert_eq!(polyhedron.shape.cycles.len(), 38, "bevel face count"); + assert_uniform_colors_per_facetype(&polyhedron); +} From 1eef6751e5787fa2edffbbdbff0b522da1696059 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Wed, 22 Jul 2026 17:03:37 -0400 Subject: [PATCH 15/24] update deployment workflow --- .github/workflows/deploy-web.yml | 37 ++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 0652d7bb..527d16c5 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -3,11 +3,23 @@ name: deploy-web on: push: branches: [main] + pull_request: {} workflow_dispatch: {} +permissions: + contents: read + deployments: write + +# superseded preview builds get cancelled, main deploys never are +concurrency: + group: deploy-web-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: deploy: runs-on: ubuntu-latest + # fork PRs have no access to the vercel secrets + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v5 - uses: DeterminateSystems/determinate-nix-action@v3 @@ -31,13 +43,34 @@ jobs: - name: install vercel CLI run: npm install -g vercel@latest - name: deploy to vercel + id: vercel env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + # the CLI logs to stderr and prints only the deployment URL to stdout run: | if [ "${{ github.event_name }}" = "push" ]; then - vercel deploy dist/public --prod --token="$VERCEL_TOKEN" --yes + url=$(vercel deploy dist/public --prod --token="$VERCEL_TOKEN" --yes) + echo "environment=Production" >> "$GITHUB_OUTPUT" else - vercel deploy dist/public --token="$VERCEL_TOKEN" --yes + url=$(vercel deploy dist/public --token="$VERCEL_TOKEN" --yes) + echo "environment=Preview" >> "$GITHUB_OUTPUT" fi + echo "url=$url" >> "$GITHUB_OUTPUT" + - name: record deployment on github + env: + GH_TOKEN: ${{ github.token }} + run: | + deployment_id=$(jq -n \ + --arg ref "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" \ + --arg env "${{ steps.vercel.outputs.environment }}" \ + '{ref: $ref, environment: $env, auto_merge: false, required_contexts: [], + production_environment: ($env == "Production"), + transient_environment: ($env == "Preview")}' \ + | gh api "repos/${{ github.repository }}/deployments" --input - --jq .id) + jq -n \ + --arg url "${{ steps.vercel.outputs.url }}" \ + --arg log "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + '{state: "success", environment_url: $url, log_url: $log, auto_inactive: true}' \ + | gh api "repos/${{ github.repository }}/deployments/$deployment_id/statuses" --input - From 8ebfa9510f689e6297752d85b46a8b4d41b1f0ee Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Wed, 22 Jul 2026 17:11:22 -0400 Subject: [PATCH 16/24] add deployment comment --- .github/workflows/deploy-web.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 527d16c5..a86b6295 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -9,6 +9,7 @@ on: permissions: contents: read deployments: write + pull-requests: write # superseded preview builds get cancelled, main deploys never are concurrency: @@ -74,3 +75,29 @@ jobs: --arg log "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ '{state: "success", environment_url: $url, log_url: $log, auto_inactive: true}' \ | gh api "repos/${{ github.repository }}/deployments/$deployment_id/statuses" --input - + - name: comment preview url on pr + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + URL: ${{ steps.vercel.outputs.url }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # one sticky comment per PR, updated in place on each push + run: | + marker="" + body="$(cat < Date: Wed, 22 Jul 2026 17:18:45 -0400 Subject: [PATCH 17/24] update readme to include kis --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 66f4c14c..2619d46c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Due to the recent refactor, we're not as far along on this roadmap as we once we Rest assured that in due time we will conquer all shapes. - [x] Ambo -- [ ] Kis +- [x] Kis - [x] Truncate - [ ] Ortho - [x] Bevel From 24a5fcfbe05fe2f4d521ff90fad0ecf51ba4ca1b Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Thu, 23 Jul 2026 16:27:47 -0400 Subject: [PATCH 18/24] cleanup --- src/polyhedron/mod.rs | 129 +++-------------------------- src/polyhedron/platonic.rs | 1 - src/polyhedron/shape/cycles/mod.rs | 19 +++-- 3 files changed, 22 insertions(+), 127 deletions(-) diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 57c0df19..22ac3c48 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -48,8 +48,10 @@ 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); + let mut i = 0; + while i < edges.len() { + let [w, x] = edges[i]; + i += 1; // Endpoints already merged (e.g. the last edge of a contracted cycle); nothing to do. if w == x { continue; @@ -58,18 +60,14 @@ pub(crate) fn contract_edge_indices( 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; + // Only edges still ahead of the cursor need remapping. + for [a, b] in &mut edges[i..] { + for endpoint in [a, b] { + if *endpoint == v { + *endpoint = u; + } else if *endpoint > v { + *endpoint -= 1; + } } } } @@ -153,17 +151,9 @@ impl Polyhedron { ] } Join => { - // let edges = self.graph.kis(Option::None); - // vec![ - // //Wait(Instant::now() + Duration::from_secs(1)), - // Release(edges), - // Name('j'), - // ] todo!() } Ambo => { - // let edges = self.shape.ambo(); - // self.shape.recompute(); let edges = self.ambo(); vec![Contraction(edges), Name('a')] } @@ -181,14 +171,6 @@ impl Polyhedron { vec![] } Truncate => { - // let mut operations = vec![]; - // for v in self.shape.vertices() { - // operations.extend(vec![ - // Wait(Instant::now() + Duration::from_millis(1000) * v as u32), - // Conway(SplitVertex(v)), - // ]); - // } - // [operations, vec![Name('t')]].concat() self.truncate(0); vec![Name('t')] } @@ -197,8 +179,6 @@ impl Polyhedron { vec![Name('e')] } Snub => { - // self.graph.expand(true); - // vec![Name('s')] todo!() } Bevel => { @@ -536,89 +516,4 @@ impl Polyhedron { }) .collect() } - - // fn face_positions(&self, face_index: usize) -> Vec { - // self.shape.cycles[face_index] - // .iter() - // .map(|&v| self.render.vertices[v].position) - // .collect() - // } - // Use a Fibonacci Lattice to spread the points evenly around a sphere - // pub fn connect(&mut self, [v, u]: [VertexId; 2]) { - // self.graph.connect([v, u]); - // } - // - // pub fn disconnect(&mut self, [v, u]: [VertexId; 2]) { - // self.graph.disconnect([v, u]); - // } - // - // pub fn insert(&mut self) -> VertexId { - // self.positions - // .push(Vec3::new(random(), random(), random()).normalized()); - // self.speeds.push(Vec3::zero()); - // self.graph.insert() - // } - - // pub fn delete(&mut self, v: VertexId) { - // self.vertices.remove(&v); - // - // self.edges = self - // .edges - // .clone() - // .into_iter() - // .filter(|e| !e.contains(v)) - // .collect(); - // - // self.cycles = self - // .cycles - // .clone() - // .into_iter() - // .map(|face| face.into_iter().filter(|&u| u != v).collect()) - // .collect(); - // - // self.positions.remove(&v); - // self.speeds.remove(&v); - // } - // - // /// Edges of a vertex - // pub fn edges(&self, v: VertexId) -> Vec { - // let mut edges = vec![]; - // for u in 0..self.dist.len() { - // if self.dist[v][u] == 1 { - // edges.push((v, u).into()); - // } - // } - // edges - // } - - // /// Number of faces - // pub fn face_count(&self) -> i64 { - // 2 + self.edges.len() as i64 - self.vertices.len() as i64 - // } - - // - // - // } - -// impl Display for PolyGraph { -// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -// let mut vertices = self.vertices.iter().collect::>(); -// vertices.sort(); -// let mut adjacents = self.edges.clone().into_iter().collect::>(); -// adjacents.sort(); -// -// f.write_fmt(format_args!( -// "name:\t\t{}\nvertices:\t{:?}\nadjacents:\t{}\nfaces:\t\t{}\n", -// self.name, -// vertices, -// adjacents -// .iter() -// .fold(String::new(), |acc, e| format!("{e}, {acc}")), -// self.cycles.iter().fold(String::new(), |acc, f| format!( -// "[{}], {acc}", -// f.iter().fold(String::new(), |acc, x| format!("{x}, {acc}")) -// )) -// )) -// } -// } diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs index e9628b5b..17c37114 100644 --- a/src/polyhedron/platonic.rs +++ b/src/polyhedron/platonic.rs @@ -28,7 +28,6 @@ impl Polyhedron { } }; // Bootstrapping assigns fresh colors regardless of construction-time operations. - polyhedron.bootstrap_face_colors(); polyhedron } diff --git a/src/polyhedron/shape/cycles/mod.rs b/src/polyhedron/shape/cycles/mod.rs index 18022b8a..9a27ab3e 100644 --- a/src/polyhedron/shape/cycles/mod.rs +++ b/src/polyhedron/shape/cycles/mod.rs @@ -181,15 +181,16 @@ impl Cycles { /// Survivors keep their ids and faces that degenerate below 3 vertices are dropped. pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) { crate::polyhedron::contract_edge_indices(edges, |v, u| { - let alive: Vec = self - .cycles - .iter_mut() - .map(|cycle| cycle.contract_vertex(v, u)) - .collect(); - let mut it = alive.iter(); - self.cycles.retain(|_| *it.next().unwrap()); - let mut it = alive.iter(); - self.ids.retain(|_| *it.next().unwrap()); + // Merge `v` into `u` in every face. + // Rebuild in a single pass; keep each surviving cycle paired with its id. + let cycles = std::mem::take(&mut self.cycles); + let ids = std::mem::take(&mut self.ids); + for (mut cycle, id) in cycles.into_iter().zip(ids) { + if cycle.contract_vertex(v, u) { + self.cycles.push(cycle); + self.ids.push(id); + } + } }); } } From 2167c4e80a2091271b72b7f64e351503f2c0c0fc Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Thu, 23 Jul 2026 16:47:15 -0400 Subject: [PATCH 19/24] change palette back --- src/render/palette.rs | 3 +-- src/render/state.rs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/render/palette.rs b/src/render/palette.rs index a1f88029..f28e6082 100644 --- a/src/render/palette.rs +++ b/src/render/palette.rs @@ -41,8 +41,7 @@ impl Palette { } pub fn clement_extended() -> Self { Self::new(&[ - "#639bff", "#8854f3", "#ff79ae", "#ff8c5c", "#fff982", "#63ffba", "#a0ff70", "#70f3ff", - "#ff70ff", + "#8854f3", "#fff982", "#639bff", "#ff8c5c", "#63ffba", "#ff79ae", "#70f3ff", ]) } diff --git a/src/render/state.rs b/src/render/state.rs index c548db4d..d0d14f36 100644 --- a/src/render/state.rs +++ b/src/render/state.rs @@ -76,7 +76,7 @@ impl Default for RenderState { impl Default for ColorPickerState { fn default() -> Self { Self { - palette: Palette::clement_extended(), + palette: Palette::clement(), color_index: None, picked_color: RGBA::new(0, 0, 0, 255), colors: 1, From 6ed474a07e9b6d8c457e407668293881f94ae12f Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Thu, 23 Jul 2026 16:48:45 -0400 Subject: [PATCH 20/24] working on topology rewrite --- src/polyhedron/shape/conway.rs | 302 ++++++++++++++++++--------------- 1 file changed, 161 insertions(+), 141 deletions(-) diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs index 87d304f2..62456565 100644 --- a/src/polyhedron/shape/conway.rs +++ b/src/polyhedron/shape/conway.rs @@ -2,6 +2,103 @@ use super::{Cycles, Distance, Shape}; use crate::polyhedron::{FaceId, VertexId}; use std::collections::{HashMap, HashSet}; +/// Canonical (order-independent) key for an undirected edge. +fn undirected(a: VertexId, b: VertexId) -> [VertexId; 2] { + if a < b { [a, b] } else { [b, a] } +} + +/// A read-only snapshot of a shape's faces taken at the start of a Conway operation, +/// with the incidence data corner/edge/face operations repeatedly need: +/// - `cycles`/`ids`: the original faces and their stable ids, +/// - `pos`: O(1) lookup of a vertex's index within a given face, +/// - `edge_faces`: the faces bordering each original edge. +/// +/// Operations snapshot once, build their new vertices, then read incidence from here while +/// emitting the new cycle list. Shared by `expand` and `chamfer`; the intent is that future +/// corner-based operations (bevel, ortho, gyro, …) build on the same primitives. +struct FaceTopology { + cycles: Vec>, + ids: Vec, + /// `pos[f][&v]` is the index of vertex `v` within face `f`. + pos: Vec>, + /// Undirected original edge → bordering face indices (exactly two on a closed polyhedron). + edge_faces: HashMap<[VertexId; 2], Vec>, +} + +impl FaceTopology { + fn snapshot(cycles: &Cycles) -> Self { + let ids = cycles.ids().to_vec(); + let cycles: Vec> = + cycles.iter().map(|c| c.iter().copied().collect()).collect(); + let mut pos: Vec> = Vec::with_capacity(cycles.len()); + let mut edge_faces: HashMap<[VertexId; 2], Vec> = HashMap::new(); + for (f, cycle) in cycles.iter().enumerate() { + let n = cycle.len(); + let mut row = HashMap::with_capacity(n); + for k in 0..n { + row.insert(cycle[k], k); + edge_faces + .entry(undirected(cycle[k], cycle[(k + 1) % n])) + .or_default() + .push(f); + } + pos.push(row); + } + Self { + cycles, + ids, + pos, + edge_faces, + } + } + + /// Index of vertex `v` within face `f`. + fn pos(&self, f: usize, v: VertexId) -> usize { + self.pos[f][&v] + } + + /// The face across `edge` from `f`, if `edge` is interior (borders exactly two faces). + fn other_face(&self, f: usize, a: VertexId, b: VertexId) -> Option { + let faces = self.edge_faces.get(&undirected(a, b))?; + (faces.len() == 2).then(|| if faces[0] == f { faces[1] } else { faces[0] }) + } + + /// Visits each interior original edge exactly once, in face-then-corner order (so ids minted + /// per edge stay deterministic), yielding the face `f` it was found in, its endpoints `a,b` in + /// `f`'s winding, and the opposite face `g`. + fn for_each_interior_edge(&self, mut visit: impl FnMut(usize, VertexId, VertexId, usize)) { + let mut seen: HashSet<[VertexId; 2]> = HashSet::new(); + for (f, cycle) in self.cycles.iter().enumerate() { + let n = cycle.len(); + for k in 0..n { + let (a, b) = (cycle[k], cycle[(k + 1) % n]); + if seen.insert(undirected(a, b)) + && let Some(g) = self.other_face(f, a, b) + { + visit(f, a, b, g); + } + } + } + } +} + +impl Shape { + /// Mints the next never-yet-used face id. + fn fresh_face_id(&mut self) -> FaceId { + let id = self.next_face_id; + self.next_face_id += 1; + id + } + + /// Installs an explicitly-built face list: canonically sort it and refresh derived metrics. + /// Callers that maintain the discovery invariant should follow with `assert_cycles_match_discovery`. + fn install_cycles(&mut self, cycles: Vec>, ids: Vec) { + self.cycles = Cycles::new(cycles, ids); + self.cycles.sort(); + self.recompute_metrics(); + } +} + impl Shape { pub fn split_vertex(&mut self, v: VertexId) -> Vec<[usize; 2]> { let sc = self.cycles.sorted_connections(v); @@ -27,9 +124,9 @@ impl Shape { } // The corner ring itself is the new vertex-figure face. new_cycles.push(corners); - new_ids.push(self.next_face_id); - self.next_face_id += 1; + new_ids.push(self.fresh_face_id()); + // Metrics are recomputed by the caller after splitting, so only rebuild the face list here. self.cycles = Cycles::new(new_cycles, new_ids); self.cycles.sort(); self.assert_cycles_match_discovery(); @@ -86,14 +183,11 @@ impl Shape { // Each original vertex spawns its vertex-figure d-gon: a genuinely new face. for (v, sc) in vertex_order.iter().enumerate() { new_cycles.push(sc.iter().map(|&u| corner[&(v, u)]).collect()); - new_ids.push(self.next_face_id); - self.next_face_id += 1; + new_ids.push(self.fresh_face_id()); } self.distance = distance; - self.cycles = Cycles::new(new_cycles, new_ids); - self.cycles.sort(); - self.recompute_metrics(); + self.install_cycles(new_cycles, new_ids); self.assert_cycles_match_discovery(); (new_edges, parents) } @@ -127,15 +221,13 @@ impl Shape { for k in 0..n { self.distance.connect([v, face[k]]); new_cycles.push(vec![v, face[k], face[(k + 1) % n]]); - new_ids.push(self.next_face_id); - self.birth_parents.insert(self.next_face_id, id); - self.next_face_id += 1; + let fid = self.fresh_face_id(); + new_ids.push(fid); + self.birth_parents.insert(fid, id); } } - self.cycles = Cycles::new(new_cycles, new_ids); - self.cycles.sort(); - self.recompute_metrics(); + self.install_cycles(new_cycles, new_ids); // No discovery oracle here because discovery falsely admits the covered original triangles. // The explicit construction is the ground truth, verified by count assertions in tests. edges @@ -144,17 +236,12 @@ impl Shape { /// `e` expand / cantellation: one new vertex per original vertex-face corner. /// 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 - .iter() - .map(|c| c.iter().copied().collect()) - .collect(); - let old_ids: Vec = self.cycles.ids().to_vec(); + let topo = FaceTopology::snapshot(&self.cycles); - // Index every (face, corner) incidence; `c[f][i]` is the new vertex there. - let mut c: Vec> = Vec::with_capacity(cycles.len()); + // Index every (face, corner) incidence; `c[f][i]` is the new vertex at face `f`'s i-th corner. + let mut c: Vec> = Vec::with_capacity(topo.cycles.len()); let mut parents: Vec = Vec::new(); - for cycle in &cycles { + for cycle in &topo.cycles { let row = cycle .iter() .map(|&v| { @@ -164,25 +251,14 @@ impl Shape { .collect(); c.push(row); } - - // Position of a vertex within a face's cycle. - let pos = |f: usize, v: VertexId| cycles[f].iter().position(|&x| x == v).unwrap(); - // 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); - } - } + // The new vertex at face `f`'s copy of vertex `v`. + let corner = |f: usize, v: VertexId| c[f][topo.pos(f, v)]; 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() { + for (f, cycle) in topo.cycles.iter().enumerate() { let n = cycle.len(); for k in 0..n { let edge = [c[f][k], c[f][(k + 1) % n]]; @@ -190,100 +266,70 @@ impl Shape { face_edges.push(edge); } } - // Vertex-figure rungs: link the two faces' copies of each endpoint. - for (edge, faces) in &edge_faces { - if faces.len() != 2 { - continue; - } - let [f, g] = [faces[0], faces[1]]; - for &v in edge { - distance.connect([c[f][pos(f, v)], c[g][pos(g, v)]]); - } - } + // Vertex-figure rungs: link the two faces' copies of each shared endpoint. + topo.for_each_interior_edge(|f, a, b, g| { + distance.connect([corner(f, a), corner(g, a)]); + distance.connect([corner(f, b), corner(g, b)]); + }); // Each original face persists as its corner-copy n-gon, keeping its id. let mut new_cycles: Vec> = c.clone(); - let mut new_ids: Vec = old_ids; + let mut new_ids: Vec = topo.ids.clone(); // Each original edge spawns a quad, interleaved so each face's copy pair stays adjacent. - let mut seen: HashSet<[VertexId; 2]> = HashSet::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] }; - if !seen.insert(edge) { - continue; - } - let faces = &edge_faces[&edge]; - if faces.len() != 2 { - continue; - } - let g = if faces[0] == f { faces[1] } else { faces[0] }; - new_cycles.push(vec![ - c[f][pos(f, a)], - c[f][pos(f, b)], - c[g][pos(g, b)], - c[g][pos(g, a)], - ]); - new_ids.push(self.next_face_id); - self.next_face_id += 1; - } - } + topo.for_each_interior_edge(|f, a, b, g| { + new_cycles.push(vec![corner(f, a), corner(f, b), corner(g, b), corner(g, a)]); + new_ids.push(self.fresh_face_id()); + }); // Each original vertex spawns its vertex-figure by walking the faces around v. for v in 0..self.order() { - let f0 = (0..cycles.len()) - .find(|&f| cycles[f].contains(&v)) + let f0 = (0..topo.cycles.len()) + .find(|&f| topo.pos[f].contains_key(&v)) .expect("vertex belongs to no face"); let mut figure = Vec::new(); let mut f = f0; - // Enter f0 via its edge (prev, v); the walk exits via (v, next) each step. + // Enter f0 via its edge (prev, v); the walk exits via v's other edge each step. let mut entry = { - let k = pos(f0, v); - let prev = cycles[f0][(k + cycles[f0].len() - 1) % cycles[f0].len()]; - if prev < v { [prev, v] } else { [v, prev] } + let cyc = &topo.cycles[f0]; + let k = topo.pos(f0, v); + undirected(cyc[(k + cyc.len() - 1) % cyc.len()], v) }; loop { - figure.push(c[f][pos(f, v)]); - let k = pos(f, v); - let next = cycles[f][(k + 1) % cycles[f].len()]; - let prev = cycles[f][(k + cycles[f].len() - 1) % cycles[f].len()]; + figure.push(corner(f, v)); + let cyc = &topo.cycles[f]; + let k = topo.pos(f, v); + let next = cyc[(k + 1) % cyc.len()]; + let prev = cyc[(k + cyc.len() - 1) % cyc.len()]; // Exit via whichever of v's two edges in f we didn't enter through. - let e_next = if next < v { [next, v] } else { [v, next] }; - let e_prev = if prev < v { [prev, v] } else { [v, prev] }; - let exit = if e_next == entry { e_prev } else { e_next }; - let faces = &edge_faces[&exit]; - debug_assert_eq!(faces.len(), 2, "open edge at vertex figure"); - f = if faces[0] == f { faces[1] } else { faces[0] }; + let exit = if undirected(next, v) == entry { + undirected(prev, v) + } else { + undirected(next, v) + }; + f = topo + .other_face(f, exit[0], exit[1]) + .expect("open edge at vertex figure"); entry = exit; if f == f0 { break; } } new_cycles.push(figure); - new_ids.push(self.next_face_id); - self.next_face_id += 1; + new_ids.push(self.fresh_face_id()); } self.distance = distance; - self.cycles = Cycles::new(new_cycles, new_ids); - self.cycles.sort(); - self.recompute_metrics(); + self.install_cycles(new_cycles, new_ids); self.assert_cycles_match_discovery(); (parents, face_edges) } pub fn chamfer(&mut self) { let originals = self.edges().collect::>(); - let cycles: Vec> = self - .cycles - .iter() - .map(|c| c.iter().copied().collect()) - .collect(); - let old_ids: Vec = self.cycles.ids().to_vec(); + let topo = FaceTopology::snapshot(&self.cycles); // Shrink each face: one new vertex per corner, tethered to the original and ringed together. - let mut c: Vec> = Vec::with_capacity(cycles.len()); - for cycle in &cycles { + let mut c: Vec> = Vec::with_capacity(topo.cycles.len()); + for cycle in &topo.cycles { let row: Vec = cycle .iter() .map(|&v| { @@ -300,52 +346,26 @@ impl Shape { for edge in originals { self.distance.disconnect(edge); } - - let pos = |f: usize, v: VertexId| cycles[f].iter().position(|&x| x == v).unwrap(); - 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); - } - } + // The shrunk copy at face `f`'s corner for vertex `v`. + let corner = |f: usize, v: VertexId| c[f][topo.pos(f, v)]; // Each original face persists as its shrunk copy, keeping its id. let mut new_cycles: Vec> = c.clone(); - let mut new_ids: Vec = old_ids; + let mut new_ids: Vec = topo.ids.clone(); // Each original edge spawns a hexagon through both faces' shrunk copies. - let mut seen: HashSet<[VertexId; 2]> = HashSet::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] }; - if !seen.insert(edge) { - continue; - } - let faces = &edge_faces[&edge]; - if faces.len() != 2 { - continue; - } - let g = if faces[0] == f { faces[1] } else { faces[0] }; - new_cycles.push(vec![ - a, - c[f][pos(f, a)], - c[f][pos(f, b)], - b, - c[g][pos(g, b)], - c[g][pos(g, a)], - ]); - new_ids.push(self.next_face_id); - self.next_face_id += 1; - } - } + topo.for_each_interior_edge(|f, a, b, g| { + new_cycles.push(vec![ + a, + corner(f, a), + corner(f, b), + b, + corner(g, b), + corner(g, a), + ]); + new_ids.push(self.fresh_face_id()); + }); - self.cycles = Cycles::new(new_cycles, new_ids); - self.cycles.sort(); - self.recompute_metrics(); + self.install_cycles(new_cycles, new_ids); self.assert_cycles_match_discovery(); } } From d15c7298cee47e9ffe83e9fc5d02675024c13e91 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Thu, 23 Jul 2026 16:50:05 -0400 Subject: [PATCH 21/24] move topology into its own file --- src/polyhedron/shape/conway.rs | 100 +------------------------------ src/polyhedron/shape/mod.rs | 16 +++++ src/polyhedron/shape/topology.rs | 86 ++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 98 deletions(-) create mode 100644 src/polyhedron/shape/topology.rs diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs index 62456565..748103f6 100644 --- a/src/polyhedron/shape/conway.rs +++ b/src/polyhedron/shape/conway.rs @@ -1,103 +1,7 @@ +use super::topology::{FaceTopology, undirected}; use super::{Cycles, Distance, Shape}; use crate::polyhedron::{FaceId, VertexId}; -use std::collections::{HashMap, HashSet}; - -/// Canonical (order-independent) key for an undirected edge. -fn undirected(a: VertexId, b: VertexId) -> [VertexId; 2] { - if a < b { [a, b] } else { [b, a] } -} - -/// A read-only snapshot of a shape's faces taken at the start of a Conway operation, -/// with the incidence data corner/edge/face operations repeatedly need: -/// - `cycles`/`ids`: the original faces and their stable ids, -/// - `pos`: O(1) lookup of a vertex's index within a given face, -/// - `edge_faces`: the faces bordering each original edge. -/// -/// Operations snapshot once, build their new vertices, then read incidence from here while -/// emitting the new cycle list. Shared by `expand` and `chamfer`; the intent is that future -/// corner-based operations (bevel, ortho, gyro, …) build on the same primitives. -struct FaceTopology { - cycles: Vec>, - ids: Vec, - /// `pos[f][&v]` is the index of vertex `v` within face `f`. - pos: Vec>, - /// Undirected original edge → bordering face indices (exactly two on a closed polyhedron). - edge_faces: HashMap<[VertexId; 2], Vec>, -} - -impl FaceTopology { - fn snapshot(cycles: &Cycles) -> Self { - let ids = cycles.ids().to_vec(); - let cycles: Vec> = - cycles.iter().map(|c| c.iter().copied().collect()).collect(); - let mut pos: Vec> = Vec::with_capacity(cycles.len()); - let mut edge_faces: HashMap<[VertexId; 2], Vec> = HashMap::new(); - for (f, cycle) in cycles.iter().enumerate() { - let n = cycle.len(); - let mut row = HashMap::with_capacity(n); - for k in 0..n { - row.insert(cycle[k], k); - edge_faces - .entry(undirected(cycle[k], cycle[(k + 1) % n])) - .or_default() - .push(f); - } - pos.push(row); - } - Self { - cycles, - ids, - pos, - edge_faces, - } - } - - /// Index of vertex `v` within face `f`. - fn pos(&self, f: usize, v: VertexId) -> usize { - self.pos[f][&v] - } - - /// The face across `edge` from `f`, if `edge` is interior (borders exactly two faces). - fn other_face(&self, f: usize, a: VertexId, b: VertexId) -> Option { - let faces = self.edge_faces.get(&undirected(a, b))?; - (faces.len() == 2).then(|| if faces[0] == f { faces[1] } else { faces[0] }) - } - - /// Visits each interior original edge exactly once, in face-then-corner order (so ids minted - /// per edge stay deterministic), yielding the face `f` it was found in, its endpoints `a,b` in - /// `f`'s winding, and the opposite face `g`. - fn for_each_interior_edge(&self, mut visit: impl FnMut(usize, VertexId, VertexId, usize)) { - let mut seen: HashSet<[VertexId; 2]> = HashSet::new(); - for (f, cycle) in self.cycles.iter().enumerate() { - let n = cycle.len(); - for k in 0..n { - let (a, b) = (cycle[k], cycle[(k + 1) % n]); - if seen.insert(undirected(a, b)) - && let Some(g) = self.other_face(f, a, b) - { - visit(f, a, b, g); - } - } - } - } -} - -impl Shape { - /// Mints the next never-yet-used face id. - fn fresh_face_id(&mut self) -> FaceId { - let id = self.next_face_id; - self.next_face_id += 1; - id - } - - /// Installs an explicitly-built face list: canonically sort it and refresh derived metrics. - /// Callers that maintain the discovery invariant should follow with `assert_cycles_match_discovery`. - fn install_cycles(&mut self, cycles: Vec>, ids: Vec) { - self.cycles = Cycles::new(cycles, ids); - self.cycles.sort(); - self.recompute_metrics(); - } -} +use std::collections::HashMap; impl Shape { pub fn split_vertex(&mut self, v: VertexId) -> Vec<[usize; 2]> { diff --git a/src/polyhedron/shape/mod.rs b/src/polyhedron/shape/mod.rs index 90103996..829ea2ef 100644 --- a/src/polyhedron/shape/mod.rs +++ b/src/polyhedron/shape/mod.rs @@ -2,6 +2,7 @@ mod conway; mod cycles; mod distance; mod platonic; +mod topology; use std::{fmt::Display, ops::Range}; use cycles::*; @@ -76,6 +77,21 @@ impl Shape { self.springs = self.distance.springs(); } + /// Mints the next never-yet-used face id. + fn fresh_face_id(&mut self) -> FaceId { + let id = self.next_face_id; + self.next_face_id += 1; + id + } + + /// Installs an explicitly-built face list: canonically sort it and refresh derived metrics. + /// Callers that maintain the discovery invariant should follow with `assert_cycles_match_discovery`. + fn install_cycles(&mut self, cycles: Vec>, ids: Vec) { + self.cycles = Cycles::new(cycles, ids); + self.cycles.sort(); + self.recompute_metrics(); + } + /// Debug oracle asserting operation-built cycles equal discovery's, as canonicalized faces in order. /// This replaces the self-healing that per-op rediscovery used to provide. pub fn assert_cycles_match_discovery(&self) { diff --git a/src/polyhedron/shape/topology.rs b/src/polyhedron/shape/topology.rs new file mode 100644 index 00000000..93df055b --- /dev/null +++ b/src/polyhedron/shape/topology.rs @@ -0,0 +1,86 @@ +use super::Cycles; +use crate::polyhedron::{FaceId, VertexId}; +use std::collections::{HashMap, HashSet}; + +/// Canonical (order-independent) key for an undirected edge. +pub(super) fn undirected(a: VertexId, b: VertexId) -> [VertexId; 2] { + if a < b { [a, b] } else { [b, a] } +} + +/// A read-only snapshot of a shape's faces taken at the start of a Conway operation, +/// with the incidence data corner/edge/face operations repeatedly need: +/// - `cycles`/`ids`: the original faces and their stable ids, +/// - `pos`: O(1) lookup of a vertex's index within a given face, +/// - `edge_faces`: the faces bordering each original edge. +/// +/// Operations snapshot once, build their new vertices, then read incidence from here while +/// emitting the new cycle list. Shared by `expand` and `chamfer`; the intent is that future +/// corner-based operations (bevel, ortho, gyro, …) build on the same primitives. +pub(super) struct FaceTopology { + pub(super) cycles: Vec>, + pub(super) ids: Vec, + /// `pos[f][&v]` is the index of vertex `v` within face `f`. + pub(super) pos: Vec>, + /// Undirected original edge → bordering face indices (exactly two on a closed polyhedron). + edge_faces: HashMap<[VertexId; 2], Vec>, +} + +impl FaceTopology { + pub(super) fn snapshot(cycles: &Cycles) -> Self { + let ids = cycles.ids().to_vec(); + let cycles: Vec> = + cycles.iter().map(|c| c.iter().copied().collect()).collect(); + let mut pos: Vec> = Vec::with_capacity(cycles.len()); + let mut edge_faces: HashMap<[VertexId; 2], Vec> = HashMap::new(); + for (f, cycle) in cycles.iter().enumerate() { + let n = cycle.len(); + let mut row = HashMap::with_capacity(n); + for k in 0..n { + row.insert(cycle[k], k); + edge_faces + .entry(undirected(cycle[k], cycle[(k + 1) % n])) + .or_default() + .push(f); + } + pos.push(row); + } + Self { + cycles, + ids, + pos, + edge_faces, + } + } + + /// Index of vertex `v` within face `f`. + pub(super) fn pos(&self, f: usize, v: VertexId) -> usize { + self.pos[f][&v] + } + + /// The face across `edge` from `f`, if `edge` is interior (borders exactly two faces). + pub(super) fn other_face(&self, f: usize, a: VertexId, b: VertexId) -> Option { + let faces = self.edge_faces.get(&undirected(a, b))?; + (faces.len() == 2).then(|| if faces[0] == f { faces[1] } else { faces[0] }) + } + + /// Visits each interior original edge exactly once, in face-then-corner order (so ids minted + /// per edge stay deterministic), yielding the face `f` it was found in, its endpoints `a,b` in + /// `f`'s winding, and the opposite face `g`. + pub(super) fn for_each_interior_edge( + &self, + mut visit: impl FnMut(usize, VertexId, VertexId, usize), + ) { + let mut seen: HashSet<[VertexId; 2]> = HashSet::new(); + for (f, cycle) in self.cycles.iter().enumerate() { + let n = cycle.len(); + for k in 0..n { + let (a, b) = (cycle[k], cycle[(k + 1) % n]); + if seen.insert(undirected(a, b)) + && let Some(g) = self.other_face(f, a, b) + { + visit(f, a, b, g); + } + } + } + } +} From d5298423a428c040f22851fa6b72026197ad0594 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Thu, 23 Jul 2026 16:57:13 -0400 Subject: [PATCH 22/24] cleanup topology --- src/polyhedron/shape/topology.rs | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/polyhedron/shape/topology.rs b/src/polyhedron/shape/topology.rs index 93df055b..e66f74a9 100644 --- a/src/polyhedron/shape/topology.rs +++ b/src/polyhedron/shape/topology.rs @@ -2,26 +2,21 @@ use super::Cycles; use crate::polyhedron::{FaceId, VertexId}; use std::collections::{HashMap, HashSet}; -/// Canonical (order-independent) key for an undirected edge. +/// Order-independent key for an undirected edge. +/// TODO: maybe we should rewrite this as a custom type pub(super) fn undirected(a: VertexId, b: VertexId) -> [VertexId; 2] { if a < b { [a, b] } else { [b, a] } } -/// A read-only snapshot of a shape's faces taken at the start of a Conway operation, -/// with the incidence data corner/edge/face operations repeatedly need: -/// - `cycles`/`ids`: the original faces and their stable ids, -/// - `pos`: O(1) lookup of a vertex's index within a given face, -/// - `edge_faces`: the faces bordering each original edge. -/// -/// Operations snapshot once, build their new vertices, then read incidence from here while -/// emitting the new cycle list. Shared by `expand` and `chamfer`; the intent is that future -/// corner-based operations (bevel, ortho, gyro, …) build on the same primitives. +/// Read-only snapshot of a shape's faces taken at the start of a Conway operation pub(super) struct FaceTopology { + /// The original faces pub(super) cycles: Vec>, + /// The IDs of those original faces pub(super) ids: Vec, - /// `pos[f][&v]` is the index of vertex `v` within face `f`. + /// Vertex `v` in face `f` = pos[f][&v] pub(super) pos: Vec>, - /// Undirected original edge → bordering face indices (exactly two on a closed polyhedron). + /// Undirected original edge bordering face indices edge_faces: HashMap<[VertexId; 2], Vec>, } @@ -57,20 +52,20 @@ impl FaceTopology { self.pos[f][&v] } - /// The face across `edge` from `f`, if `edge` is interior (borders exactly two faces). + /// The face across `edge` from `f`, if `edge` is interior aka borders exactly two faces. pub(super) fn other_face(&self, f: usize, a: VertexId, b: VertexId) -> Option { let faces = self.edge_faces.get(&undirected(a, b))?; (faces.len() == 2).then(|| if faces[0] == f { faces[1] } else { faces[0] }) } - /// Visits each interior original edge exactly once, in face-then-corner order (so ids minted - /// per edge stay deterministic), yielding the face `f` it was found in, its endpoints `a,b` in - /// `f`'s winding, and the opposite face `g`. + /// Visits each interior original edge exactly once in face order then corner order. + /// Yields the face `f` it was found in, its endpoints `a,b` in `f`'s winding, and the opposite face `g`. pub(super) fn for_each_interior_edge( &self, mut visit: impl FnMut(usize, VertexId, VertexId, usize), ) { let mut seen: HashSet<[VertexId; 2]> = HashSet::new(); + // For each face for (f, cycle) in self.cycles.iter().enumerate() { let n = cycle.len(); for k in 0..n { From 39e14ff1d68ce0b6515a4f0a33a9d6294555b005 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Thu, 23 Jul 2026 17:02:25 -0400 Subject: [PATCH 23/24] cleanup --- src/polyhedron/shape/cycles/cycle.rs | 10 ---------- src/polyhedron/shape/cycles/mod.rs | 6 ------ 2 files changed, 16 deletions(-) diff --git a/src/polyhedron/shape/cycles/cycle.rs b/src/polyhedron/shape/cycles/cycle.rs index 2db847df..6850a916 100644 --- a/src/polyhedron/shape/cycles/cycle.rs +++ b/src/polyhedron/shape/cycles/cycle.rs @@ -59,14 +59,4 @@ impl Cycle { pub fn iter(&self) -> std::slice::Iter<'_, usize> { self.0.iter() } - - #[allow(dead_code)] - pub fn contains(&self, v: &VertexId) -> bool { - self.0.contains(v) - } - - #[allow(dead_code)] - pub fn push(&mut self, v: VertexId) { - self.0.push(v); - } } diff --git a/src/polyhedron/shape/cycles/mod.rs b/src/polyhedron/shape/cycles/mod.rs index 9a27ab3e..d90b1388 100644 --- a/src/polyhedron/shape/cycles/mod.rs +++ b/src/polyhedron/shape/cycles/mod.rs @@ -74,7 +74,6 @@ impl Cycles { cycles } - #[allow(dead_code)] pub fn len(&self) -> usize { self.cycles.len() } @@ -82,11 +81,6 @@ impl Cycles { pub fn iter(&self) -> std::slice::Iter<'_, Cycle> { self.cycles.iter() } - - #[allow(dead_code)] - pub fn into_iter(self) -> std::vec::IntoIter { - self.cycles.into_iter() - } /// Returns the pub fn sorted_connections(&self, v: VertexId) -> Vec { // We only care about cycles that contain the vertex From a33e59dec04c0e172c57bd7d10b8c7e3eddf9021 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Thu, 23 Jul 2026 17:07:50 -0400 Subject: [PATCH 24/24] update readme --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2619d46c..6ccb3358 100644 --- a/README.md +++ b/README.md @@ -70,14 +70,14 @@ Rest assured that in due time we will conquer all shapes. - [ ] "Undo" button - [ ] Save and load animations and cycles of `Transaction`s - [x] Schlegel diagrams -- [x] Color pickers +- [ ] Color pickers - [ ] Pokedex entries for polyhedra, point users to wikipedia or polytope wiki when they stumble onto a known entry - - [x] Basic functionality + - [ ] Basic functionality - [ ] Switch from `RON` to `JSON` - [ ] Expand pokedex to include more shapes and improve overlap on isomorphic conway strings - [ ] Fix pokedex on WASM -- [ ] Create WASM deployment and add to website as git submodule - - [ ] Fix `time` on web for `dual` and related transitions - - [x] WebGL compat - - [ ] WebGPU compat +- [ ] Create WASM deployment + - [x] Fix `time` on web for `dual` and related transitions + - [ ] WebGL compat + - [x] WebGPU compat - [x] Setup some basic CI integrations