From a1a31048d6d0cca9d8bbd6b14ebed6c56dd09e79 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 12:58:05 -0400 Subject: [PATCH 01/16] add keybind and selector UI --- assets/main.css | 9 ++++ src/components/menu.rs | 51 +++++++++++++++++- src/main.rs | 32 ++++++++--- src/polyhedron/mod.rs | 87 ++++++++++++++++++++++++------ src/polyhedron/shape/cycles/mod.rs | 68 ++++++++++++++--------- src/render/message.rs | 35 ++++++++++-- src/render/state.rs | 5 +- 7 files changed, 230 insertions(+), 57 deletions(-) diff --git a/assets/main.css b/assets/main.css index cae6495..e94aa40 100644 --- a/assets/main.css +++ b/assets/main.css @@ -26,6 +26,15 @@ body { position: relative; } +.menu-group.top-right { + margin-left: auto; +} + +.menu-group.top-right .dropdown { + left: auto; + right: 0; +} + .menu-btn { background: #374151; padding: 8px 16px; diff --git a/src/components/menu.rs b/src/components/menu.rs index b6e1a1f..643e543 100644 --- a/src/components/menu.rs +++ b/src/components/menu.rs @@ -1,6 +1,9 @@ +use cfg_if::cfg_if; use dioxus::prelude::*; +use polyblade::polyhedron::FaceTypeOption; use polyblade::render::message::{ ConwayMessage, PolybladeMessage, PresetMessage, RenderMessage, push_message, + schlegel_face_options, }; use strum::IntoEnumIterator; @@ -49,10 +52,51 @@ fn SizedPresetMenu(name: String, make: Callback) -> Elemen } } +/// Polls the backend-published Schlegel face-type options. +/// Renders one button per distinct type. +/// Only rendered when schlegel mode is on. #[component] -pub fn MenuBar() -> Element { - let mut schlegel = use_signal(|| false); +fn SchlegelFaceMenu() -> Element { + let mut options = use_signal(Vec::::new); + + use_future(move || async move { + loop { + options.set(schlegel_face_options()); + cfg_if! { + if #[cfg(target_arch = "wasm32")] { + polyblade::next_animation_frame().await; + } else { + tokio::time::sleep(std::time::Duration::from_millis(16)).await; + } + } + } + }); + rsx! { + div { class: "menu-group top-right", + div { class: "menu-btn", "Schlegel Face" } + div { class: "dropdown", + for option in options() { + div { + class: "item", + onclick: move |_| { + push_message( + PolybladeMessage::Render( + RenderMessage::SchlegelFace(option.signature.clone()), + ), + ); + }, + "{option.label}" + span { class: "shortcut", "×{option.count}" } + } + } + } + } + } +} + +#[component] +pub fn MenuBar(mut schlegel: Signal) -> Element { rsx! { div { class: "menu-group", div { class: "menu-btn", "Preset" } @@ -105,5 +149,8 @@ pub fn MenuBar() -> Element { } } } + if schlegel() { + SchlegelFaceMenu {} + } } } diff --git a/src/main.rs b/src/main.rs index 6806654..4bfbfbb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ use cfg_if::cfg_if; use dioxus::prelude::*; -use polyblade::render::message::{ConwayMessage, PolybladeMessage, PresetMessage, push_message}; +use polyblade::render::message::{ + ConwayMessage, PolybladeMessage, PresetMessage, RenderMessage, push_message, +}; mod components; use components::MenuBar; @@ -53,6 +55,8 @@ fn App() -> Element { /// Shared navbar component. Also owns keyboard focus so shortcuts work anywhere. #[component] fn Navbar() -> Element { + let mut schlegel = use_signal(|| false); + rsx! { div { class: "main-div", @@ -61,16 +65,20 @@ fn Navbar() -> Element { onmounted: move |evt| async move { let _ = evt.set_focus(true).await; }, - onkeydown: handle_key, - div { class: "menu-bar", MenuBar {} } + onkeydown: move |evt| handle_key(evt, &mut schlegel), + div { class: "menu-bar", + MenuBar { schlegel } + } Outlet:: {} } } } -/// Shift+letter selects a preset; a bare letter triggers a Conway operation. +/// Shift+letter selects a preset. +/// A bare letter triggers a Conway operation. +/// `x` toggles Schlegel mode. /// Case-insensitive. -fn handle_key(evt: Event) { +fn handle_key(evt: Event, schlegel: &mut Signal) { use dioxus::html::Key; let Key::Character(ch) = evt.key() else { @@ -78,6 +86,14 @@ fn handle_key(evt: Event) { }; let ch = ch.to_lowercase(); + if !evt.modifiers().shift() && ch == "x" { + evt.prevent_default(); + let next = !schlegel(); + schlegel.set(next); + push_message(PolybladeMessage::Render(RenderMessage::Schlegel(next))); + return; + } + let msg = if evt.modifiers().shift() { use PresetMessage::*; match ch.as_str() { @@ -182,9 +198,9 @@ pub fn PolyhedronCanvas() -> Element { } else if #[cfg(feature = "native")] { let paint_id = dioxus_native::use_wgpu(PolybladePaintSource::new); - // Blitz only repaints when the DOM changes, so tick a dummy - // attribute at ~60fps to keep the animation running. Stopgap until - // a proper redraw mechanism is exposed for custom paint sources. + // Blitz only repaints when the DOM changes, + // so tick a dummy attribute at ~60fps to keep the animation running. + // Stopgap until a proper redraw mechanism is exposed for custom paint sources. let mut frame = use_signal(|| 0u64); use_future(move || async move { loop { diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 08a25ff..4f915f3 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -33,9 +33,26 @@ const SCHLEGEL_MIN_EYE_OFFSET: f32 = 0.02; /// Safety margin applied to the computed containment bound, so vertices sit strictly inside face 0. const SCHLEGEL_CONTAINMENT_MARGIN: f32 = 0.9; -/// Depth epsilon for the containment check, scaled to face 0's inradius to avoid flicker. +/// Depth epsilon for the containment check, scaled to the face's inradius to avoid flicker. const SCHLEGEL_DEPTH_EPSILON_FACTOR: f32 = 0.02; +/// Identity of a Schlegel face "type": its side count plus the sorted multiset of its +/// neighboring faces' side counts. Stable across structural changes, unlike a raw face index. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct FaceTypeSignature { + pub side_count: usize, + pub neighbor_sides: Vec, +} + +/// One distinct face "type" available to project through in Schlegel mode. +#[derive(Debug, Clone, PartialEq)] +pub struct FaceTypeOption { + pub face_index: usize, + pub signature: FaceTypeSignature, + pub count: usize, + pub label: String, +} + #[derive(Debug, Clone)] pub struct Polyhedron { /// Conway Polyhedron Notation @@ -276,7 +293,7 @@ impl Polyhedron { } /// Distance from a 2D polygon's local origin to its boundary along a unit direction. - /// Used so containment is measured against face 0's true shape, not a circle approximation. + /// Used so containment is measured against the face's true shape, not a circle approximation. fn polygon_boundary_distance(poly: &[(f32, f32)], dir: (f32, f32)) -> f32 { let n = poly.len(); (0..n) @@ -295,15 +312,50 @@ impl Polyhedron { .fold(f32::MAX, f32::min) } - /// Largest eye_offset for which every vertex still projects inside face 0's true boundary. + /// Groups all faces into distinct "types" by (side_count, neighbor side-count multiset), for + /// the Schlegel outer-face picker. Faces are visited in existing priority order, so the group + /// containing the auto-selected face 0 is always first. + pub fn schlegel_face_options(&self) -> Vec { + let neighbor_sides = self.shape.cycles.neighbor_signatures(); + let mut options: Vec = Vec::new(); + for (face_index, cycle) in self.shape.cycles.iter().enumerate() { + let signature = FaceTypeSignature { + side_count: cycle.len(), + neighbor_sides: neighbor_sides[face_index].clone(), + }; + if let Some(existing) = options.iter_mut().find(|o| o.signature == signature) { + existing.count += 1; + } else { + let label = format!( + "{}-gon ({})", + signature.side_count, + signature + .neighbor_sides + .iter() + .map(usize::to_string) + .collect::>() + .join(",") + ); + options.push(FaceTypeOption { + face_index, + signature, + count: 1, + label, + }); + } + } + options + } + + /// Largest eye_offset for which every vertex still projects inside the chosen face's true boundary. /// The depth epsilon is scaled to `inradius` to avoid flicker from simulation noise. - pub fn schlegel_safe_eye_offset(&self, requested: f32) -> f32 { - let centroid = self.face_centroid(0); - let normal = self.face_normal(0); - let reference = self.render.positions[self.shape.cycles[0][0]] - centroid; + pub fn schlegel_safe_eye_offset(&self, face_index: usize, requested: f32) -> f32 { + let centroid = self.face_centroid(face_index); + let normal = self.face_normal(face_index); + let reference = self.render.positions[self.shape.cycles[face_index][0]] - centroid; let u = reference.normalized(); let v = normal.cross(u).normalized(); - let polygon: Vec<(f32, f32)> = self.shape.cycles[0] + let polygon: Vec<(f32, f32)> = self.shape.cycles[face_index] .iter() .map(|&i| { let q = self.render.positions[i] - centroid; @@ -311,10 +363,11 @@ impl Polyhedron { }) .collect(); - let depth_epsilon = self.face_inradius(0, centroid) * SCHLEGEL_DEPTH_EPSILON_FACTOR; + let depth_epsilon = + self.face_inradius(face_index, centroid) * SCHLEGEL_DEPTH_EPSILON_FACTOR; let bound = self.render.positions.iter().fold(f32::MAX, |bound, &p| { let q = p - centroid; - let depth = -q.dot(normal); // distance behind face 0's plane; convex faces give depth >= 0 + let depth = -q.dot(normal); // distance behind the face's plane; convex faces give depth >= 0 let lateral = q - q.dot(normal) * normal; let lateral_mag = lateral.mag(); if depth <= depth_epsilon || lateral_mag < 1e-6 { @@ -332,18 +385,18 @@ impl Polyhedron { (requested.min(bound * SCHLEGEL_CONTAINMENT_MARGIN)).max(SCHLEGEL_MIN_EYE_OFFSET) } - /// Camera for a Schlegel-diagram view through face 0 at a given eye_offset. - /// Fitting the FOV to face 0's own circumradius alone is sufficient, given `schlegel_safe_eye_offset`. - pub fn schlegel_camera_from_offset(&self, eye_offset: f32) -> Camera { - let centroid = self.face_centroid(0); - let normal = self.face_normal(0); - let reference_vertex = self.render.positions[self.shape.cycles[0][0]]; + /// Camera for a Schlegel-diagram view through `face_index` at a given eye_offset. + /// Fitting the FOV to the face's own circumradius alone is sufficient, given `schlegel_safe_eye_offset`. + pub fn schlegel_camera_from_offset(&self, face_index: usize, eye_offset: f32) -> Camera { + let centroid = self.face_centroid(face_index); + let normal = self.face_normal(face_index); + let reference_vertex = self.render.positions[self.shape.cycles[face_index][0]]; let eye = centroid + normal * eye_offset; let target = centroid - normal; let up = (reference_vertex - centroid).normalized(); - let circumradius = self.shape.cycles[0] + let circumradius = self.shape.cycles[face_index] .iter() .map(|&v| (self.render.positions[v] - centroid).mag()) .fold(0.0_f32, f32::max); diff --git a/src/polyhedron/shape/cycles/mod.rs b/src/polyhedron/shape/cycles/mod.rs index ebbb8c3..539837a 100644 --- a/src/polyhedron/shape/cycles/mod.rs +++ b/src/polyhedron/shape/cycles/mod.rs @@ -101,6 +101,13 @@ impl Cycles { .collect::>>() .concat() } + + /// For each face (by index), the sorted multiset of side-counts of faces sharing an edge + /// with it. Used to group faces into distinct "types" for the Schlegel face picker. + pub fn neighbor_signatures(&self) -> Vec> { + let cycles: Vec> = self.cycles.iter().map(|c| c.0.clone()).collect(); + neighbor_type_signatures(&cycles) + } } impl Index for Cycles { @@ -135,6 +142,39 @@ impl Cycles { } } +/// For each face (by index in `cycles`), the sorted multiset of side-counts of the faces +/// adjacent to it. Shared by the face-priority sort key below and `Cycles::neighbor_signatures`. +fn neighbor_type_signatures(cycles: &[Vec]) -> Vec> { + let mut edge_faces: HashMap<[VertexId; 2], Vec> = HashMap::new(); + for (i, 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(i); + } + } + cycles + .iter() + .enumerate() + .map(|(i, cycle)| { + let n = cycle.len(); + let mut sides: Vec = (0..n) + .filter_map(|k| { + let (a, b) = (cycle[k], cycle[(k + 1) % n]); + let edge = if a < b { [a, b] } else { [b, a] }; + edge_faces[&edge] + .iter() + .find(|&&j| j != i) + .map(|&j| cycles[j].len()) + }) + .collect(); + sides.sort_unstable(); + sides + }) + .collect() +} + impl From<&Distance> for Cycles { fn from(distance: &Distance) -> Self { let mut triplets: Vec> = Default::default(); @@ -179,34 +219,10 @@ impl From<&Distance> for Cycles { let cycles = cycles.into_iter().collect::>(); - // Map each undirected edge to the faces sharing it, to score neighborhood uniformity. - let mut edge_faces: HashMap<[VertexId; 2], Vec> = HashMap::new(); - for (i, 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(i); - } - } // Fewer distinct neighbor side-counts means a more locally symmetric/uniform face. - let neighbor_uniformity: Vec = cycles + let neighbor_uniformity: Vec = neighbor_type_signatures(&cycles) .iter() - .enumerate() - .map(|(i, cycle)| { - let n = cycle.len(); - let types: HashSet = (0..n) - .filter_map(|k| { - let (a, b) = (cycle[k], cycle[(k + 1) % n]); - let edge = if a < b { [a, b] } else { [b, a] }; - edge_faces[&edge] - .iter() - .find(|&&j| j != i) - .map(|&j| cycles[j].len()) - }) - .collect(); - types.len() - }) + .map(|sig| sig.iter().collect::>().len()) .collect(); let mut scored: Vec<(Vec, usize)> = diff --git a/src/render/message.rs b/src/render/message.rs index 90ee79e..3106790 100644 --- a/src/render/message.rs +++ b/src/render/message.rs @@ -1,6 +1,6 @@ use crate::{ Instant, - polyhedron::{Polyhedron, Transaction}, + polyhedron::{FaceTypeOption, FaceTypeSignature, Polyhedron, Transaction}, render::{camera::Camera, color::RGBA}, }; use std::fmt::Display; @@ -28,6 +28,20 @@ pub fn drain_messages() -> Vec { std::mem::take(&mut *MESSAGE_QUEUE.lock().unwrap()) } +/// Distinct Schlegel face-type options for the current polyhedron, republished every tick and +/// polled by the "Schlegel Face" menu. Symmetric to `MESSAGE_QUEUE` above but flowing the +/// opposite direction, since the UI has no other way to observe live polyhedron state. +static SCHLEGEL_FACE_OPTIONS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +pub fn publish_schlegel_face_options(options: Vec) { + *SCHLEGEL_FACE_OPTIONS.lock().unwrap() = options; +} + +pub fn schlegel_face_options() -> Vec { + SCHLEGEL_FACE_OPTIONS.lock().unwrap().clone() +} + #[derive(Debug, Clone, Display)] pub enum PolybladeMessage { Tick(Instant), @@ -113,6 +127,7 @@ pub enum ConwayMessage { #[derive(Debug, Clone)] pub enum RenderMessage { Schlegel(bool), + SchlegelFace(FaceTypeSignature), Rotating(bool), FovChanged(f32), ZoomChanged(f32), @@ -201,6 +216,9 @@ impl ProcessMessage for RenderMessage { state.zoom = 1.0; } } + SchlegelFace(signature) => { + state.schlegel_face = Some(signature.clone()); + } Rotating(rotating) => { state.rotating = *rotating; if !rotating { @@ -262,11 +280,22 @@ impl ProcessMessage for PolybladeMessage { Tick(time) => { state.update_state(*time); + let options = state.model.polyhedron.schlegel_face_options(); + let face_index = state + .render + .schlegel_face + .as_ref() + .and_then(|sig| options.iter().find(|o| &o.signature == sig)) + .or_else(|| options.first()) + .map(|o| o.face_index) + .unwrap_or(0); + publish_schlegel_face_options(options); + if state.render.schlegel { let safe_offset = state .model .polyhedron - .schlegel_safe_eye_offset(state.render.zoom); + .schlegel_safe_eye_offset(face_index, state.render.zoom); // Tighten slowly (damps transient spring-settling skew) but relax quickly. let rate = if safe_offset < state.render.schlegel_eye_offset { SCHLEGEL_TIGHTEN_RATE @@ -279,7 +308,7 @@ impl ProcessMessage for PolybladeMessage { state.render.camera = state .model .polyhedron - .schlegel_camera_from_offset(state.render.schlegel_eye_offset); + .schlegel_camera_from_offset(face_index, state.render.schlegel_eye_offset); } } Preset(preset) => preset.process(&mut state.model), diff --git a/src/render/state.rs b/src/render/state.rs index 6661e8c..e7ca173 100644 --- a/src/render/state.rs +++ b/src/render/state.rs @@ -1,6 +1,6 @@ use crate::{ Instant, - polyhedron::Polyhedron, + polyhedron::{FaceTypeSignature, Polyhedron}, render::{ camera::Camera, color::RGBA, @@ -33,6 +33,8 @@ pub struct RenderState { pub schlegel: bool, /// Smoothed toward the safe eye_offset each tick, to damp single-frame geometry noise. pub schlegel_eye_offset: f32, + /// User-picked outer face type; `None` means use the default (auto-selected) face. + pub schlegel_face: Option, pub line_thickness: f32, pub method: ColorMethodMessage, pub picker: ColorPickerState, @@ -59,6 +61,7 @@ impl Default for RenderState { rotating: true, schlegel: false, schlegel_eye_offset: SCHLEGEL_DEFAULT_EYE_OFFSET, + schlegel_face: None, line_thickness: 2.0, method: ColorMethodMessage::Polygon, picker: ColorPickerState::default(), From 967fac30adb60877cf1ae9f7f3cb765612eae125 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 13:04:33 -0400 Subject: [PATCH 02/16] fix hidden face selection --- assets/tailwind.css | 4 ++++ src/polyhedron/mod.rs | 19 +++++++++++++++++-- src/render/driver.rs | 15 ++++++++------- src/render/message.rs | 1 + src/render/pipeline/mod.rs | 16 ++++++++++++++-- src/render/state.rs | 3 +++ 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/assets/tailwind.css b/assets/tailwind.css index 11e9577..d9be7be 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -600,6 +600,10 @@ video { isolation: isolate; } +.hidden { + display: none; +} + .shrink { flex-shrink: 1; } diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 4f915f3..6c4d2c5 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -69,14 +69,29 @@ impl Polyhedron { pub fn shape_vertices(&self) -> Vec { self.shape.cycles.shape_vertices() } - pub fn starting_vertex(&self) -> VertexId { - match self.shape.cycles[0].len() { + /// Vertex count a face's triangles occupy in the moment/shape vertex buffers, by side count. + fn face_vertex_count(side_count: usize) -> usize { + match side_count { 3 => 3, 4 => 6, n => n * 3, } } + /// The `[start, end)` vertex range a face occupies in the moment/shape vertex buffers + /// (which are laid out in `shape.cycles` order), used to hide it in Schlegel mode. + pub fn face_vertex_range(&self, face_index: usize) -> (u32, u32) { + let start: usize = self + .shape + .cycles + .iter() + .take(face_index) + .map(|c| Self::face_vertex_count(c.len())) + .sum(); + let end = start + Self::face_vertex_count(self.shape.cycles[face_index].len()); + (start as u32, end as u32) + } + pub fn process_transactions(&mut self, _speed: f32) { if let Some(transaction) = self.transactions.first().cloned() { use Transaction::*; diff --git a/src/render/driver.rs b/src/render/driver.rs index 5576ba1..bbda912 100644 --- a/src/render/driver.rs +++ b/src/render/driver.rs @@ -86,14 +86,15 @@ impl RenderDriver { self.state.render.background_color.into(), ); - // Ignore the whole first polygon if we're in schlegel mode - let starting_vertex = if self.state.render.schlegel { - self.state.model.polyhedron.starting_vertex() - } else { - 0 - } as u32; + // Hide the selected outer face's triangles if we're in schlegel mode + let hidden = self.state.render.schlegel.then(|| { + self.state + .model + .polyhedron + .face_vertex_range(self.state.render.schlegel_active_face_index) + }); - self.scene.draw(starting_vertex, &mut render_pass); + self.scene.draw(hidden, &mut render_pass); } queue.submit(Some(encoder.finish())); } diff --git a/src/render/message.rs b/src/render/message.rs index 3106790..1474458 100644 --- a/src/render/message.rs +++ b/src/render/message.rs @@ -289,6 +289,7 @@ impl ProcessMessage for PolybladeMessage { .or_else(|| options.first()) .map(|o| o.face_index) .unwrap_or(0); + state.render.schlegel_active_face_index = face_index; publish_schlegel_face_options(options); if state.render.schlegel { diff --git a/src/render/pipeline/mod.rs b/src/render/pipeline/mod.rs index 84c9f42..925fa3a 100644 --- a/src/render/pipeline/mod.rs +++ b/src/render/pipeline/mod.rs @@ -102,12 +102,24 @@ impl Scene { }) } - pub fn draw(&self, starting_vertex: u32, pass: &mut wgpu::RenderPass<'_>) { + /// Draws every face, except the `[start, end)` vertex range in `hidden`, if given. + pub fn draw(&self, hidden: Option<(u32, u32)>, pass: &mut wgpu::RenderPass<'_>) { pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.uniform_group, &[]); pass.set_vertex_buffer(0, self.moment_buf.raw_slice()); pass.set_vertex_buffer(1, self.shape_buf.raw_slice()); - pass.draw(starting_vertex..self.shape_buf.len() as u32, 0..1); + let len = self.shape_buf.len() as u32; + match hidden { + Some((start, end)) => { + if start > 0 { + pass.draw(0..start, 0..1); + } + if end < len { + pass.draw(end..len, 0..1); + } + } + None => pass.draw(0..len, 0..1), + } } fn build_pipeline( diff --git a/src/render/state.rs b/src/render/state.rs index e7ca173..394dae9 100644 --- a/src/render/state.rs +++ b/src/render/state.rs @@ -35,6 +35,8 @@ pub struct RenderState { pub schlegel_eye_offset: f32, /// User-picked outer face type; `None` means use the default (auto-selected) face. pub schlegel_face: Option, + /// Face index resolved from `schlegel_face` this tick; the one hidden when drawing. + pub schlegel_active_face_index: usize, pub line_thickness: f32, pub method: ColorMethodMessage, pub picker: ColorPickerState, @@ -62,6 +64,7 @@ impl Default for RenderState { schlegel: false, schlegel_eye_offset: SCHLEGEL_DEFAULT_EYE_OFFSET, schlegel_face: None, + schlegel_active_face_index: 0, line_thickness: 2.0, method: ColorMethodMessage::Polygon, picker: ColorPickerState::default(), From e1e7b635e6510bfb0cfd9f89ffa328a2429f30a1 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 13:41:43 -0400 Subject: [PATCH 03/16] use face type signature for color --- src/polyhedron/mod.rs | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 6c4d2c5..34ab07e 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -38,7 +38,7 @@ const SCHLEGEL_DEPTH_EPSILON_FACTOR: f32 = 0.02; /// Identity of a Schlegel face "type": its side count plus the sorted multiset of its /// neighboring faces' side counts. Stable across structural changes, unlike a raw face index. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct FaceTypeSignature { pub side_count: usize, pub neighbor_sides: Vec, @@ -440,19 +440,33 @@ impl Polyhedron { pub fn moment_vertices(&self, colors: &[crate::render::color::RGBA]) -> Vec { let Polyhedron { shape, render, .. } = self; - // Polygon side count -> color - let color_map: HashMap = - shape.cycles.iter().fold(HashMap::new(), |mut acc, c| { - if !acc.contains_key(&c.len()) { - acc.insert(c.len(), colors[acc.len() % colors.len()].into()); - } - acc - }); + // Face type (side count + neighbor multiset) -> color. + // Signatures are sorted into a canonical order before assignment so the mapping is deterministic. + // This is not dependent on cycle iteration order, and distinct types sharing a side count get distinct colors. + let neighbor_sides = shape.cycles.neighbor_signatures(); + let signatures: Vec = shape + .cycles + .iter() + .enumerate() + .map(|(i, c)| FaceTypeSignature { + side_count: c.len(), + neighbor_sides: neighbor_sides[i].clone(), + }) + .collect(); + let mut distinct = signatures.clone(); + distinct.sort(); + distinct.dedup(); + let color_map: HashMap = distinct + .into_iter() + .enumerate() + .map(|(i, sig)| (sig, colors[i % colors.len()].into())) + .collect(); shape .cycles .iter() - .flat_map(|cycle| { + .enumerate() + .flat_map(|(i, cycle)| { let positions: Vec = match cycle.len() { 3 => cycle.iter().map(|&i| render.positions[i]).collect(), 4 => [0, 1, 2, 2, 3, 0] @@ -478,8 +492,7 @@ impl Polyhedron { } }; - // Colors are determined by cycle length - let color = color_map[&cycle.len()]; + let color = color_map[&signatures[i]]; // Map into MomentVertices positions .into_iter() From 35b7968121f0a602d1b2e74c8dfe0535973335bf Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 13:46:02 -0400 Subject: [PATCH 04/16] reverse --- src/polyhedron/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 34ab07e..fbc57ac 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -456,6 +456,7 @@ impl Polyhedron { let mut distinct = signatures.clone(); distinct.sort(); distinct.dedup(); + distinct.reverse(); let color_map: HashMap = distinct .into_iter() .enumerate() From 7779d05bf97f8e116a4a1bed4aa8cf0c6743e77d Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 15:48:13 -0400 Subject: [PATCH 05/16] coloring logic --- assets/tailwind.css | 4 + src/polyhedron/mod.rs | 184 ++++++++++++++++++++---- src/polyhedron/platonic.rs | 13 +- src/polyhedron/shape/conway.rs | 5 +- src/polyhedron/shape/distance/conway.rs | 5 +- src/polyhedron/shape/distance/mod.rs | 61 +++++++- src/polyhedron/shape/mod.rs | 19 ++- src/polyhedron/test.rs | 118 +++++++++++++++ 8 files changed, 374 insertions(+), 35 deletions(-) diff --git a/assets/tailwind.css b/assets/tailwind.css index d9be7be..255f477 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -596,6 +596,10 @@ video { position: fixed; } +.relative { + position: relative; +} + .isolate { isolation: isolate; } diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index fbc57ac..2da1b51 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -10,7 +10,7 @@ pub use transaction::*; #[cfg(test)] mod test; -use std::{collections::HashMap, time::Duration}; +use std::{collections::HashSet, time::Duration}; use crate::Instant; use crate::render::{ @@ -63,6 +63,11 @@ pub struct Polyhedron { pub render: Render, /// Transaction queue pub transactions: Vec, + /// Palette-relative color slot per current face, parallel to `shape.cycles`. + face_colors: Vec, + /// Next never-yet-used color slot, monotonically increasing so concurrently created + /// face groups (e.g. expand's new triangles vs. new squares) get visually distinct colors. + next_color_slot: usize, } impl Polyhedron { @@ -97,23 +102,23 @@ impl Polyhedron { use Transaction::*; match transaction { Contraction(edges) => { - let Polyhedron { - shape, - render, - transactions, - .. - } = self; - let all_completed = !edges .iter() - .map(|&[v, u]| render.spring_length([v, u])) + .map(|&[v, u]| self.render.spring_length([v, u])) .any(|l| l > 0.05); if all_completed { + let old_face_ancestors: Vec> = (0..self.shape.cycles.len()) + .map(|i| self.shape.face_ancestors(i)) + .collect(); + let old_face_colors = self.face_colors.clone(); + // Contract them in the graph - shape.contract_edges(edges.clone()); - render.contract_edges(edges); - transactions.remove(0); + self.shape.contract_edges(edges.clone()); + self.render.contract_edges(edges); + self.transactions.remove(0); + + self.reconcile_face_colors(&old_face_ancestors, &old_face_colors); } } Release(edges) => { @@ -124,6 +129,12 @@ impl Polyhedron { self.transactions.remove(0); use ConwayMessage::*; use Transaction::*; + + let old_face_ancestors: Vec> = (0..self.shape.cycles.len()) + .map(|i| self.shape.face_ancestors(i)) + .collect(); + let old_face_colors = self.face_colors.clone(); + let new_transactions = match conway { Dual => { // let edges = self.expand(false); @@ -197,6 +208,8 @@ impl Polyhedron { }; self.render.new_capacity(self.shape.order()); self.transactions = [new_transactions, self.transactions.clone()].concat(); + + self.reconcile_face_colors(&old_face_ancestors, &old_face_colors); } Name(c) => { if c == 'b' { @@ -437,14 +450,10 @@ impl Polyhedron { } } - pub fn moment_vertices(&self, colors: &[crate::render::color::RGBA]) -> Vec { - let Polyhedron { shape, render, .. } = self; - - // Face type (side count + neighbor multiset) -> color. - // Signatures are sorted into a canonical order before assignment so the mapping is deterministic. - // This is not dependent on cycle iteration order, and distinct types sharing a side count get distinct colors. - let neighbor_sides = shape.cycles.neighbor_signatures(); - let signatures: Vec = shape + /// Per-face identity (side count + neighbor multiset), in current `shape.cycles` order. + fn face_signatures(&self) -> Vec { + let neighbor_sides = self.shape.cycles.neighbor_signatures(); + self.shape .cycles .iter() .enumerate() @@ -452,16 +461,137 @@ impl Polyhedron { side_count: c.len(), neighbor_sides: neighbor_sides[i].clone(), }) - .collect(); + .collect() + } + + /// Fresh, no-history color assignment: distinct signatures sorted into a canonical order + /// (deterministic, not dependent on cycle iteration order), one slot per distinct class so + /// types sharing a side count still get distinct colors. Used when there's no prior shape + /// to preserve color continuity from (construction time). + fn bootstrap_face_colors(&self) -> (Vec, usize) { + let signatures = self.face_signatures(); let mut distinct = signatures.clone(); distinct.sort(); distinct.dedup(); - distinct.reverse(); - let color_map: HashMap = distinct - .into_iter() - .enumerate() - .map(|(i, sig)| (sig, colors[i % colors.len()].into())) + let next_color_slot = distinct.len(); + let face_colors = signatures + .iter() + .map(|sig| distinct.iter().position(|d| d == sig).unwrap()) .collect(); + (face_colors, next_color_slot) + } + + /// Matches each current face against a pre-mutation snapshot of every face's vertex + /// ancestry, as a one-to-one assignment (strongest match claims its old face first) rather + /// than each new face picking independently. Ranked by Jaccard similarity (intersection over + /// union), not raw overlap count: contracting an edge unions its two endpoints' ancestry into + /// the survivor, so ancestry keeps compounding over repeated operations, and a face whose + /// ancestry has grown large (e.g. Ambo's vertex-figure faces, built from several contractions) + /// can end up *containing* an unrelated old face's entire (smaller) ancestor set — a raw + /// overlap count tied with, or exceeding, a genuine exact match elsewhere. Jaccard correctly + /// scores that containment lower than a true match (where the two sets are equal, Jaccard 1.0), + /// since the inflated set's much larger union size dilutes the score. + /// + /// Per-face matching alone only usually agrees within a facetype, though — nothing + /// structurally guarantees it. So faces are then grouped by `FaceTypeSignature` and each + /// group takes a majority vote over what its members individually matched, applying the + /// winner (a fresh color if "no match" wins) to every member uniformly. Every face of the + /// same facetype is therefore guaranteed the same color, not just usually consistent. + fn reconcile_face_colors( + &mut self, + old_face_ancestors: &[HashSet], + old_face_colors: &[usize], + ) { + let signatures = self.face_signatures(); + let new_ancestors: Vec> = (0..self.shape.cycles.len()) + .map(|i| self.shape.face_ancestors(i)) + .collect(); + + // (new_face, old_face, intersection, union) per candidate pair with any overlap. + let mut candidates: Vec<(usize, usize, usize, usize)> = Vec::new(); + for (i, ancestors) in new_ancestors.iter().enumerate() { + for (j, old) in old_face_ancestors.iter().enumerate() { + let intersection = old.intersection(ancestors).count(); + if intersection > 0 { + let union = old.union(ancestors).count(); + candidates.push((i, j, intersection, union)); + } + } + } + // Rank by Jaccard similarity (descending), breaking ties by raw overlap count. + candidates.sort_by(|&(_, _, ia, ua), &(_, _, ib, ub)| { + 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)) + }); + + let mut matched_color: Vec> = vec![None; new_ancestors.len()]; + let mut old_claimed = vec![false; old_face_ancestors.len()]; + for (i, j, ..) in candidates { + if matched_color[i].is_none() && !old_claimed[j] { + matched_color[i] = Some(old_face_colors[j]); + old_claimed[j] = true; + } + } + + // Group by facetype and take a majority vote, so every face of the same facetype ends + // up with the same color regardless of any individual-match noise. + let mut groups: Vec<(FaceTypeSignature, Vec)> = Vec::new(); + for (i, sig) in signatures.iter().enumerate() { + match groups.iter_mut().find(|(s, _)| s == sig) { + Some((_, members)) => members.push(i), + None => groups.push((sig.clone(), vec![i])), + } + } + + let mut new_colors = vec![0; new_ancestors.len()]; + for (_, members) in &groups { + let mut votes: Vec<(Option, usize)> = Vec::new(); + for &i in members { + match votes.iter_mut().find(|(v, _)| *v == matched_color[i]) { + Some((_, count)) => *count += 1, + None => votes.push((matched_color[i], 1)), + } + } + let winner = votes.iter().max_by_key(|(_, count)| *count).unwrap().0; + + 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; + } + } + + self.face_colors = new_colors; + // Bound ancestor-set growth to one operation's worth of information (see + // `Distance::reset_ancestry`) rather than letting it accumulate across the whole session. + self.shape.reset_ancestry(); + } + + /// Maps `face_colors`'s persistent (possibly large/sparse, since it only ever grows via + /// `next_color_slot`) identity values down to a dense render index (0, 1, 2, ...) over just + /// the distinct values present right now. `face_colors` alone is not safe to index into a + /// palette directly: two facetypes whose identity values happen to be congruent mod + /// `colors.len()` (entirely possible once `next_color_slot` has grown past the palette size + /// over several structural changes) would render identically even with unused palette + /// entries free. Recomputed fresh whenever colors are needed rather than cached, so it can + /// never go stale relative to `face_colors`. + fn render_color_indices(&self) -> Vec { + let mut distinct = self.face_colors.clone(); + distinct.sort_unstable(); + distinct.dedup(); + self.face_colors + .iter() + .map(|slot| distinct.binary_search(slot).unwrap()) + .collect() + } + + pub fn moment_vertices(&self, colors: &[crate::render::color::RGBA]) -> Vec { + let render_colors = self.render_color_indices(); + let Polyhedron { shape, render, .. } = self; shape .cycles @@ -493,7 +623,7 @@ impl Polyhedron { } }; - let color = color_map[&signatures[i]]; + let color: Vec4 = colors[render_colors[i] % colors.len()].into(); // Map into MomentVertices positions .into_iter() diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs index b312d90..cbe2878 100644 --- a/src/polyhedron/platonic.rs +++ b/src/polyhedron/platonic.rs @@ -4,7 +4,7 @@ use PresetMessage::*; impl Polyhedron { pub fn preset(preset: &PresetMessage) -> Polyhedron { use PresetMessage::*; - match preset { + let mut polyhedron = match preset { Octahedron => Self::octahedron(), Dodecahedron => todo!(), Icosahedron => Self::icosahedron(), @@ -23,9 +23,18 @@ impl Polyhedron { shape, render, transactions: vec![], + face_colors: vec![], + next_color_slot: 0, } } - } + }; + // Bootstrapping is really "reconciling from nothing" — reset ancestry to a fresh + // singleton tag per vertex too, so a preset built out of real Conway-style mutations + // (e.g. `octahedron()` internally ambos a tetrahedron) doesn't leak that construction's + // ancestry into whatever the user does next (see `Distance::reset_ancestry`). + polyhedron.shape.reset_ancestry(); + (polyhedron.face_colors, polyhedron.next_color_slot) = polyhedron.bootstrap_face_colors(); + polyhedron } fn octahedron() -> Polyhedron { diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs index 968c989..ab5afbc 100644 --- a/src/polyhedron/shape/conway.rs +++ b/src/polyhedron/shape/conway.rs @@ -34,7 +34,8 @@ impl Shape { .collect(); for cycle in cycles { - let v = self.distance.insert(); + let parents: Vec = cycle.iter().copied().collect(); + let v = self.distance.insert_from(&parents); // let mut vpos = Vec3::zero(); for &u in cycle.iter() { @@ -54,7 +55,7 @@ impl Shape { for cycle in self.cycles.iter() { let mut new_face = vec![]; for &v in cycle.iter() { - let u = self.distance.insert(); + let u = self.distance.insert_from(&[v]); new_face.push(u); self.distance.connect([v, u]); } diff --git a/src/polyhedron/shape/distance/conway.rs b/src/polyhedron/shape/distance/conway.rs index 7df71cb..b6c0ce7 100644 --- a/src/polyhedron/shape/distance/conway.rs +++ b/src/polyhedron/shape/distance/conway.rs @@ -9,6 +9,9 @@ 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 self.delete(v); } @@ -39,7 +42,7 @@ impl Distance { let new_cycle: Cycle = Cycle::from( vec![v] .into_iter() - .chain((1..connections.len()).map(|_| self.insert())) + .chain((1..connections.len()).map(|_| self.insert_from(&[v]))) .collect(), ); diff --git a/src/polyhedron/shape/distance/mod.rs b/src/polyhedron/shape/distance/mod.rs index fd12be9..2df22f7 100644 --- a/src/polyhedron/shape/distance/mod.rs +++ b/src/polyhedron/shape/distance/mod.rs @@ -15,11 +15,24 @@ use std::{ /// usize::MAX -> disconnected /// 0 -> identity /// n -> actual distance -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone)] pub(super) struct Distance { /// The order is the number of vertices order: usize, distance: Vec, + /// Set of persistent tags each vertex descends from. Propagated (not reassigned) across + /// splits/merges: unioned into the survivor on a merge, copied as-is to every copy on a + /// split, so a face's ancestry survives regardless of which specific vertex copy it ends + /// up with. + ancestors: Vec>, + /// Next never-yet-used tag, for genuinely new vertices only. + next_tag: u64, +} + +impl PartialEq for Distance { + fn eq(&self, other: &Self) -> bool { + self.order == other.order && self.distance == other.distance + } } impl Distance { @@ -34,6 +47,8 @@ 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, } } } @@ -53,14 +68,48 @@ impl Distance { } } - /// Inserts a new vertex in the matrix + /// Inserts a new, genuinely-unrelated vertex in the matrix, with a fresh ancestor tag. 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/continuation of `parents`, inheriting the union of + /// their ancestor sets rather than minting a fresh tag. + 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] + } + + /// Wipes all vertex ancestry back to a fresh singleton tag per current vertex. Matching only + /// ever needs to compare the current state against one operation ago, never deeper — so + /// resetting right after every successful color reconciliation (and at construction, which + /// is really reconciling from nothing) bounds ancestor-set size to the current vertex count + /// instead of letting it accumulate, unbounded, across the whole session's operation + /// history. Left unbounded, a handful of contractions is enough to saturate a vertex's + /// ancestor set to the entire tag universe, at which point otherwise-distinct faces become + /// combinatorially 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); @@ -73,6 +122,14 @@ 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 ae033ab..0755837 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::{fmt::Display, ops::Range}; +use std::{collections::HashSet, fmt::Display, ops::Range}; use cycles::*; use distance::*; @@ -58,6 +58,23 @@ impl Shape { self.distance.vertices() } + /// Union of a face's vertices' ancestor sets, used to match it against a pre-mutation + /// snapshot when Conway ops change which faces exist. + pub 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 + }) + } + + /// Wipes vertex ancestry back to a fresh singleton tag per current vertex. See + /// `Distance::reset_ancestry` for why this needs to happen after every color reconciliation. + pub fn reset_ancestry(&mut self) { + self.distance.reset_ancestry(); + } + pub fn recompute(&mut self) { // Update the distance matrix in place self.distance.bfs_apsp(); diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index 2f10a1a..85c2e21 100644 --- a/src/polyhedron/test.rs +++ b/src/polyhedron/test.rs @@ -1,5 +1,6 @@ use super::*; use crate::render::message::PresetMessage::{self, *}; +use std::collections::HashSet; use std::fs::create_dir_all; // @@ -43,3 +44,120 @@ fn ambo() { let octahedron = Polyhedron::preset(&Octahedron); assert_eq!(polyhedron.shape, octahedron.shape); } + +/// Applies an Ambo synchronously (matching how `ambo`/`truncate_contract` above bypass the +/// tick/transaction queue, since spring-convergence timing isn't relevant here), bracketed with +/// the same ancestry snapshot + reconcile calls `process_transactions` performs at its two hook +/// points. +fn apply_ambo(polyhedron: &mut Polyhedron) { + let old_face_ancestors: Vec> = (0..polyhedron.shape.cycles.len()) + .map(|i| polyhedron.shape.face_ancestors(i)) + .collect(); + let old_face_colors = polyhedron.face_colors.clone(); + polyhedron.ambo_contract(); + polyhedron.reconcile_face_colors(&old_face_ancestors, &old_face_colors); +} + +/// Every face sharing a `FaceTypeSignature` must share a color. +fn assert_uniform_colors_per_facetype(polyhedron: &Polyhedron) { + let signatures = polyhedron.face_signatures(); + let mut seen: Vec<(FaceTypeSignature, usize)> = Vec::new(); + for (i, sig) in signatures.iter().enumerate() { + let color = polyhedron.face_colors[i]; + match seen.iter().find(|(s, _)| s == sig) { + Some((_, expected)) => { + assert_eq!( + color, *expected, + "faces of signature {sig:?} have inconsistent colors" + ) + } + None => seen.push((sig.clone(), color)), + } + } +} + +/// 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 + .iter() + .position(|sig| sig == target) + .unwrap_or_else(|| panic!("no face with signature {target:?}")); + polyhedron.face_colors[i] +} + +/// Ambo-ing a cube twice ("aaC") exercises exactly the scenario facetype-level consensus is for: +/// the cuboctahedron's triangles keep an unchanged signature into the rhombicuboctahedron and +/// must keep their color; the cuboctahedron's squares gain a new signature (their neighbors +/// change from triangles to the new vertex-figure squares) but must still color-continue via +/// ancestry; and the new vertex-figure squares are a genuinely new facetype needing a color +/// distinct from both. +#[test] +fn ambo_twice_preserves_facetype_colors() { + let mut polyhedron = Polyhedron::preset(&Prism(4)); // cube ("C") + + apply_ambo(&mut polyhedron); // -> cuboctahedron + assert_uniform_colors_per_facetype(&polyhedron); + + let triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![4, 4, 4], + }; + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 3, 3], + }; + let triangle_color = color_for_signature(&polyhedron, &triangle); + let square_color = color_for_signature(&polyhedron, &square); + assert_ne!(triangle_color, square_color); + + apply_ambo(&mut polyhedron); // -> rhombicuboctahedron + assert_uniform_colors_per_facetype(&polyhedron); + + // Unchanged signature; must keep its color. + assert_eq!(color_for_signature(&polyhedron, &triangle), triangle_color); + + // New signature (bordered by the new vertex-figure squares instead of triangles), but must + // still color-continue from the old squares via ancestry. + let shrunk_square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![4, 4, 4, 4], + }; + assert_eq!( + color_for_signature(&polyhedron, &shrunk_square), + square_color + ); + + // Genuinely new facetype: must be distinct from both persisting facetypes' colors. + let vertex_figure_square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 4, 4], + }; + let vertex_figure_color = color_for_signature(&polyhedron, &vertex_figure_square); + assert_ne!(vertex_figure_color, triangle_color); + assert_ne!(vertex_figure_color, square_color); +} + +/// Ambo-ing an octahedron ("aO") also produces a cuboctahedron (triangles + squares), just via a +/// different construction path than aC: `Polyhedron::octahedron()` is itself built by ambo'ing a +/// tetrahedron at construction time, so the octahedron's own vertices already carry ancestry +/// accumulated from that construction before the user ever applies an Ambo to it. +#[test] +fn ambo_octahedron_gives_distinct_facetype_colors() { + let mut polyhedron = Polyhedron::preset(&Octahedron); + apply_ambo(&mut polyhedron); // -> cuboctahedron + assert_uniform_colors_per_facetype(&polyhedron); + + let triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![4, 4, 4], + }; + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 3, 3], + }; + assert_ne!( + color_for_signature(&polyhedron, &triangle), + color_for_signature(&polyhedron, &square) + ); +} From 6d8f3355bde99d394751791c310e6446d37af17b Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 15:58:05 -0400 Subject: [PATCH 06/16] simplify comments --- src/components/menu.rs | 5 ++- src/main.rs | 7 ++-- src/polyhedron/mod.rs | 54 +++++++--------------------- src/polyhedron/platonic.rs | 6 ++-- src/polyhedron/shape/cycles/mod.rs | 6 ++-- src/polyhedron/shape/distance/mod.rs | 18 +++------- src/polyhedron/shape/mod.rs | 6 ++-- src/render/message.rs | 5 ++- 8 files changed, 29 insertions(+), 78 deletions(-) diff --git a/src/components/menu.rs b/src/components/menu.rs index 643e543..b3c12a0 100644 --- a/src/components/menu.rs +++ b/src/components/menu.rs @@ -52,9 +52,8 @@ fn SizedPresetMenu(name: String, make: Callback) -> Elemen } } -/// Polls the backend-published Schlegel face-type options. -/// Renders one button per distinct type. -/// Only rendered when schlegel mode is on. +/// Polls the backend-published Schlegel face-type options, rendering one button per type. +/// Only rendered when Schlegel mode is on. #[component] fn SchlegelFaceMenu() -> Element { let mut options = use_signal(Vec::::new); diff --git a/src/main.rs b/src/main.rs index 4bfbfbb..245aa27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -74,9 +74,7 @@ fn Navbar() -> Element { } } -/// Shift+letter selects a preset. -/// A bare letter triggers a Conway operation. -/// `x` toggles Schlegel mode. +/// Shift+letter selects a preset, a bare letter triggers a Conway op, and `x` toggles Schlegel mode. /// Case-insensitive. fn handle_key(evt: Event, schlegel: &mut Signal) { use dioxus::html::Key; @@ -198,8 +196,7 @@ pub fn PolyhedronCanvas() -> Element { } else if #[cfg(feature = "native")] { let paint_id = dioxus_native::use_wgpu(PolybladePaintSource::new); - // Blitz only repaints when the DOM changes, - // so tick a dummy attribute at ~60fps to keep the animation running. + // Blitz only repaints when the DOM changes, so tick a dummy attribute at ~60fps to animate. // Stopgap until a proper redraw mechanism is exposed for custom paint sources. let mut frame = use_signal(|| 0u64); use_future(move || async move { diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 2da1b51..b15a7d6 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -36,8 +36,8 @@ 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; -/// Identity of a Schlegel face "type": its side count plus the sorted multiset of its -/// neighboring faces' side counts. Stable across structural changes, unlike a raw face index. +/// A face's "type": side count plus its neighbors' sorted side-count multiset. +/// Stable across structural changes, unlike a raw face index. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct FaceTypeSignature { pub side_count: usize, @@ -65,8 +65,7 @@ pub struct Polyhedron { pub transactions: Vec, /// Palette-relative color slot per current face, parallel to `shape.cycles`. face_colors: Vec, - /// Next never-yet-used color slot, monotonically increasing so concurrently created - /// face groups (e.g. expand's new triangles vs. new squares) get visually distinct colors. + /// Next unused color slot; monotonically increasing so new facetypes get distinct colors. next_color_slot: usize, } @@ -83,8 +82,7 @@ impl Polyhedron { } } - /// The `[start, end)` vertex range a face occupies in the moment/shape vertex buffers - /// (which are laid out in `shape.cycles` order), used to hide it in Schlegel mode. + /// A face's `[start, end)` vertex range in the vertex buffers, for hiding it in Schlegel mode. pub fn face_vertex_range(&self, face_index: usize) -> (u32, u32) { let start: usize = self .shape @@ -340,9 +338,8 @@ impl Polyhedron { .fold(f32::MAX, f32::min) } - /// Groups all faces into distinct "types" by (side_count, neighbor side-count multiset), for - /// the Schlegel outer-face picker. Faces are visited in existing priority order, so the group - /// containing the auto-selected face 0 is always first. + /// Groups all faces into distinct "types" by (side_count, neighbor side-count multiset). + /// Faces are visited in priority order, so the group containing face 0 is always first. pub fn schlegel_face_options(&self) -> Vec { let neighbor_sides = self.shape.cycles.neighbor_signatures(); let mut options: Vec = Vec::new(); @@ -464,10 +461,8 @@ impl Polyhedron { .collect() } - /// Fresh, no-history color assignment: distinct signatures sorted into a canonical order - /// (deterministic, not dependent on cycle iteration order), one slot per distinct class so - /// types sharing a side count still get distinct colors. Used when there's no prior shape - /// to preserve color continuity from (construction time). + /// Fresh, no-history color assignment: one slot per distinct signature, sorted canonically. + /// Used when there's no prior shape to preserve continuity from (construction time). fn bootstrap_face_colors(&self) -> (Vec, usize) { let signatures = self.face_signatures(); let mut distinct = signatures.clone(); @@ -481,22 +476,8 @@ impl Polyhedron { (face_colors, next_color_slot) } - /// Matches each current face against a pre-mutation snapshot of every face's vertex - /// ancestry, as a one-to-one assignment (strongest match claims its old face first) rather - /// than each new face picking independently. Ranked by Jaccard similarity (intersection over - /// union), not raw overlap count: contracting an edge unions its two endpoints' ancestry into - /// the survivor, so ancestry keeps compounding over repeated operations, and a face whose - /// ancestry has grown large (e.g. Ambo's vertex-figure faces, built from several contractions) - /// can end up *containing* an unrelated old face's entire (smaller) ancestor set — a raw - /// overlap count tied with, or exceeding, a genuine exact match elsewhere. Jaccard correctly - /// scores that containment lower than a true match (where the two sets are equal, Jaccard 1.0), - /// since the inflated set's much larger union size dilutes the score. - /// - /// Per-face matching alone only usually agrees within a facetype, though — nothing - /// structurally guarantees it. So faces are then grouped by `FaceTypeSignature` and each - /// group takes a majority vote over what its members individually matched, applying the - /// winner (a fresh color if "no match" wins) to every member uniformly. Every face of the - /// same facetype is therefore guaranteed the same color, not just usually consistent. + /// Matches faces to a pre-mutation ancestry snapshot by Jaccard similarity, not raw overlap (which can let an inflated ancestor set beat a true match). + /// Results are then majority-voted per `FaceTypeSignature` to guarantee one color per facetype. fn reconcile_face_colors( &mut self, old_face_ancestors: &[HashSet], @@ -534,8 +515,7 @@ impl Polyhedron { } } - // Group by facetype and take a majority vote, so every face of the same facetype ends - // up with the same color regardless of any individual-match noise. + // Group by facetype and majority-vote one color per group. let mut groups: Vec<(FaceTypeSignature, Vec)> = Vec::new(); for (i, sig) in signatures.iter().enumerate() { match groups.iter_mut().find(|(s, _)| s == sig) { @@ -566,19 +546,11 @@ impl Polyhedron { } self.face_colors = new_colors; - // Bound ancestor-set growth to one operation's worth of information (see - // `Distance::reset_ancestry`) rather than letting it accumulate across the whole session. + // Reset ancestry now so it never accumulates past one operation (see `Distance::reset_ancestry`). self.shape.reset_ancestry(); } - /// Maps `face_colors`'s persistent (possibly large/sparse, since it only ever grows via - /// `next_color_slot`) identity values down to a dense render index (0, 1, 2, ...) over just - /// the distinct values present right now. `face_colors` alone is not safe to index into a - /// palette directly: two facetypes whose identity values happen to be congruent mod - /// `colors.len()` (entirely possible once `next_color_slot` has grown past the palette size - /// over several structural changes) would render identically even with unused palette - /// entries free. Recomputed fresh whenever colors are needed rather than cached, so it can - /// never go stale relative to `face_colors`. + /// Maps `face_colors`'s ever-growing values to a dense render index, so two facetypes never collide merely by being congruent mod `colors.len()`. fn render_color_indices(&self) -> Vec { let mut distinct = self.face_colors.clone(); distinct.sort_unstable(); diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs index cbe2878..bd98bff 100644 --- a/src/polyhedron/platonic.rs +++ b/src/polyhedron/platonic.rs @@ -28,10 +28,8 @@ impl Polyhedron { } } }; - // Bootstrapping is really "reconciling from nothing" — reset ancestry to a fresh - // singleton tag per vertex too, so a preset built out of real Conway-style mutations - // (e.g. `octahedron()` internally ambos a tetrahedron) doesn't leak that construction's - // ancestry into whatever the user does next (see `Distance::reset_ancestry`). + // 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(); (polyhedron.face_colors, polyhedron.next_color_slot) = polyhedron.bootstrap_face_colors(); polyhedron diff --git a/src/polyhedron/shape/cycles/mod.rs b/src/polyhedron/shape/cycles/mod.rs index 539837a..d2742f4 100644 --- a/src/polyhedron/shape/cycles/mod.rs +++ b/src/polyhedron/shape/cycles/mod.rs @@ -102,8 +102,7 @@ impl Cycles { .concat() } - /// For each face (by index), the sorted multiset of side-counts of faces sharing an edge - /// with it. Used to group faces into distinct "types" for the Schlegel face picker. + /// For each face, the sorted multiset of its edge-adjacent neighbors' side-counts. pub fn neighbor_signatures(&self) -> Vec> { let cycles: Vec> = self.cycles.iter().map(|c| c.0.clone()).collect(); neighbor_type_signatures(&cycles) @@ -142,8 +141,7 @@ impl Cycles { } } -/// For each face (by index in `cycles`), the sorted multiset of side-counts of the faces -/// adjacent to it. Shared by the face-priority sort key below and `Cycles::neighbor_signatures`. +/// For each face, the sorted multiset of adjacent side-counts; shared by the sort key below and `Cycles::neighbor_signatures`. fn neighbor_type_signatures(cycles: &[Vec]) -> Vec> { let mut edge_faces: HashMap<[VertexId; 2], Vec> = HashMap::new(); for (i, cycle) in cycles.iter().enumerate() { diff --git a/src/polyhedron/shape/distance/mod.rs b/src/polyhedron/shape/distance/mod.rs index 2df22f7..9b1edff 100644 --- a/src/polyhedron/shape/distance/mod.rs +++ b/src/polyhedron/shape/distance/mod.rs @@ -20,10 +20,7 @@ pub(super) struct Distance { /// The order is the number of vertices order: usize, distance: Vec, - /// Set of persistent tags each vertex descends from. Propagated (not reassigned) across - /// splits/merges: unioned into the survivor on a merge, copied as-is to every copy on a - /// split, so a face's ancestry survives regardless of which specific vertex copy it ends - /// up with. + /// 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, @@ -76,8 +73,7 @@ impl Distance { v } - /// Inserts a new vertex that's a copy/continuation of `parents`, inheriting the union of - /// their ancestor sets rather than minting a fresh tag. + /// 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()); @@ -95,14 +91,8 @@ impl Distance { &self.ancestors[v] } - /// Wipes all vertex ancestry back to a fresh singleton tag per current vertex. Matching only - /// ever needs to compare the current state against one operation ago, never deeper — so - /// resetting right after every successful color reconciliation (and at construction, which - /// is really reconciling from nothing) bounds ancestor-set size to the current vertex count - /// instead of letting it accumulate, unbounded, across the whole session's operation - /// history. Left unbounded, a handful of contractions is enough to saturate a vertex's - /// ancestor set to the entire tag universe, at which point otherwise-distinct faces become - /// combinatorially indistinguishable by ancestry alone. + /// 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])) diff --git a/src/polyhedron/shape/mod.rs b/src/polyhedron/shape/mod.rs index 0755837..67e290c 100644 --- a/src/polyhedron/shape/mod.rs +++ b/src/polyhedron/shape/mod.rs @@ -58,8 +58,7 @@ impl Shape { self.distance.vertices() } - /// Union of a face's vertices' ancestor sets, used to match it against a pre-mutation - /// snapshot when Conway ops change which faces exist. + /// Union of a face's vertices' ancestor sets. pub fn face_ancestors(&self, face_index: usize) -> HashSet { self.cycles[face_index] .iter() @@ -69,8 +68,7 @@ impl Shape { }) } - /// Wipes vertex ancestry back to a fresh singleton tag per current vertex. See - /// `Distance::reset_ancestry` for why this needs to happen after every color reconciliation. + /// 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(); } diff --git a/src/render/message.rs b/src/render/message.rs index 1474458..8722f22 100644 --- a/src/render/message.rs +++ b/src/render/message.rs @@ -28,9 +28,8 @@ pub fn drain_messages() -> Vec { std::mem::take(&mut *MESSAGE_QUEUE.lock().unwrap()) } -/// Distinct Schlegel face-type options for the current polyhedron, republished every tick and -/// polled by the "Schlegel Face" menu. Symmetric to `MESSAGE_QUEUE` above but flowing the -/// opposite direction, since the UI has no other way to observe live polyhedron state. +/// Distinct Schlegel face-type options, republished every tick and polled by the face menu. +/// Symmetric to `MESSAGE_QUEUE` above but flowing the opposite direction (backend to UI). static SCHLEGEL_FACE_OPTIONS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); From 22d60026f4bf0a5337bf803cc9d1919f0332a612 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 15:59:31 -0400 Subject: [PATCH 07/16] version bump --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 091f439..b8804f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "polyblade" -version = "0.3.3" +version = "0.3.4" edition = "2024" description = "Make shapes dance." readme = "README.md" From 988e9359bc6ff5f388e311be2cb21fbc6e59755a Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 16:08:51 -0400 Subject: [PATCH 08/16] version --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 00d687b..19d2bb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5018,7 +5018,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyblade" -version = "0.3.3" +version = "0.3.4" dependencies = [ "bytemuck", "cfg-if", From c4019e671a4ea378e395269374838e69fde4f21f Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 16:21:40 -0400 Subject: [PATCH 09/16] nits --- src/polyhedron/test.rs | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index 85c2e21..8ec102b 100644 --- a/src/polyhedron/test.rs +++ b/src/polyhedron/test.rs @@ -45,10 +45,6 @@ fn ambo() { assert_eq!(polyhedron.shape, octahedron.shape); } -/// Applies an Ambo synchronously (matching how `ambo`/`truncate_contract` above bypass the -/// tick/transaction queue, since spring-convergence timing isn't relevant here), bracketed with -/// the same ancestry snapshot + reconcile calls `process_transactions` performs at its two hook -/// points. fn apply_ambo(polyhedron: &mut Polyhedron) { let old_face_ancestors: Vec> = (0..polyhedron.shape.cycles.len()) .map(|i| polyhedron.shape.face_ancestors(i)) @@ -86,17 +82,13 @@ fn color_for_signature(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> u polyhedron.face_colors[i] } -/// Ambo-ing a cube twice ("aaC") exercises exactly the scenario facetype-level consensus is for: -/// the cuboctahedron's triangles keep an unchanged signature into the rhombicuboctahedron and -/// must keep their color; the cuboctahedron's squares gain a new signature (their neighbors -/// change from triangles to the new vertex-figure squares) but must still color-continue via -/// ancestry; and the new vertex-figure squares are a genuinely new facetype needing a color -/// distinct from both. #[test] fn ambo_twice_preserves_facetype_colors() { - let mut polyhedron = Polyhedron::preset(&Prism(4)); // cube ("C") + // cube ("C") + let mut polyhedron = Polyhedron::preset(&Prism(4)); - apply_ambo(&mut polyhedron); // -> cuboctahedron + // cuboctahedron ("aC") + apply_ambo(&mut polyhedron); assert_uniform_colors_per_facetype(&polyhedron); let triangle = FaceTypeSignature { @@ -111,14 +103,14 @@ fn ambo_twice_preserves_facetype_colors() { let square_color = color_for_signature(&polyhedron, &square); assert_ne!(triangle_color, square_color); - apply_ambo(&mut polyhedron); // -> rhombicuboctahedron + // rhombicuboctahedron ("aaC") + apply_ambo(&mut polyhedron); assert_uniform_colors_per_facetype(&polyhedron); // Unchanged signature; must keep its color. assert_eq!(color_for_signature(&polyhedron, &triangle), triangle_color); - // New signature (bordered by the new vertex-figure squares instead of triangles), but must - // still color-continue from the old squares via ancestry. + // New signature but must still color-continue from the old squares via ancestry. let shrunk_square = FaceTypeSignature { side_count: 4, neighbor_sides: vec![4, 4, 4, 4], @@ -128,7 +120,7 @@ fn ambo_twice_preserves_facetype_colors() { square_color ); - // Genuinely new facetype: must be distinct from both persisting facetypes' colors. + // Genuinely new facetype must be distinct from both persisting facetypes' colors. let vertex_figure_square = FaceTypeSignature { side_count: 4, neighbor_sides: vec![3, 3, 4, 4], @@ -138,14 +130,13 @@ fn ambo_twice_preserves_facetype_colors() { assert_ne!(vertex_figure_color, square_color); } -/// Ambo-ing an octahedron ("aO") also produces a cuboctahedron (triangles + squares), just via a -/// different construction path than aC: `Polyhedron::octahedron()` is itself built by ambo'ing a -/// tetrahedron at construction time, so the octahedron's own vertices already carry ancestry -/// accumulated from that construction before the user ever applies an Ambo to it. #[test] fn ambo_octahedron_gives_distinct_facetype_colors() { + // octahedron ("O") let mut polyhedron = Polyhedron::preset(&Octahedron); - apply_ambo(&mut polyhedron); // -> cuboctahedron + + // cuboctahedron ("aO") + apply_ambo(&mut polyhedron); assert_uniform_colors_per_facetype(&polyhedron); let triangle = FaceTypeSignature { From 30f644f4325ad6650912aa8a70ab261d6ca51afe Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 16:38:26 -0400 Subject: [PATCH 10/16] simplify --- src/components/menu.rs | 5 ++++- src/polyhedron/mod.rs | 34 ++++++++++++++++------------------ src/polyhedron/platonic.rs | 2 ++ src/render/message.rs | 24 ++++++++++++------------ 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/components/menu.rs b/src/components/menu.rs index b3c12a0..89ee796 100644 --- a/src/components/menu.rs +++ b/src/components/menu.rs @@ -60,7 +60,10 @@ fn SchlegelFaceMenu() -> Element { use_future(move || async move { loop { - options.set(schlegel_face_options()); + let new_options = schlegel_face_options(); + if new_options != *options.peek() { + options.set(new_options); + } cfg_if! { if #[cfg(target_arch = "wasm32")] { polyblade::next_animation_frame().await; diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index b15a7d6..7381fa9 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -67,6 +67,19 @@ pub struct Polyhedron { face_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 `face_colors`; kept in sync wherever `face_colors` is set. + render_color_indices: Vec, +} + +/// Maps `face_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(face_colors: &[usize]) -> Vec { + let mut distinct = face_colors.to_vec(); + distinct.sort_unstable(); + distinct.dedup(); + face_colors + .iter() + .map(|slot| distinct.binary_search(slot).unwrap()) + .collect() } impl Polyhedron { @@ -341,13 +354,8 @@ impl Polyhedron { /// Groups all faces into distinct "types" by (side_count, neighbor side-count multiset). /// Faces are visited in priority order, so the group containing face 0 is always first. pub fn schlegel_face_options(&self) -> Vec { - let neighbor_sides = self.shape.cycles.neighbor_signatures(); let mut options: Vec = Vec::new(); - for (face_index, cycle) in self.shape.cycles.iter().enumerate() { - let signature = FaceTypeSignature { - side_count: cycle.len(), - neighbor_sides: neighbor_sides[face_index].clone(), - }; + for (face_index, signature) in self.face_signatures().into_iter().enumerate() { if let Some(existing) = options.iter_mut().find(|o| o.signature == signature) { existing.count += 1; } else { @@ -546,23 +554,13 @@ impl Polyhedron { } self.face_colors = new_colors; + self.render_color_indices = dense_color_indices(&self.face_colors); // Reset ancestry now so it never accumulates past one operation (see `Distance::reset_ancestry`). self.shape.reset_ancestry(); } - /// Maps `face_colors`'s ever-growing values to a dense render index, so two facetypes never collide merely by being congruent mod `colors.len()`. - fn render_color_indices(&self) -> Vec { - let mut distinct = self.face_colors.clone(); - distinct.sort_unstable(); - distinct.dedup(); - self.face_colors - .iter() - .map(|slot| distinct.binary_search(slot).unwrap()) - .collect() - } - pub fn moment_vertices(&self, colors: &[crate::render::color::RGBA]) -> Vec { - let render_colors = self.render_color_indices(); + let render_colors = &self.render_color_indices; let Polyhedron { shape, render, .. } = self; shape diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs index bd98bff..cb40dee 100644 --- a/src/polyhedron/platonic.rs +++ b/src/polyhedron/platonic.rs @@ -25,6 +25,7 @@ impl Polyhedron { transactions: vec![], face_colors: vec![], next_color_slot: 0, + render_color_indices: vec![], } } }; @@ -32,6 +33,7 @@ impl Polyhedron { // Otherwise e.g. octahedron's internal construction-time ambo leaks into the user's first op. polyhedron.shape.reset_ancestry(); (polyhedron.face_colors, polyhedron.next_color_slot) = polyhedron.bootstrap_face_colors(); + polyhedron.render_color_indices = dense_color_indices(&polyhedron.face_colors); polyhedron } diff --git a/src/render/message.rs b/src/render/message.rs index 8722f22..baead70 100644 --- a/src/render/message.rs +++ b/src/render/message.rs @@ -279,19 +279,19 @@ impl ProcessMessage for PolybladeMessage { Tick(time) => { state.update_state(*time); - let options = state.model.polyhedron.schlegel_face_options(); - let face_index = state - .render - .schlegel_face - .as_ref() - .and_then(|sig| options.iter().find(|o| &o.signature == sig)) - .or_else(|| options.first()) - .map(|o| o.face_index) - .unwrap_or(0); - state.render.schlegel_active_face_index = face_index; - publish_schlegel_face_options(options); - if state.render.schlegel { + let options = state.model.polyhedron.schlegel_face_options(); + let face_index = state + .render + .schlegel_face + .as_ref() + .and_then(|sig| options.iter().find(|o| &o.signature == sig)) + .or_else(|| options.first()) + .map(|o| o.face_index) + .unwrap_or(0); + state.render.schlegel_active_face_index = face_index; + publish_schlegel_face_options(options); + let safe_offset = state .model .polyhedron From 78f87bb0ba49d1de81d0865b34c44c6a292ab584 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 16:43:51 -0400 Subject: [PATCH 11/16] dead code --- src/polyhedron/shape/distance/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/polyhedron/shape/distance/mod.rs b/src/polyhedron/shape/distance/mod.rs index 9b1edff..d57917f 100644 --- a/src/polyhedron/shape/distance/mod.rs +++ b/src/polyhedron/shape/distance/mod.rs @@ -66,6 +66,8 @@ 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)] pub fn insert(&mut self) -> VertexId { let v = self.insert_from(&[]); self.ancestors[v].insert(self.next_tag); From 057ede14d81a8c830d590b15a6153840336948d6 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 17:36:45 -0400 Subject: [PATCH 12/16] refactor face cache --- assets/tailwind.css | 4 -- src/components/menu.rs | 2 +- src/polyhedron/face.rs | 24 +++++++++++ src/polyhedron/mod.rs | 85 ++++++++++++++----------------------- src/polyhedron/platonic.rs | 11 ++--- src/polyhedron/render.rs | 13 ++++++ src/polyhedron/shape/mod.rs | 8 +++- src/polyhedron/test.rs | 12 ++---- src/render/message.rs | 5 ++- src/render/state.rs | 2 +- 10 files changed, 91 insertions(+), 75 deletions(-) create mode 100644 src/polyhedron/face.rs diff --git a/assets/tailwind.css b/assets/tailwind.css index 255f477..d9be7be 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -596,10 +596,6 @@ video { position: fixed; } -.relative { - position: relative; -} - .isolate { isolation: isolate; } diff --git a/src/components/menu.rs b/src/components/menu.rs index 89ee796..aadfa74 100644 --- a/src/components/menu.rs +++ b/src/components/menu.rs @@ -1,6 +1,6 @@ use cfg_if::cfg_if; use dioxus::prelude::*; -use polyblade::polyhedron::FaceTypeOption; +use polyblade::polyhedron::face::FaceTypeOption; use polyblade::render::message::{ ConwayMessage, PolybladeMessage, PresetMessage, RenderMessage, push_message, schlegel_face_options, diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs new file mode 100644 index 0000000..0939feb --- /dev/null +++ b/src/polyhedron/face.rs @@ -0,0 +1,24 @@ +use std::collections::HashSet; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct FaceCache { + pub ancestors: Vec>, + pub colors: Vec, +} + +/// A face's "type": side count plus its neighbors' sorted side-count multiset. +/// Stable across structural changes, unlike a raw face index. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct FaceTypeSignature { + pub side_count: usize, + pub neighbor_sides: Vec, +} + +/// One distinct face "type" available to project through in Schlegel mode. +#[derive(Debug, Clone, PartialEq)] +pub struct FaceTypeOption { + pub face_index: usize, + pub signature: FaceTypeSignature, + pub count: usize, + pub label: String, +} diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 7381fa9..75234db 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -1,4 +1,5 @@ mod conway; +pub mod face; mod platonic; mod render; mod shape; @@ -10,9 +11,10 @@ pub use transaction::*; #[cfg(test)] mod test; -use std::{collections::HashSet, time::Duration}; +use std::time::Duration; use crate::Instant; +use crate::polyhedron::face::{FaceCache, FaceTypeOption, FaceTypeSignature}; use crate::render::{ camera::Camera, message::{ConwayMessage, PresetMessage}, @@ -36,23 +38,6 @@ 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; -/// A face's "type": side count plus its neighbors' sorted side-count multiset. -/// Stable across structural changes, unlike a raw face index. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct FaceTypeSignature { - pub side_count: usize, - pub neighbor_sides: Vec, -} - -/// One distinct face "type" available to project through in Schlegel mode. -#[derive(Debug, Clone, PartialEq)] -pub struct FaceTypeOption { - pub face_index: usize, - pub signature: FaceTypeSignature, - pub count: usize, - pub label: String, -} - #[derive(Debug, Clone)] pub struct Polyhedron { /// Conway Polyhedron Notation @@ -63,12 +48,6 @@ pub struct Polyhedron { pub render: Render, /// Transaction queue pub transactions: Vec, - /// Palette-relative color slot per current face, parallel to `shape.cycles`. - face_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 `face_colors`; kept in sync wherever `face_colors` is set. - render_color_indices: Vec, } /// Maps `face_colors`'s ever-growing values to a dense render index, so two facetypes never collide merely by being congruent mod `colors.len()`. @@ -94,7 +73,6 @@ impl Polyhedron { n => n * 3, } } - /// A face's `[start, end)` vertex range in the vertex buffers, for hiding it in Schlegel mode. pub fn face_vertex_range(&self, face_index: usize) -> (u32, u32) { let start: usize = self @@ -108,6 +86,13 @@ impl Polyhedron { (start as u32, end as u32) } + pub fn cache_faces(&mut self) { + self.render.face_cache = FaceCache { + ancestors: self.shape.ancestors(), + colors: self.render.face_colors.clone(), + }; + } + pub fn process_transactions(&mut self, _speed: f32) { if let Some(transaction) = self.transactions.first().cloned() { use Transaction::*; @@ -119,17 +104,14 @@ impl Polyhedron { .any(|l| l > 0.05); if all_completed { - let old_face_ancestors: Vec> = (0..self.shape.cycles.len()) - .map(|i| self.shape.face_ancestors(i)) - .collect(); - let old_face_colors = self.face_colors.clone(); + 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(&old_face_ancestors, &old_face_colors); + self.reconcile_face_colors(); } } Release(edges) => { @@ -141,10 +123,7 @@ impl Polyhedron { use ConwayMessage::*; use Transaction::*; - let old_face_ancestors: Vec> = (0..self.shape.cycles.len()) - .map(|i| self.shape.face_ancestors(i)) - .collect(); - let old_face_colors = self.face_colors.clone(); + self.cache_faces(); let new_transactions = match conway { Dual => { @@ -217,10 +196,11 @@ impl Polyhedron { ] } }; + self.render.new_capacity(self.shape.order()); self.transactions = [new_transactions, self.transactions.clone()].concat(); - self.reconcile_face_colors(&old_face_ancestors, &old_face_colors); + self.reconcile_face_colors(); } Name(c) => { if c == 'b' { @@ -486,20 +466,17 @@ impl Polyhedron { /// Matches faces to a pre-mutation ancestry snapshot by Jaccard similarity, not raw overlap (which can let an inflated ancestor set beat a true match). /// Results are then majority-voted per `FaceTypeSignature` to guarantee one color per facetype. - fn reconcile_face_colors( - &mut self, - old_face_ancestors: &[HashSet], - old_face_colors: &[usize], - ) { + fn reconcile_face_colors(&mut self) { + let old = self.render.face_cache.clone(); + self.cache_faces(); + let new = self.render.face_cache.clone(); + let signatures = self.face_signatures(); - let new_ancestors: Vec> = (0..self.shape.cycles.len()) - .map(|i| self.shape.face_ancestors(i)) - .collect(); // (new_face, old_face, intersection, union) per candidate pair with any overlap. let mut candidates: Vec<(usize, usize, usize, usize)> = Vec::new(); - for (i, ancestors) in new_ancestors.iter().enumerate() { - for (j, old) in old_face_ancestors.iter().enumerate() { + for (i, ancestors) in new.ancestors.iter().enumerate() { + for (j, old) in old.ancestors.iter().enumerate() { let intersection = old.intersection(ancestors).count(); if intersection > 0 { let union = old.union(ancestors).count(); @@ -514,11 +491,11 @@ impl Polyhedron { jaccard_b.total_cmp(&jaccard_a).then(ib.cmp(&ia)) }); - let mut matched_color: Vec> = vec![None; new_ancestors.len()]; - let mut old_claimed = vec![false; old_face_ancestors.len()]; + let mut matched_color: Vec> = vec![None; new.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_face_colors[j]); + matched_color[i] = Some(old.colors[j]); old_claimed[j] = true; } } @@ -532,7 +509,7 @@ impl Polyhedron { } } - let mut new_colors = vec![0; new_ancestors.len()]; + let mut new_colors = vec![0; new.ancestors.len()]; for (_, members) in &groups { let mut votes: Vec<(Option, usize)> = Vec::new(); for &i in members { @@ -544,8 +521,8 @@ impl Polyhedron { let winner = votes.iter().max_by_key(|(_, count)| *count).unwrap().0; let color = winner.unwrap_or_else(|| { - let slot = self.next_color_slot; - self.next_color_slot += 1; + let slot = self.render.next_color_slot; + self.render.next_color_slot += 1; slot }); for &i in members { @@ -553,14 +530,14 @@ impl Polyhedron { } } - self.face_colors = new_colors; - self.render_color_indices = dense_color_indices(&self.face_colors); + self.render.face_colors = new_colors; + self.render.render_color_indices = dense_color_indices(&self.render.face_colors); // Reset ancestry now so it never accumulates past one operation (see `Distance::reset_ancestry`). self.shape.reset_ancestry(); } pub fn moment_vertices(&self, colors: &[crate::render::color::RGBA]) -> Vec { - let render_colors = &self.render_color_indices; + let render_colors = &self.render.render_color_indices; let Polyhedron { shape, render, .. } = self; shape diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs index cb40dee..03c8b52 100644 --- a/src/polyhedron/platonic.rs +++ b/src/polyhedron/platonic.rs @@ -23,17 +23,18 @@ impl Polyhedron { shape, render, transactions: vec![], - face_colors: vec![], - next_color_slot: 0, - render_color_indices: vec![], } } }; // 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(); - (polyhedron.face_colors, polyhedron.next_color_slot) = polyhedron.bootstrap_face_colors(); - polyhedron.render_color_indices = dense_color_indices(&polyhedron.face_colors); + ( + polyhedron.render.face_colors, + polyhedron.render.next_color_slot, + ) = polyhedron.bootstrap_face_colors(); + polyhedron.render.render_color_indices = + dense_color_indices(&polyhedron.render.face_colors); polyhedron } diff --git a/src/polyhedron/render.rs b/src/polyhedron/render.rs index 99baa44..90b629e 100644 --- a/src/polyhedron/render.rs +++ b/src/polyhedron/render.rs @@ -1,6 +1,8 @@ use rand::random; use ultraviolet::{Lerp as _, Vec3}; +use crate::polyhedron::face::FaceCache; + use super::{SPEED_DAMPENING, VertexId}; #[derive(Debug, Clone)] @@ -11,6 +13,13 @@ pub struct Render { pub speeds: Vec, /// Edge length pub edge_length: f32, + /// Palette-relative color slot per current face, parallel to `shape.cycles`. + pub face_colors: Vec, + /// Next unused color slot; monotonically increasing so new facetypes get distinct colors. + pub next_color_slot: usize, + /// Dense render index per face, derived from `face_colors`; kept in sync wherever `face_colors` is set. + pub render_color_indices: Vec, + pub face_cache: FaceCache, } //impl rand:: @@ -26,6 +35,10 @@ impl Render { positions: random_positions(n), speeds: vec![Vec3::zero(); n], edge_length: 1.0, + face_colors: vec![], + next_color_slot: 0, + render_color_indices: vec![], + face_cache: Default::default(), } } diff --git a/src/polyhedron/shape/mod.rs b/src/polyhedron/shape/mod.rs index 67e290c..c6f9541 100644 --- a/src/polyhedron/shape/mod.rs +++ b/src/polyhedron/shape/mod.rs @@ -59,7 +59,7 @@ impl Shape { } /// Union of a face's vertices' ancestor sets. - pub fn face_ancestors(&self, face_index: usize) -> HashSet { + fn face_ancestors(&self, face_index: usize) -> HashSet { self.cycles[face_index] .iter() .fold(HashSet::new(), |mut acc, &v| { @@ -68,6 +68,12 @@ impl Shape { }) } + 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(); diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index 8ec102b..6735ef5 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::collections::HashSet; use std::fs::create_dir_all; // @@ -46,12 +45,9 @@ fn ambo() { } fn apply_ambo(polyhedron: &mut Polyhedron) { - let old_face_ancestors: Vec> = (0..polyhedron.shape.cycles.len()) - .map(|i| polyhedron.shape.face_ancestors(i)) - .collect(); - let old_face_colors = polyhedron.face_colors.clone(); + polyhedron.cache_faces(); polyhedron.ambo_contract(); - polyhedron.reconcile_face_colors(&old_face_ancestors, &old_face_colors); + polyhedron.reconcile_face_colors(); } /// Every face sharing a `FaceTypeSignature` must share a color. @@ -59,7 +55,7 @@ fn assert_uniform_colors_per_facetype(polyhedron: &Polyhedron) { let signatures = polyhedron.face_signatures(); let mut seen: Vec<(FaceTypeSignature, usize)> = Vec::new(); for (i, sig) in signatures.iter().enumerate() { - let color = polyhedron.face_colors[i]; + let color = polyhedron.render.face_colors[i]; match seen.iter().find(|(s, _)| s == sig) { Some((_, expected)) => { assert_eq!( @@ -79,7 +75,7 @@ fn color_for_signature(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> u .iter() .position(|sig| sig == target) .unwrap_or_else(|| panic!("no face with signature {target:?}")); - polyhedron.face_colors[i] + polyhedron.render.face_colors[i] } #[test] diff --git a/src/render/message.rs b/src/render/message.rs index baead70..181e14f 100644 --- a/src/render/message.rs +++ b/src/render/message.rs @@ -1,6 +1,9 @@ use crate::{ Instant, - polyhedron::{FaceTypeOption, FaceTypeSignature, Polyhedron, Transaction}, + polyhedron::{ + Polyhedron, Transaction, + face::{FaceTypeOption, FaceTypeSignature}, + }, render::{camera::Camera, color::RGBA}, }; use std::fmt::Display; diff --git a/src/render/state.rs b/src/render/state.rs index 394dae9..420482e 100644 --- a/src/render/state.rs +++ b/src/render/state.rs @@ -1,6 +1,6 @@ use crate::{ Instant, - polyhedron::{FaceTypeSignature, Polyhedron}, + polyhedron::{Polyhedron, face::FaceTypeSignature}, render::{ camera::Camera, color::RGBA, From 3641b3e51597527b71deaf47e84530e96de1a698 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 17:48:10 -0400 Subject: [PATCH 13/16] refactor FaceColoring --- src/polyhedron/face.rs | 109 +++++++++++++++++++++++++++++++++++++ src/polyhedron/mod.rs | 90 +++--------------------------- src/polyhedron/platonic.rs | 9 +-- src/polyhedron/render.rs | 13 ----- src/polyhedron/test.rs | 4 +- 5 files changed, 121 insertions(+), 104 deletions(-) diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs index 0939feb..fe22a4e 100644 --- a/src/polyhedron/face.rs +++ b/src/polyhedron/face.rs @@ -6,6 +6,19 @@ pub struct FaceCache { pub colors: Vec, } +/// 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`. + pub colors: Vec, + /// Next unused color slot; monotonically increasing so new facetypes get distinct colors. + pub next_color_slot: usize, + /// Dense render index per face, derived from `colors`; kept in sync wherever `colors` is set. + pub render_indices: Vec, + /// Pre-mutation snapshot of ancestors/colors, used to reconcile colors across a structural change. + pub cache: FaceCache, +} + /// A face's "type": side count plus its neighbors' sorted side-count multiset. /// Stable across structural changes, unlike a raw face index. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -22,3 +35,99 @@ pub struct FaceTypeOption { pub count: usize, pub label: String, } + +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>) { + self.cache = FaceCache { + ancestors, + colors: self.colors.clone(), + }; + } + + /// 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) { + self.colors = colors; + self.next_color_slot = next_color_slot; + self.render_indices = dense_color_indices(&self.colors); + } + + /// Matches faces to a pre-mutation ancestry snapshot by Jaccard similarity, not raw overlap (which can let an inflated ancestor set beat a true match). + /// Results are then majority-voted per `FaceTypeSignature` to guarantee one color per facetype. + pub fn reconcile(&mut self, ancestors: Vec>, signatures: &[FaceTypeSignature]) { + let old = self.cache.clone(); + self.snapshot(ancestors); + let new = self.cache.clone(); + + // (new_face, old_face, intersection, union) per candidate pair with any overlap. + let mut candidates: Vec<(usize, usize, usize, usize)> = Vec::new(); + for (i, ancestors) in new.ancestors.iter().enumerate() { + for (j, old) in old.ancestors.iter().enumerate() { + let intersection = old.intersection(ancestors).count(); + if intersection > 0 { + let union = old.union(ancestors).count(); + candidates.push((i, j, intersection, union)); + } + } + } + // Rank by Jaccard similarity (descending), breaking ties by raw overlap count. + candidates.sort_by(|&(_, _, ia, ua), &(_, _, ib, ub)| { + 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)) + }); + + let mut matched_color: Vec> = vec![None; new.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; + } + } + + // Group by facetype and majority-vote one color per group. + let mut groups: Vec<(FaceTypeSignature, Vec)> = Vec::new(); + for (i, sig) in signatures.iter().enumerate() { + match groups.iter_mut().find(|(s, _)| s == sig) { + Some((_, members)) => members.push(i), + None => groups.push((sig.clone(), vec![i])), + } + } + + let mut new_colors = vec![0; new.ancestors.len()]; + for (_, members) in &groups { + let mut votes: Vec<(Option, usize)> = Vec::new(); + for &i in members { + match votes.iter_mut().find(|(v, _)| *v == matched_color[i]) { + Some((_, count)) => *count += 1, + None => votes.push((matched_color[i], 1)), + } + } + let winner = votes.iter().max_by_key(|(_, count)| *count).unwrap().0; + + 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; + } + } + + self.colors = new_colors; + self.render_indices = dense_color_indices(&self.colors); + } +} + +/// 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() +} diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 75234db..0a4c5ab 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -14,7 +14,7 @@ mod test; use std::time::Duration; use crate::Instant; -use crate::polyhedron::face::{FaceCache, FaceTypeOption, FaceTypeSignature}; +use crate::polyhedron::face::{FaceColoring, FaceTypeOption, FaceTypeSignature}; use crate::render::{ camera::Camera, message::{ConwayMessage, PresetMessage}, @@ -48,17 +48,8 @@ pub struct Polyhedron { pub render: Render, /// Transaction queue pub transactions: Vec, -} - -/// Maps `face_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(face_colors: &[usize]) -> Vec { - let mut distinct = face_colors.to_vec(); - distinct.sort_unstable(); - distinct.dedup(); - face_colors - .iter() - .map(|slot| distinct.binary_search(slot).unwrap()) - .collect() + /// Per-face color bookkeeping. + pub face_coloring: FaceColoring, } impl Polyhedron { @@ -87,10 +78,7 @@ impl Polyhedron { } pub fn cache_faces(&mut self) { - self.render.face_cache = FaceCache { - ancestors: self.shape.ancestors(), - colors: self.render.face_colors.clone(), - }; + self.face_coloring.snapshot(self.shape.ancestors()); } pub fn process_transactions(&mut self, _speed: f32) { @@ -464,80 +452,16 @@ impl Polyhedron { (face_colors, next_color_slot) } - /// Matches faces to a pre-mutation ancestry snapshot by Jaccard similarity, not raw overlap (which can let an inflated ancestor set beat a true match). - /// Results are then majority-voted per `FaceTypeSignature` to guarantee one color per facetype. fn reconcile_face_colors(&mut self) { - let old = self.render.face_cache.clone(); - self.cache_faces(); - let new = self.render.face_cache.clone(); - + let ancestors = self.shape.ancestors(); let signatures = self.face_signatures(); - - // (new_face, old_face, intersection, union) per candidate pair with any overlap. - let mut candidates: Vec<(usize, usize, usize, usize)> = Vec::new(); - for (i, ancestors) in new.ancestors.iter().enumerate() { - for (j, old) in old.ancestors.iter().enumerate() { - let intersection = old.intersection(ancestors).count(); - if intersection > 0 { - let union = old.union(ancestors).count(); - candidates.push((i, j, intersection, union)); - } - } - } - // Rank by Jaccard similarity (descending), breaking ties by raw overlap count. - candidates.sort_by(|&(_, _, ia, ua), &(_, _, ib, ub)| { - 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)) - }); - - let mut matched_color: Vec> = vec![None; new.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; - } - } - - // Group by facetype and majority-vote one color per group. - let mut groups: Vec<(FaceTypeSignature, Vec)> = Vec::new(); - for (i, sig) in signatures.iter().enumerate() { - match groups.iter_mut().find(|(s, _)| s == sig) { - Some((_, members)) => members.push(i), - None => groups.push((sig.clone(), vec![i])), - } - } - - let mut new_colors = vec![0; new.ancestors.len()]; - for (_, members) in &groups { - let mut votes: Vec<(Option, usize)> = Vec::new(); - for &i in members { - match votes.iter_mut().find(|(v, _)| *v == matched_color[i]) { - Some((_, count)) => *count += 1, - None => votes.push((matched_color[i], 1)), - } - } - let winner = votes.iter().max_by_key(|(_, count)| *count).unwrap().0; - - let color = winner.unwrap_or_else(|| { - let slot = self.render.next_color_slot; - self.render.next_color_slot += 1; - slot - }); - for &i in members { - new_colors[i] = color; - } - } - - self.render.face_colors = new_colors; - self.render.render_color_indices = dense_color_indices(&self.render.face_colors); + self.face_coloring.reconcile(ancestors, &signatures); // Reset ancestry now so it never accumulates past one operation (see `Distance::reset_ancestry`). self.shape.reset_ancestry(); } pub fn moment_vertices(&self, colors: &[crate::render::color::RGBA]) -> Vec { - let render_colors = &self.render.render_color_indices; + let render_colors = &self.face_coloring.render_indices; let Polyhedron { shape, render, .. } = self; shape diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs index 03c8b52..21a9ef4 100644 --- a/src/polyhedron/platonic.rs +++ b/src/polyhedron/platonic.rs @@ -23,18 +23,15 @@ impl Polyhedron { shape, render, transactions: vec![], + face_coloring: FaceColoring::default(), } } }; // 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(); - ( - polyhedron.render.face_colors, - polyhedron.render.next_color_slot, - ) = polyhedron.bootstrap_face_colors(); - polyhedron.render.render_color_indices = - dense_color_indices(&polyhedron.render.face_colors); + let (colors, next_color_slot) = polyhedron.bootstrap_face_colors(); + polyhedron.face_coloring.bootstrap(colors, next_color_slot); polyhedron } diff --git a/src/polyhedron/render.rs b/src/polyhedron/render.rs index 90b629e..99baa44 100644 --- a/src/polyhedron/render.rs +++ b/src/polyhedron/render.rs @@ -1,8 +1,6 @@ use rand::random; use ultraviolet::{Lerp as _, Vec3}; -use crate::polyhedron::face::FaceCache; - use super::{SPEED_DAMPENING, VertexId}; #[derive(Debug, Clone)] @@ -13,13 +11,6 @@ pub struct Render { pub speeds: Vec, /// Edge length pub edge_length: f32, - /// Palette-relative color slot per current face, parallel to `shape.cycles`. - pub face_colors: Vec, - /// Next unused color slot; monotonically increasing so new facetypes get distinct colors. - pub next_color_slot: usize, - /// Dense render index per face, derived from `face_colors`; kept in sync wherever `face_colors` is set. - pub render_color_indices: Vec, - pub face_cache: FaceCache, } //impl rand:: @@ -35,10 +26,6 @@ impl Render { positions: random_positions(n), speeds: vec![Vec3::zero(); n], edge_length: 1.0, - face_colors: vec![], - next_color_slot: 0, - render_color_indices: vec![], - face_cache: Default::default(), } } diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index 6735ef5..d6939e3 100644 --- a/src/polyhedron/test.rs +++ b/src/polyhedron/test.rs @@ -55,7 +55,7 @@ fn assert_uniform_colors_per_facetype(polyhedron: &Polyhedron) { let signatures = polyhedron.face_signatures(); let mut seen: Vec<(FaceTypeSignature, usize)> = Vec::new(); for (i, sig) in signatures.iter().enumerate() { - let color = polyhedron.render.face_colors[i]; + let color = polyhedron.face_coloring.colors[i]; match seen.iter().find(|(s, _)| s == sig) { Some((_, expected)) => { assert_eq!( @@ -75,7 +75,7 @@ fn color_for_signature(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> u .iter() .position(|sig| sig == target) .unwrap_or_else(|| panic!("no face with signature {target:?}")); - polyhedron.render.face_colors[i] + polyhedron.face_coloring.colors[i] } #[test] From ab291fcf623f8fc8a95c9ce942760e2d8e7bb204 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 17:51:43 -0400 Subject: [PATCH 14/16] nits --- src/polyhedron/face.rs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs index fe22a4e..bd2eb1f 100644 --- a/src/polyhedron/face.rs +++ b/src/polyhedron/face.rs @@ -1,9 +1,9 @@ use std::collections::HashSet; #[derive(Debug, Default, Clone, PartialEq)] -pub struct FaceCache { - pub ancestors: Vec>, - pub colors: Vec, +struct FaceCache { + ancestors: Vec>, + colors: Vec, } /// Per-face color bookkeeping, kept separate from `Render` since it tracks facetype identity, not physical simulation state. @@ -12,11 +12,11 @@ pub struct FaceColoring { /// Palette-relative color slot per current face, parallel to `shape.cycles`. pub colors: Vec, /// Next unused color slot; monotonically increasing so new facetypes get distinct colors. - pub next_color_slot: usize, + next_color_slot: usize, /// Dense render index per face, derived from `colors`; kept in sync wherever `colors` is set. pub render_indices: Vec, /// Pre-mutation snapshot of ancestors/colors, used to reconcile colors across a structural change. - pub cache: FaceCache, + cache: FaceCache, } /// A face's "type": side count plus its neighbors' sorted side-count multiset. @@ -54,18 +54,19 @@ impl FaceColoring { /// Matches faces to a pre-mutation ancestry snapshot by Jaccard similarity, not raw overlap (which can let an inflated ancestor set beat a true match). /// 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.clone(); - self.snapshot(ancestors); - let new = self.cache.clone(); + 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(); - for (i, ancestors) in new.ancestors.iter().enumerate() { - for (j, old) in old.ancestors.iter().enumerate() { - let intersection = old.intersection(ancestors).count(); + 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 = old.union(ancestors).count(); + let union = o.union(a).count(); candidates.push((i, j, intersection, union)); } } @@ -77,7 +78,7 @@ impl FaceColoring { jaccard_b.total_cmp(&jaccard_a).then(ib.cmp(&ia)) }); - let mut matched_color: Vec> = vec![None; new.ancestors.len()]; + 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] { @@ -95,7 +96,7 @@ impl FaceColoring { } } - let mut new_colors = vec![0; new.ancestors.len()]; + let mut new_colors = vec![0; ancestors.len()]; for (_, members) in &groups { let mut votes: Vec<(Option, usize)> = Vec::new(); for &i in members { From d2751e58aa67e23a3154e9d8f1e9d39ee33f6d84 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 17:53:31 -0400 Subject: [PATCH 15/16] nit --- src/polyhedron/face.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs index bd2eb1f..de13e5c 100644 --- a/src/polyhedron/face.rs +++ b/src/polyhedron/face.rs @@ -52,11 +52,11 @@ impl FaceColoring { self.render_indices = dense_color_indices(&self.colors); } - /// Matches faces to a pre-mutation ancestry snapshot by Jaccard similarity, not raw overlap (which can let an inflated ancestor set beat a true match). + /// 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. + /// `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; From 9aa3c2398a4154de9e071f07c5f6de2f10ee2c20 Mon Sep 17 00:00:00 2001 From: Vera Gonzalez Date: Mon, 20 Jul 2026 17:55:32 -0400 Subject: [PATCH 16/16] simplify bootstrapping --- src/polyhedron/mod.rs | 4 ++-- src/polyhedron/platonic.rs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index 0a4c5ab..ae49e06 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -439,7 +439,7 @@ impl Polyhedron { /// Fresh, no-history color assignment: one slot per distinct signature, sorted canonically. /// Used when there's no prior shape to preserve continuity from (construction time). - fn bootstrap_face_colors(&self) -> (Vec, usize) { + fn bootstrap_face_colors(&mut self) { let signatures = self.face_signatures(); let mut distinct = signatures.clone(); distinct.sort(); @@ -449,7 +449,7 @@ impl Polyhedron { .iter() .map(|sig| distinct.iter().position(|d| d == sig).unwrap()) .collect(); - (face_colors, next_color_slot) + self.face_coloring.bootstrap(face_colors, next_color_slot); } fn reconcile_face_colors(&mut self) { diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs index 21a9ef4..014740e 100644 --- a/src/polyhedron/platonic.rs +++ b/src/polyhedron/platonic.rs @@ -30,8 +30,7 @@ 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(); - let (colors, next_color_slot) = polyhedron.bootstrap_face_colors(); - polyhedron.face_coloring.bootstrap(colors, next_color_slot); + polyhedron.bootstrap_face_colors(); polyhedron }