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", 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" 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/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/components/menu.rs b/src/components/menu.rs index b6e1a1f..aadfa74 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::face::FaceTypeOption; use polyblade::render::message::{ ConwayMessage, PolybladeMessage, PresetMessage, RenderMessage, push_message, + schlegel_face_options, }; use strum::IntoEnumIterator; @@ -49,10 +52,53 @@ fn SizedPresetMenu(name: String, make: Callback) -> Elemen } } +/// Polls the backend-published Schlegel face-type options, rendering one button per 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 { + 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; + } 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 +151,8 @@ pub fn MenuBar() -> Element { } } } + if schlegel() { + SchlegelFaceMenu {} + } } } diff --git a/src/main.rs b/src/main.rs index 6806654..245aa27 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,18 @@ 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 op, and `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 +84,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 +196,8 @@ 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 animate. + // 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/face.rs b/src/polyhedron/face.rs new file mode 100644 index 0000000..de13e5c --- /dev/null +++ b/src/polyhedron/face.rs @@ -0,0 +1,134 @@ +use std::collections::HashSet; + +#[derive(Debug, Default, Clone, PartialEq)] +struct FaceCache { + ancestors: Vec>, + 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. + 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. + 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)] +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, +} + +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. + /// Results are then majority-voted per `FaceTypeSignature` to guarantee one color per facetype. + /// + /// `ancestors` is the post-mutation ancestry, one entry per current face. + /// The pre-mutation baseline it's matched against is whatever `snapshot` last recorded. + pub fn reconcile(&mut self, ancestors: Vec>, signatures: &[FaceTypeSignature]) { + let old = &self.cache; + + // (new_face, old_face, intersection, union) per candidate pair with any overlap. + let mut candidates: Vec<(usize, usize, usize, usize)> = Vec::new(); + for (i, a) in ancestors.iter().enumerate() { + for (j, o) in old.ancestors.iter().enumerate() { + let intersection = o.intersection(a).count(); + if intersection > 0 { + let union = o.union(a).count(); + candidates.push((i, j, intersection, union)); + } + } + } + // 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; 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; 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 08a25ff..ae49e06 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::HashMap, time::Duration}; +use std::time::Duration; use crate::Instant; +use crate::polyhedron::face::{FaceColoring, FaceTypeOption, FaceTypeSignature}; use crate::render::{ camera::Camera, message::{ConwayMessage, PresetMessage}, @@ -33,7 +35,7 @@ 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; #[derive(Debug, Clone)] @@ -46,42 +48,58 @@ pub struct Polyhedron { pub render: Render, /// Transaction queue pub transactions: Vec, + /// Per-face color bookkeeping. + pub face_coloring: FaceColoring, } 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, } } + /// 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 + .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 cache_faces(&mut self) { + self.face_coloring.snapshot(self.shape.ancestors()); + } pub fn process_transactions(&mut self, _speed: f32) { if let Some(transaction) = self.transactions.first().cloned() { 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 { + self.cache_faces(); + // 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(); } } Release(edges) => { @@ -92,6 +110,9 @@ impl Polyhedron { self.transactions.remove(0); use ConwayMessage::*; use Transaction::*; + + self.cache_faces(); + let new_transactions = match conway { Dual => { // let edges = self.expand(false); @@ -163,8 +184,11 @@ impl Polyhedron { ] } }; + self.render.new_capacity(self.shape.order()); self.transactions = [new_transactions, self.transactions.clone()].concat(); + + self.reconcile_face_colors(); } Name(c) => { if c == 'b' { @@ -276,7 +300,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 +319,44 @@ 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). + /// Faces are visited in priority order, so the group containing face 0 is always first. + pub fn schlegel_face_options(&self) -> Vec { + let mut options: Vec = Vec::new(); + 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 { + 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 +364,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 +386,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); @@ -369,22 +423,52 @@ impl Polyhedron { } } + /// 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() + .map(|(i, c)| FaceTypeSignature { + side_count: c.len(), + neighbor_sides: neighbor_sides[i].clone(), + }) + .collect() + } + + /// 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(&mut self) { + let signatures = self.face_signatures(); + let mut distinct = signatures.clone(); + distinct.sort(); + distinct.dedup(); + let next_color_slot = distinct.len(); + let face_colors = signatures + .iter() + .map(|sig| distinct.iter().position(|d| d == sig).unwrap()) + .collect(); + self.face_coloring.bootstrap(face_colors, next_color_slot); + } + + fn reconcile_face_colors(&mut self) { + let ancestors = self.shape.ancestors(); + let signatures = self.face_signatures(); + self.face_coloring.reconcile(ancestors, &signatures); + // Reset ancestry now so it never accumulates past one operation (see `Distance::reset_ancestry`). + self.shape.reset_ancestry(); + } + pub fn moment_vertices(&self, colors: &[crate::render::color::RGBA]) -> Vec { + let render_colors = &self.face_coloring.render_indices; 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 - }); - 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] @@ -410,8 +494,7 @@ impl Polyhedron { } }; - // Colors are determined by cycle length - let color = color_map[&cycle.len()]; + 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..014740e 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,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.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/cycles/mod.rs b/src/polyhedron/shape/cycles/mod.rs index ebbb8c3..d2742f4 100644 --- a/src/polyhedron/shape/cycles/mod.rs +++ b/src/polyhedron/shape/cycles/mod.rs @@ -101,6 +101,12 @@ impl Cycles { .collect::>>() .concat() } + + /// 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) + } } impl Index for Cycles { @@ -135,6 +141,38 @@ impl Cycles { } } +/// 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() { + 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 +217,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/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..d57917f 100644 --- a/src/polyhedron/shape/distance/mod.rs +++ b/src/polyhedron/shape/distance/mod.rs @@ -15,11 +15,21 @@ 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, + /// Tags each vertex descends from; unioned into the survivor on a merge, copied to every copy on a split. + ancestors: Vec>, + /// Next never-yet-used tag, for genuinely new vertices only. + next_tag: u64, +} + +impl PartialEq for Distance { + fn eq(&self, other: &Self) -> bool { + self.order == other.order && self.distance == other.distance + } } impl Distance { @@ -34,6 +44,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 +65,43 @@ impl Distance { } } - /// Inserts a new vertex in the matrix + /// 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); + self.next_tag += 1; + v + } + + /// Inserts a new vertex that's a copy of `parents`, inheriting the union of their ancestor sets. + pub fn insert_from(&mut self, parents: &[VertexId]) -> VertexId { self.distance .extend([vec![usize::MAX; self.order], vec![0]].concat()); self.order += 1; + let ancestors = parents.iter().fold(HashSet::new(), |mut acc, &p| { + acc.extend(&self.ancestors[p]); + acc + }); + self.ancestors.push(ancestors); self.order - 1 } + /// The set of persistent tags a vertex descends from. + pub fn ancestors(&self, v: VertexId) -> &HashSet { + &self.ancestors[v] + } + + /// Wipes vertex ancestry back to a fresh singleton tag per current vertex. + /// Left unbounded, repeated merges eventually saturate every vertex's tags to the whole original set, making distinct faces indistinguishable by ancestry alone. + pub fn reset_ancestry(&mut self) { + self.ancestors = (0..self.order as u64) + .map(|tag| HashSet::from([tag])) + .collect(); + self.next_tag = self.order as u64; + } + /// Deletes a vertex from the matrix pub fn delete(&mut self, v: VertexId) { let mut distance = Distance::new(self.order - 1); @@ -73,6 +114,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..c6f9541 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,27 @@ impl Shape { self.distance.vertices() } + /// Union of a face's vertices' ancestor sets. + fn face_ancestors(&self, face_index: usize) -> HashSet { + self.cycles[face_index] + .iter() + .fold(HashSet::new(), |mut acc, &v| { + acc.extend(self.distance.ancestors(v)); + acc + }) + } + + pub fn ancestors(&self) -> Vec> { + (0..self.cycles.len()) + .map(|i| self.face_ancestors(i)) + .collect() + } + + /// Wipes vertex ancestry back to a fresh singleton tag per current vertex; see `Distance::reset_ancestry`. + pub fn reset_ancestry(&mut self) { + self.distance.reset_ancestry(); + } + pub fn recompute(&mut self) { // Update the distance matrix in place self.distance.bfs_apsp(); diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index 2f10a1a..d6939e3 100644 --- a/src/polyhedron/test.rs +++ b/src/polyhedron/test.rs @@ -43,3 +43,108 @@ fn ambo() { let octahedron = Polyhedron::preset(&Octahedron); assert_eq!(polyhedron.shape, octahedron.shape); } + +fn apply_ambo(polyhedron: &mut Polyhedron) { + polyhedron.cache_faces(); + polyhedron.ambo_contract(); + polyhedron.reconcile_face_colors(); +} + +/// Every face sharing a `FaceTypeSignature` must share a color. +fn assert_uniform_colors_per_facetype(polyhedron: &Polyhedron) { + let signatures = polyhedron.face_signatures(); + let mut seen: Vec<(FaceTypeSignature, usize)> = Vec::new(); + for (i, sig) in signatures.iter().enumerate() { + let color = polyhedron.face_coloring.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_coloring.colors[i] +} + +#[test] +fn ambo_twice_preserves_facetype_colors() { + // cube ("C") + let mut polyhedron = Polyhedron::preset(&Prism(4)); + + // cuboctahedron ("aC") + apply_ambo(&mut polyhedron); + 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); + + // 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 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); +} + +#[test] +fn ambo_octahedron_gives_distinct_facetype_colors() { + // octahedron ("O") + let mut polyhedron = Polyhedron::preset(&Octahedron); + + // cuboctahedron ("aO") + apply_ambo(&mut polyhedron); + 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) + ); +} 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 90ee79e..181e14f 100644 --- a/src/render/message.rs +++ b/src/render/message.rs @@ -1,6 +1,9 @@ use crate::{ Instant, - polyhedron::{Polyhedron, Transaction}, + polyhedron::{ + Polyhedron, Transaction, + face::{FaceTypeOption, FaceTypeSignature}, + }, render::{camera::Camera, color::RGBA}, }; use std::fmt::Display; @@ -28,6 +31,19 @@ pub fn drain_messages() -> Vec { std::mem::take(&mut *MESSAGE_QUEUE.lock().unwrap()) } +/// 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()); + +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 +129,7 @@ pub enum ConwayMessage { #[derive(Debug, Clone)] pub enum RenderMessage { Schlegel(bool), + SchlegelFace(FaceTypeSignature), Rotating(bool), FovChanged(f32), ZoomChanged(f32), @@ -201,6 +218,9 @@ impl ProcessMessage for RenderMessage { state.zoom = 1.0; } } + SchlegelFace(signature) => { + state.schlegel_face = Some(signature.clone()); + } Rotating(rotating) => { state.rotating = *rotating; if !rotating { @@ -263,10 +283,22 @@ impl ProcessMessage for PolybladeMessage { state.update_state(*time); 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 - .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 +311,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/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 6661e8c..420482e 100644 --- a/src/render/state.rs +++ b/src/render/state.rs @@ -1,6 +1,6 @@ use crate::{ Instant, - polyhedron::Polyhedron, + polyhedron::{Polyhedron, face::FaceTypeSignature}, render::{ camera::Camera, color::RGBA, @@ -33,6 +33,10 @@ 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, + /// 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, @@ -59,6 +63,8 @@ impl Default for RenderState { rotating: true, 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(),