diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 0652d7b..a86b629 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -3,11 +3,24 @@ name: deploy-web on: push: branches: [main] + pull_request: {} workflow_dispatch: {} +permissions: + contents: read + deployments: write + pull-requests: write + +# superseded preview builds get cancelled, main deploys never are +concurrency: + group: deploy-web-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: deploy: runs-on: ubuntu-latest + # fork PRs have no access to the vercel secrets + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v5 - uses: DeterminateSystems/determinate-nix-action@v3 @@ -31,13 +44,60 @@ jobs: - name: install vercel CLI run: npm install -g vercel@latest - name: deploy to vercel + id: vercel env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + # the CLI logs to stderr and prints only the deployment URL to stdout run: | if [ "${{ github.event_name }}" = "push" ]; then - vercel deploy dist/public --prod --token="$VERCEL_TOKEN" --yes + url=$(vercel deploy dist/public --prod --token="$VERCEL_TOKEN" --yes) + echo "environment=Production" >> "$GITHUB_OUTPUT" + else + url=$(vercel deploy dist/public --token="$VERCEL_TOKEN" --yes) + echo "environment=Preview" >> "$GITHUB_OUTPUT" + fi + echo "url=$url" >> "$GITHUB_OUTPUT" + - name: record deployment on github + env: + GH_TOKEN: ${{ github.token }} + run: | + deployment_id=$(jq -n \ + --arg ref "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" \ + --arg env "${{ steps.vercel.outputs.environment }}" \ + '{ref: $ref, environment: $env, auto_merge: false, required_contexts: [], + production_environment: ($env == "Production"), + transient_environment: ($env == "Preview")}' \ + | gh api "repos/${{ github.repository }}/deployments" --input - --jq .id) + jq -n \ + --arg url "${{ steps.vercel.outputs.url }}" \ + --arg log "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + '{state: "success", environment_url: $url, log_url: $log, auto_inactive: true}' \ + | gh api "repos/${{ github.repository }}/deployments/$deployment_id/statuses" --input - + - name: comment preview url on pr + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + URL: ${{ steps.vercel.outputs.url }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # one sticky comment per PR, updated in place on each push + run: | + marker="" + body="$(cat < -## WARNING -This software is currently broken. Use the release published on crates.io or 0.1.0 while I fix it, which can be installed using the method below. -The main branch is not as functional as it once was, but this will be remedied soon post-refactor. - -## Installation -```cargo install polyblade``` +## Running Note that the `webGPU` demo is available [here](https://polyblade.app). It runs just as smoothly as the native application. @@ -54,11 +49,12 @@ Due to the recent refactor, we're not as far along on this roadmap as we once we Rest assured that in due time we will conquer all shapes. - [x] Ambo -- [ ] Kis +- [x] Kis - [x] Truncate - [ ] Ortho - [x] Bevel -- [ ] Expand +- [x] Expand +- [x] Dual - [ ] Snub - [ ] Join - [ ] Zip @@ -74,14 +70,14 @@ Rest assured that in due time we will conquer all shapes. - [ ] "Undo" button - [ ] Save and load animations and cycles of `Transaction`s - [x] Schlegel diagrams -- [x] Color pickers +- [ ] Color pickers - [ ] Pokedex entries for polyhedra, point users to wikipedia or polytope wiki when they stumble onto a known entry - - [x] Basic functionality + - [ ] Basic functionality - [ ] Switch from `RON` to `JSON` - [ ] Expand pokedex to include more shapes and improve overlap on isomorphic conway strings - [ ] Fix pokedex on WASM -- [ ] Create WASM deployment and add to website as git submodule - - [ ] Fix `time` on web for `dual` and related transitions - - [x] WebGL compat - - [ ] WebGPU compat +- [ ] Create WASM deployment + - [x] Fix `time` on web for `dual` and related transitions + - [ ] WebGL compat + - [x] WebGPU compat - [x] Setup some basic CI integrations diff --git a/assets/tailwind.css b/assets/tailwind.css index d9be7be..b2e4324 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -588,6 +588,10 @@ video { } } +.collapse { + visibility: collapse; +} + .static { position: static; } @@ -630,6 +634,12 @@ video { outline-style: solid; } +.ring { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } \ No newline at end of file diff --git a/src/polyhedron/conway.rs b/src/polyhedron/conway.rs index c703a52..a2e884f 100644 --- a/src/polyhedron/conway.rs +++ b/src/polyhedron/conway.rs @@ -9,11 +9,19 @@ impl Polyhedron { } pub fn truncate(&mut self, d: usize) -> Vec<[VertexId; 2]> { + // Full truncation is built in one pass and recomputes once. + if d == 0 { + let (new_edges, parents) = self.shape.truncate(); + self.render.rebuild_from_parents(&parents); + return new_edges; + } + + // Selective truncation still uses the slow per-vertex path; perf is a follow-up. let mut new_edges = Vec::default(); for v in self.shape.vertices().rev() { - if d == 0 || self.shape.degree(v) == d { + if self.shape.degree(v) == d { new_edges.extend(self.split_vertex(v)); - self.shape.recompute(); + self.shape.recompute_metrics(); } } new_edges @@ -49,4 +57,22 @@ impl Polyhedron { pub fn chamfer(&mut self) { self.shape.chamfer(); } + + pub fn expand(&mut self) { + let (parents, _) = self.shape.expand(); + self.render.rebuild_from_parents(&parents); + } + + /// Expands, then returns the face-figure edges to contract for the dual. + /// The animated `Dual` transaction drives the contraction; call `dual` to apply it immediately. + pub fn begin_dual(&mut self) -> Vec<[VertexId; 2]> { + let (parents, face_edges) = self.shape.expand(); + self.render.rebuild_from_parents(&parents); + face_edges + } + + pub fn dual(&mut self) { + let edges = self.begin_dual(); + self.contract(edges); + } } diff --git a/src/polyhedron/face.rs b/src/polyhedron/face.rs index de13e5c..fe52905 100644 --- a/src/polyhedron/face.rs +++ b/src/polyhedron/face.rs @@ -1,22 +1,21 @@ -use std::collections::HashSet; - -#[derive(Debug, Default, Clone, PartialEq)] -struct FaceCache { - ancestors: Vec>, - colors: Vec, -} +use super::palette::PaletteAllocator; +use crate::polyhedron::FaceId; +use std::collections::{BTreeSet, HashMap}; /// Per-face color bookkeeping, kept separate from `Render` since it tracks facetype identity, not physical simulation state. +/// Continuity is definitional, not matched: a face id keeps its color slot for as long as it lives. #[derive(Debug, Default, Clone, PartialEq)] pub struct FaceColoring { - /// Palette-relative color slot per current face, parallel to `shape.cycles`. + /// Color slot per current face, parallel to `shape.cycles`. pub colors: Vec, /// Next unused color slot; monotonically increasing so new facetypes get distinct colors. next_color_slot: usize, - /// Dense render index per face, derived from `colors`; kept in sync wherever `colors` is set. + /// Cached palette index per face; derived from `colors` via `allocator`, read every frame by the renderer. pub render_indices: Vec, - /// Pre-mutation snapshot of ancestors/colors, used to reconcile colors across a structural change. - cache: FaceCache, + /// Maps color slots to stable, recyclable palette indices. + allocator: PaletteAllocator, + /// Persistent color slot per live face id; the single source of continuity across operations. + slots: HashMap, } /// A face's "type": side count plus its neighbors' sorted side-count multiset. @@ -37,98 +36,121 @@ pub struct FaceTypeOption { } impl FaceColoring { - /// Snapshots the current colors against a fresh ancestor set, as the baseline for the next `reconcile`. - pub fn snapshot(&mut self, ancestors: Vec>) { - self.cache = FaceCache { - ancestors, - colors: self.colors.clone(), - }; + /// Tells the coloring how many palette entries exist, refreshing render indices if it changed. + pub fn set_palette_len(&mut self, len: usize) { + if self.allocator.set_len(len) { + self.refresh_render_indices(); + } } - /// Assigns colors fresh, one slot per distinct signature; used when there's no prior state to preserve continuity from. - pub fn bootstrap(&mut self, colors: Vec, next_color_slot: usize) { + /// Assigns colors fresh, one slot per distinct signature, when there is no prior state to preserve. + /// Wipes leftover id and palette state, since presets run operations internally before bootstrapping. + pub fn bootstrap(&mut self, face_ids: &[FaceId], colors: Vec, next_color_slot: usize) { + self.slots = face_ids + .iter() + .copied() + .zip(colors.iter().copied()) + .collect(); self.colors = colors; self.next_color_slot = next_color_slot; - self.render_indices = dense_color_indices(&self.colors); + self.allocator = PaletteAllocator::default(); + // Seed a palette floor so the initial render is dense before any `set_palette_len`. + self.allocator.set_len(next_color_slot); + self.refresh_render_indices(); } - /// Matches faces to a pre-mutation ancestry snapshot by Jaccard similarity. - /// Results are then majority-voted per `FaceTypeSignature` to guarantee one color per facetype. - /// - /// `ancestors` is the post-mutation ancestry, one entry per current face. - /// The pre-mutation baseline it's matched against is whatever `snapshot` last recorded. - pub fn reconcile(&mut self, ancestors: Vec>, signatures: &[FaceTypeSignature]) { - let old = &self.cache; - - // (new_face, old_face, 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)) - }); + /// Carries colors across a structural change: surviving ids keep their slot, parented fresh ids inherit it, and remaining fresh ids get one new slot per signature. + /// Normalization then enforces one color per signature, preferring inherited slots over ones minted this call, then most members, then smallest slot. + pub fn finalize( + &mut self, + face_ids: &[FaceId], + birth_parents: &HashMap, + signatures: &[FaceTypeSignature], + ) { + // Exact transfer by id, then by parent id. + let mut colors: Vec> = face_ids + .iter() + .map(|id| { + self.slots.get(id).copied().or_else(|| { + birth_parents + .get(id) + .and_then(|parent| self.slots.get(parent)) + .copied() + }) + }) + .collect(); - let mut matched_color: Vec> = vec![None; ancestors.len()]; - let mut old_claimed = vec![false; old.ancestors.len()]; - for (i, j, ..) in candidates { - if matched_color[i].is_none() && !old_claimed[j] { - matched_color[i] = Some(old.colors[j]); - old_claimed[j] = true; + // Mint for the genuinely new facetypes. + let mut minted: Vec = Vec::new(); + let mut minted_by_signature: Vec<(&FaceTypeSignature, usize)> = Vec::new(); + for (i, color) in colors.iter_mut().enumerate() { + if color.is_none() { + let slot = match minted_by_signature + .iter() + .find(|(sig, _)| **sig == signatures[i]) + { + Some((_, slot)) => *slot, + None => { + let slot = self.next_color_slot; + self.next_color_slot += 1; + minted.push(slot); + minted_by_signature.push((&signatures[i], slot)); + slot + } + }; + *color = Some(slot); } } + let mut colors: Vec = colors.into_iter().map(Option::unwrap).collect(); - // Group by facetype and majority-vote one color per group. - let mut groups: Vec<(FaceTypeSignature, Vec)> = Vec::new(); + // Normalization: one color per signature. + let mut groups: Vec<(&FaceTypeSignature, Vec)> = Vec::new(); for (i, sig) in signatures.iter().enumerate() { - match groups.iter_mut().find(|(s, _)| s == sig) { + match groups.iter_mut().find(|(s, _)| *s == sig) { Some((_, members)) => members.push(i), - None => groups.push((sig.clone(), vec![i])), + None => groups.push((sig, vec![i])), } } - - let mut new_colors = vec![0; ancestors.len()]; for (_, members) in &groups { - let mut votes: Vec<(Option, usize)> = Vec::new(); + let mut votes: Vec<(usize, usize)> = Vec::new(); for &i in members { - match votes.iter_mut().find(|(v, _)| *v == matched_color[i]) { + match votes.iter_mut().find(|(slot, _)| *slot == colors[i]) { Some((_, count)) => *count += 1, - None => votes.push((matched_color[i], 1)), + None => votes.push((colors[i], 1)), } } - 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; + if votes.len() > 1 { + let winner = votes + .iter() + .min_by_key(|&&(slot, count)| { + (minted.contains(&slot), usize::MAX - count, slot) + }) + .unwrap() + .0; + for &i in members { + colors[i] = winner; + } } } - self.colors = new_colors; - self.render_indices = dense_color_indices(&self.colors); + // Live ids adopt their final (possibly normalized) slots; dead ids fall away here. + self.slots = face_ids + .iter() + .copied() + .zip(colors.iter().copied()) + .collect(); + self.colors = colors; + self.refresh_render_indices(); } -} -/// 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() + /// Reassigns palette indices for the current slots and rebuilds the cached render indices. + fn refresh_render_indices(&mut self) { + let present: BTreeSet = self.colors.iter().copied().collect(); + self.allocator.reassign(&present); + self.render_indices = self + .colors + .iter() + .map(|&slot| self.allocator.palette_of(slot)) + .collect(); + } } diff --git a/src/polyhedron/mod.rs b/src/polyhedron/mod.rs index ae49e06..22ac3c4 100644 --- a/src/polyhedron/mod.rs +++ b/src/polyhedron/mod.rs @@ -1,5 +1,6 @@ mod conway; pub mod face; +mod palette; mod platonic; mod render; mod shape; @@ -24,6 +25,9 @@ use ultraviolet::{Vec3, Vec4}; pub type VertexId = usize; +/// Stable, never-reused face identity, carried through every operation for color continuity. +pub type FaceId = u64; + pub const SPEED_DAMPENING: f32 = 0.92; /// Margin for the auto-fit Schlegel FOV so extremal vertices don't touch the viewport edge. @@ -38,6 +42,37 @@ const SCHLEGEL_CONTAINMENT_MARGIN: f32 = 0.9; /// Depth epsilon for the containment check, scaled to the face's inradius to avoid flicker. const SCHLEGEL_DEPTH_EPSILON_FACTOR: f32 = 0.02; +/// Contracts each edge in turn, remapping later edges onto the surviving lower index and closing the gap. +/// `delete(v, u)` performs the per-structure removal of the higher endpoint `v` merged into survivor `u`. +pub(crate) fn contract_edge_indices( + mut edges: Vec<[VertexId; 2]>, + mut delete: impl FnMut(VertexId, VertexId), +) { + let mut i = 0; + while i < edges.len() { + let [w, x] = edges[i]; + i += 1; + // Endpoints already merged (e.g. the last edge of a contracted cycle); nothing to do. + if w == x { + continue; + } + let v = w.max(x); + let u = w.min(x); + delete(v, u); + // Remap the deleted vertex onto the survivor, then close the index gap. + // Only edges still ahead of the cursor need remapping. + for [a, b] in &mut edges[i..] { + for endpoint in [a, b] { + if *endpoint == v { + *endpoint = u; + } else if *endpoint > v { + *endpoint -= 1; + } + } + } + } +} + #[derive(Debug, Clone)] pub struct Polyhedron { /// Conway Polyhedron Notation @@ -77,10 +112,6 @@ impl Polyhedron { (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::*; @@ -92,14 +123,12 @@ impl Polyhedron { .any(|l| l > 0.05); if all_completed { - self.cache_faces(); - // Contract them in the graph self.shape.contract_edges(edges.clone()); self.render.contract_edges(edges); self.transactions.remove(0); - self.reconcile_face_colors(); + self.finalize_face_colors(); } } Release(edges) => { @@ -111,30 +140,20 @@ impl Polyhedron { use ConwayMessage::*; use Transaction::*; - self.cache_faces(); - let new_transactions = match conway { Dual => { - // let edges = self.expand(false); - // vec![ - // Wait(Instant::now() + Duration::from_millis((65.0 * speed) as u64)), - // Contraction(edges), - // Name('d'), - // ] - todo!() + // Expand blooms out, then contracting the face-figures collapses each face to a point. + let edges = self.begin_dual(); + vec![ + Wait(Instant::now() + Duration::from_millis(500)), + Contraction(edges), + Name('d'), + ] } Join => { - // let edges = self.graph.kis(Option::None); - // vec![ - // //Wait(Instant::now() + Duration::from_secs(1)), - // Release(edges), - // Name('j'), - // ] todo!() } Ambo => { - // let edges = self.shape.ambo(); - // self.shape.recompute(); let edges = self.ambo(); vec![Contraction(edges), Name('a')] } @@ -143,36 +162,23 @@ impl Polyhedron { vec![Name('c')] } Kis => { - // self.graph.kis(Option::None); - // vec![Name('k')] - todo!() + self.shape.kis(Option::None); + vec![Name('k')] } SplitVertex(n) => { self.split_vertex(n); - self.shape.recompute(); + self.shape.recompute_metrics(); vec![] } Truncate => { - // let mut operations = vec![]; - // for v in self.shape.vertices() { - // operations.extend(vec![ - // Wait(Instant::now() + Duration::from_millis(1000) * v as u32), - // Conway(SplitVertex(v)), - // ]); - // } - // [operations, vec![Name('t')]].concat() self.truncate(0); vec![Name('t')] } Expand => { - self.ambo_contract(); - let edges = self.ambo(); - // self.shape.expand(false); - vec![Contraction(edges), Name('e')] + self.expand(); + vec![Name('e')] } Snub => { - // self.graph.expand(true); - // vec![Name('s')] todo!() } Bevel => { @@ -188,7 +194,7 @@ impl Polyhedron { self.render.new_capacity(self.shape.order()); self.transactions = [new_transactions, self.transactions.clone()].concat(); - self.reconcile_face_colors(); + self.finalize_face_colors(); } Name(c) => { if c == 'b' { @@ -449,15 +455,18 @@ impl Polyhedron { .iter() .map(|sig| distinct.iter().position(|d| d == sig).unwrap()) .collect(); - self.face_coloring.bootstrap(face_colors, next_color_slot); + // Construction-time operations may have left parent records; bootstrap starts clean. + self.shape.birth_parents.clear(); + self.face_coloring + .bootstrap(self.shape.cycles.ids(), face_colors, next_color_slot); } - fn reconcile_face_colors(&mut self) { - let ancestors = self.shape.ancestors(); + /// Carries face colors across the operation that just completed, keyed purely by face id. + fn finalize_face_colors(&mut self) { let signatures = self.face_signatures(); - self.face_coloring.reconcile(ancestors, &signatures); - // Reset ancestry now so it never accumulates past one operation (see `Distance::reset_ancestry`). - self.shape.reset_ancestry(); + let birth_parents = std::mem::take(&mut self.shape.birth_parents); + self.face_coloring + .finalize(self.shape.cycles.ids(), &birth_parents, &signatures); } pub fn moment_vertices(&self, colors: &[crate::render::color::RGBA]) -> Vec { @@ -494,6 +503,11 @@ impl Polyhedron { } }; + // Exhausted only when live facetypes outnumber the palette; the `%` then wraps to a reused color. + debug_assert!( + render_colors[i] < colors.len(), + "palette exhausted: more facetypes than colors" + ); let color: Vec4 = colors[render_colors[i] % colors.len()].into(); // Map into MomentVertices positions @@ -502,89 +516,4 @@ impl Polyhedron { }) .collect() } - - // fn face_positions(&self, face_index: usize) -> Vec { - // self.shape.cycles[face_index] - // .iter() - // .map(|&v| self.render.vertices[v].position) - // .collect() - // } - // Use a Fibonacci Lattice to spread the points evenly around a sphere - // pub fn connect(&mut self, [v, u]: [VertexId; 2]) { - // self.graph.connect([v, u]); - // } - // - // pub fn disconnect(&mut self, [v, u]: [VertexId; 2]) { - // self.graph.disconnect([v, u]); - // } - // - // pub fn insert(&mut self) -> VertexId { - // self.positions - // .push(Vec3::new(random(), random(), random()).normalized()); - // self.speeds.push(Vec3::zero()); - // self.graph.insert() - // } - - // pub fn delete(&mut self, v: VertexId) { - // self.vertices.remove(&v); - // - // self.edges = self - // .edges - // .clone() - // .into_iter() - // .filter(|e| !e.contains(v)) - // .collect(); - // - // self.cycles = self - // .cycles - // .clone() - // .into_iter() - // .map(|face| face.into_iter().filter(|&u| u != v).collect()) - // .collect(); - // - // self.positions.remove(&v); - // self.speeds.remove(&v); - // } - // - // /// Edges of a vertex - // pub fn edges(&self, v: VertexId) -> Vec { - // let mut edges = vec![]; - // for u in 0..self.dist.len() { - // if self.dist[v][u] == 1 { - // edges.push((v, u).into()); - // } - // } - // edges - // } - - // /// Number of faces - // pub fn face_count(&self) -> i64 { - // 2 + self.edges.len() as i64 - self.vertices.len() as i64 - // } - - // - // - // } - -// impl Display for PolyGraph { -// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -// let mut vertices = self.vertices.iter().collect::>(); -// vertices.sort(); -// let mut adjacents = self.edges.clone().into_iter().collect::>(); -// adjacents.sort(); -// -// f.write_fmt(format_args!( -// "name:\t\t{}\nvertices:\t{:?}\nadjacents:\t{}\nfaces:\t\t{}\n", -// self.name, -// vertices, -// adjacents -// .iter() -// .fold(String::new(), |acc, e| format!("{e}, {acc}")), -// self.cycles.iter().fold(String::new(), |acc, f| format!( -// "[{}], {acc}", -// f.iter().fold(String::new(), |acc, x| format!("{x}, {acc}")) -// )) -// )) -// } -// } diff --git a/src/polyhedron/palette.rs b/src/polyhedron/palette.rs new file mode 100644 index 0000000..14567bb --- /dev/null +++ b/src/polyhedron/palette.rs @@ -0,0 +1,60 @@ +use std::collections::{BTreeSet, HashMap, VecDeque}; + +/// Maps ever-growing color slots onto a bounded palette of display indices. +/// A live slot keeps its index while present, and a freed index recycles last so churned facetypes advance to fresh colors. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct PaletteAllocator { + /// Palette index currently held by each live color slot. + assigned: HashMap, + /// Available palette indices in hand-out order; the front is freshest and freed entries go to the back. + free: VecDeque, +} + +impl PaletteAllocator { + /// Sets how many palette entries exist, keeping existing assignments and preference order. + /// Returns whether anything changed; growth appends new indices, shrink drops out-of-range ones for reassignment. + pub fn set_len(&mut self, len: usize) -> bool { + let total = self.assigned.len() + self.free.len(); + if total == len { + return false; + } + if len > total { + self.free.extend(total..len); + } else { + self.free.retain(|&p| p < len); + self.assigned.retain(|_, &mut p| p < len); + } + true + } + + /// Reassigns palette indices for a new set of live slots. + /// Survivors keep their index, vanished slots free theirs to the back, and newcomers take the front. + pub fn reassign(&mut self, present: &BTreeSet) { + let gone: Vec = self + .assigned + .keys() + .copied() + .filter(|s| !present.contains(s)) + .collect(); + for slot in gone { + let palette = self.assigned.remove(&slot).unwrap(); + self.free.push_back(palette); + } + for &slot in present { + if !self.assigned.contains_key(&slot) { + // An exhausted palette hands out an out-of-range index that the render site wraps. + // That knowingly reuses a color; the render site's debug_assert flags it in debug builds. + let palette = match self.free.pop_front() { + Some(p) => p, + None => self.assigned.len(), + }; + self.assigned.insert(slot, palette); + } + } + } + + /// Palette index a slot maps to, falling back to 0 for an unassigned slot. + pub fn palette_of(&self, slot: usize) -> usize { + self.assigned.get(&slot).copied().unwrap_or(0) + } +} diff --git a/src/polyhedron/platonic.rs b/src/polyhedron/platonic.rs index 014740e..17c3711 100644 --- a/src/polyhedron/platonic.rs +++ b/src/polyhedron/platonic.rs @@ -6,7 +6,7 @@ impl Polyhedron { use PresetMessage::*; let mut polyhedron = match preset { Octahedron => Self::octahedron(), - Dodecahedron => todo!(), + Dodecahedron => Self::dodecahedron(), Icosahedron => Self::icosahedron(), _ => { let shape = match preset { @@ -27,9 +27,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(); + // Bootstrapping assigns fresh colors regardless of construction-time operations. polyhedron.bootstrap_face_colors(); polyhedron } @@ -39,6 +37,14 @@ impl Polyhedron { polyhedron.ambo_contract(); polyhedron } + + pub fn dodecahedron() -> Polyhedron { + let mut graph = Polyhedron::preset(&AntiPrism(5)); + graph.dual(); + graph.truncate(5); + graph + } + pub fn icosahedron() -> Polyhedron { let mut graph = Polyhedron::preset(&AntiPrism(5)); graph.shape.kis(Some(5)); diff --git a/src/polyhedron/render.rs b/src/polyhedron/render.rs index 99baa44..6c93f18 100644 --- a/src/polyhedron/render.rs +++ b/src/polyhedron/render.rs @@ -63,6 +63,13 @@ impl Render { self.speeds.extend(vec![Vec3::zero(); n]); } + /// Re-seeds positions after a re-indexing rebuild: new vertex `k` starts at its parent's position. + /// Speeds reset to zero so the fresh vertices relax outward from the original corners. + pub fn rebuild_from_parents(&mut self, parents: &[VertexId]) { + self.positions = parents.iter().map(|&p| self.positions[p]).collect(); + self.speeds = vec![Vec3::zero(); parents.len()]; + } + pub fn spring_length(&self, [v, u]: [VertexId; 2]) -> f32 { (self.positions[v] - self.positions[u]).mag() } @@ -96,27 +103,10 @@ impl Render { self.positions[u] += self.speeds[u]; } - pub fn contract_edges(&mut self, mut edges: Vec<[VertexId; 2]>) { - // let mut transformed = HashSet::default(); - while !edges.is_empty() { - // Pop an edge - let [w, x] = edges.remove(0); - let v = w.max(x); - let _u = w.min(x); - // if transformed.contains(&v) && transformed.contains(&u) {} - + pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) { + crate::polyhedron::contract_edge_indices(edges, |v, _| { self.positions.remove(v); self.speeds.remove(v); - // transformed.insert(v); - - for [x, w] in &mut edges { - if *x > v { - *x -= 1; - } - if *w > v { - *w -= 1; - } - } - } + }); } } diff --git a/src/polyhedron/shape/conway.rs b/src/polyhedron/shape/conway.rs index ab5afbc..748103f 100644 --- a/src/polyhedron/shape/conway.rs +++ b/src/polyhedron/shape/conway.rs @@ -1,72 +1,275 @@ -use super::{Cycle, Cycles, Shape}; -use crate::polyhedron::VertexId; +use super::topology::{FaceTopology, undirected}; +use super::{Cycles, Distance, Shape}; +use crate::polyhedron::{FaceId, VertexId}; +use std::collections::HashMap; impl Shape { pub fn split_vertex(&mut self, v: VertexId) -> Vec<[usize; 2]> { let sc = self.cycles.sorted_connections(v); - let edges = self.distance.split_vertex(v, sc); - self.cycles = Cycles::from(&self.distance); + let edges = self.distance.split_vertex(v, sc.clone()); + // The ring edges are [corner_i, corner_i+1], where corner_i stays adjacent to sc[i]. + let corners: Vec = edges.iter().map(|&[a, _]| a).collect(); + + // Faces containing v keep their id, with v replaced by prev's then next's corner. + let mut new_cycles: Vec> = Vec::with_capacity(self.cycles.len() + 1); + let mut new_ids: Vec = Vec::with_capacity(self.cycles.len() + 1); + for (i, cycle) in self.cycles.iter().enumerate() { + let mut face: Vec = cycle.iter().copied().collect(); + if let Some(k) = face.iter().position(|&x| x == v) { + let n = face.len(); + let prev = face[(k + n - 1) % n]; + let next = face[(k + 1) % n]; + let j = sc.iter().position(|&x| x == prev).unwrap(); + let m = sc.iter().position(|&x| x == next).unwrap(); + face.splice(k..=k, [corners[j], corners[m]]); + } + new_cycles.push(face); + new_ids.push(self.cycles.ids()[i]); + } + // The corner ring itself is the new vertex-figure face. + new_cycles.push(corners); + new_ids.push(self.fresh_face_id()); + + // Metrics are recomputed by the caller after splitting, so only rebuild the face list here. + self.cycles = Cycles::new(new_cycles, new_ids); + self.cycles.sort(); + self.assert_cycles_match_discovery(); edges } + /// `t` full truncation: one new vertex per (vertex, incident-edge) corner. + /// Returns the vertex-figure edges (so `ambo` contracts the rest) and each new vertex's origin for render re-seeding. + pub fn truncate(&mut self) -> (Vec<[VertexId; 2]>, Vec) { + // Index every (vertex, neighbor) corner; `corner[(v, u)]` is the new vertex there. + let mut corner: HashMap<(VertexId, VertexId), VertexId> = HashMap::new(); + let mut parents: Vec = Vec::new(); + let mut vertex_order: Vec> = Vec::with_capacity(self.order()); + for v in self.vertices() { + let sc = self.cycles.sorted_connections(v); + for &u in &sc { + corner.insert((v, u), parents.len()); + parents.push(v); + } + vertex_order.push(sc); + } + + let mut distance = Distance::new(parents.len()); + // Vertex-figure d-gon: link a vertex's corners in cyclic neighbor order. + // The truncated faces (2n-gons) emerge for free from these plus the original edges. + let mut new_edges = Vec::new(); + for (v, sc) in vertex_order.iter().enumerate() { + let d = sc.len(); + for i in 0..d { + let edge = [corner[&(v, sc[i])], corner[&(v, sc[(i + 1) % d])]]; + distance.connect(edge); + new_edges.push(edge); + } + } + // Original edges: each keeps its two endpoint corners joined. + for [v, u] in self.edges() { + distance.connect([corner[&(v, u)], corner[&(u, v)]]); + } + + // Each original face persists as the 2n-gon over its corner copies, keeping its id. + // Consecutive corners alternate between original-edge crossings and vertex-figure edges. + let mut new_cycles: Vec> = Vec::new(); + let mut new_ids: Vec = Vec::new(); + for (i, cycle) in self.cycles.iter().enumerate() { + let gon = (0..cycle.len()) + .flat_map(|k| { + let (a, b) = (cycle[k], cycle[k + 1]); + [corner[&(a, b)], corner[&(b, a)]] + }) + .collect(); + new_cycles.push(gon); + new_ids.push(self.cycles.ids()[i]); + } + // Each original vertex spawns its vertex-figure d-gon: a genuinely new face. + for (v, sc) in vertex_order.iter().enumerate() { + new_cycles.push(sc.iter().map(|&u| corner[&(v, u)]).collect()); + new_ids.push(self.fresh_face_id()); + } + + self.distance = distance; + self.install_cycles(new_cycles, new_ids); + self.assert_cycles_match_discovery(); + (new_edges, parents) + } + pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) { - self.distance.contract_edges(edges); - // Delete a - // for - // for i in 0..self.cycles.len() { - // self.cycles[i].replace(v, u); - // } - self.recompute(); + self.distance.contract_edges(edges.clone()); + // Both walks replay the same merge sequence, so the face list tracks the matrix exactly. + self.cycles.contract_edges(edges); + self.cycles.sort(); + self.recompute_metrics(); + self.assert_cycles_match_discovery(); } pub fn kis(&mut self, degree: Option) -> Vec<[VertexId; 2]> { let edges = self.distance.edges().collect(); - let cycles: Vec<&Cycle> = self - .cycles - .iter() - .filter(move |cycle| { - if let Some(degree) = degree { - cycle.len() == degree - } else { - true - } - }) - .collect(); - for cycle in cycles { - let parents: Vec = cycle.iter().copied().collect(); - let v = self.distance.insert_from(&parents); - // let mut vpos = Vec3::zero(); + let mut new_cycles: Vec> = Vec::new(); + let mut new_ids: Vec = Vec::new(); + for i in 0..self.cycles.len() { + let face: Vec = self.cycles[i].iter().copied().collect(); + let id = self.cycles.ids()[i]; + if degree.is_some_and(|d| face.len() != d) { + // Untouched face persists as-is. + new_cycles.push(face); + new_ids.push(id); + continue; + } + // Raise an apex over the face; it splits into n triangles, each carved from `id`. + let v = self.distance.insert(); + let n = face.len(); + for k in 0..n { + self.distance.connect([v, face[k]]); + new_cycles.push(vec![v, face[k], face[(k + 1) % n]]); + let fid = self.fresh_face_id(); + new_ids.push(fid); + self.birth_parents.insert(fid, id); + } + } + + self.install_cycles(new_cycles, new_ids); + // No discovery oracle here because discovery falsely admits the covered original triangles. + // The explicit construction is the ground truth, verified by count assertions in tests. + edges + } + + /// `e` expand / cantellation: one new vertex per original vertex-face corner. + /// Returns each new vertex's origin (for render re-seeding) and the face-figure edges to contract for the dual. + pub fn expand(&mut self) -> (Vec, Vec<[VertexId; 2]>) { + let topo = FaceTopology::snapshot(&self.cycles); + + // Index every (face, corner) incidence; `c[f][i]` is the new vertex at face `f`'s i-th corner. + let mut c: Vec> = Vec::with_capacity(topo.cycles.len()); + let mut parents: Vec = Vec::new(); + for cycle in &topo.cycles { + let row = cycle + .iter() + .map(|&v| { + parents.push(v); + parents.len() - 1 + }) + .collect(); + c.push(row); + } + // The new vertex at face `f`'s copy of vertex `v`. + let corner = |f: usize, v: VertexId| c[f][topo.pos(f, v)]; - for &u in cycle.iter() { - self.distance.connect([v, u]); - //vpos += self.positions[&u]; + let mut distance = Distance::new(parents.len()); + // Face-figure edges: the original n-gon, using this face's corner copies. + // Contracting these collapses each face to a point, giving the dual. + let mut face_edges = Vec::new(); + for (f, cycle) in topo.cycles.iter().enumerate() { + let n = cycle.len(); + for k in 0..n { + let edge = [c[f][k], c[f][(k + 1) % n]]; + distance.connect(edge); + face_edges.push(edge); } + } + // Vertex-figure rungs: link the two faces' copies of each shared endpoint. + topo.for_each_interior_edge(|f, a, b, g| { + distance.connect([corner(f, a), corner(g, a)]); + distance.connect([corner(f, b), corner(g, b)]); + }); - //self.positions.insert(v, vpos / cycle.len() as f32); + // Each original face persists as its corner-copy n-gon, keeping its id. + let mut new_cycles: Vec> = c.clone(); + let mut new_ids: Vec = topo.ids.clone(); + // Each original edge spawns a quad, interleaved so each face's copy pair stays adjacent. + topo.for_each_interior_edge(|f, a, b, g| { + new_cycles.push(vec![corner(f, a), corner(f, b), corner(g, b), corner(g, a)]); + new_ids.push(self.fresh_face_id()); + }); + // Each original vertex spawns its vertex-figure by walking the faces around v. + for v in 0..self.order() { + let f0 = (0..topo.cycles.len()) + .find(|&f| topo.pos[f].contains_key(&v)) + .expect("vertex belongs to no face"); + let mut figure = Vec::new(); + let mut f = f0; + // Enter f0 via its edge (prev, v); the walk exits via v's other edge each step. + let mut entry = { + let cyc = &topo.cycles[f0]; + let k = topo.pos(f0, v); + undirected(cyc[(k + cyc.len() - 1) % cyc.len()], v) + }; + loop { + figure.push(corner(f, v)); + let cyc = &topo.cycles[f]; + let k = topo.pos(f, v); + let next = cyc[(k + 1) % cyc.len()]; + let prev = cyc[(k + cyc.len() - 1) % cyc.len()]; + // Exit via whichever of v's two edges in f we didn't enter through. + let exit = if undirected(next, v) == entry { + undirected(prev, v) + } else { + undirected(next, v) + }; + f = topo + .other_face(f, exit[0], exit[1]) + .expect("open edge at vertex figure"); + entry = exit; + if f == f0 { + break; + } + } + new_cycles.push(figure); + new_ids.push(self.fresh_face_id()); } - self.recompute(); - edges + self.distance = distance; + self.install_cycles(new_cycles, new_ids); + self.assert_cycles_match_discovery(); + (parents, face_edges) } pub fn chamfer(&mut self) { let originals = self.edges().collect::>(); - for cycle in self.cycles.iter() { - let mut new_face = vec![]; - for &v in cycle.iter() { - let u = self.distance.insert_from(&[v]); - new_face.push(u); - self.distance.connect([v, u]); - } - for i in 0..new_face.len() { - self.distance - .connect([new_face[i], new_face[(i + 1) % new_face.len()]]); + let topo = FaceTopology::snapshot(&self.cycles); + + // Shrink each face: one new vertex per corner, tethered to the original and ringed together. + let mut c: Vec> = Vec::with_capacity(topo.cycles.len()); + for cycle in &topo.cycles { + let row: Vec = cycle + .iter() + .map(|&v| { + let u = self.distance.insert(); + self.distance.connect([v, u]); + u + }) + .collect(); + for k in 0..row.len() { + self.distance.connect([row[k], row[(k + 1) % row.len()]]); } + c.push(row); } for edge in originals { self.distance.disconnect(edge); } - self.recompute(); + // The shrunk copy at face `f`'s corner for vertex `v`. + let corner = |f: usize, v: VertexId| c[f][topo.pos(f, v)]; + + // Each original face persists as its shrunk copy, keeping its id. + let mut new_cycles: Vec> = c.clone(); + let mut new_ids: Vec = topo.ids.clone(); + // Each original edge spawns a hexagon through both faces' shrunk copies. + topo.for_each_interior_edge(|f, a, b, g| { + new_cycles.push(vec![ + a, + corner(f, a), + corner(f, b), + b, + corner(g, b), + corner(g, a), + ]); + new_ids.push(self.fresh_face_id()); + }); + + self.install_cycles(new_cycles, new_ids); + self.assert_cycles_match_discovery(); } } diff --git a/src/polyhedron/shape/cycles/cycle.rs b/src/polyhedron/shape/cycles/cycle.rs index 2ad7962..6850a91 100644 --- a/src/polyhedron/shape/cycles/cycle.rs +++ b/src/polyhedron/shape/cycles/cycle.rs @@ -28,80 +28,35 @@ impl Cycle { self.0.len() } - #[allow(dead_code)] - pub fn delete(&mut self, v: VertexId) { - self.0 = self - .0 - .clone() - .into_iter() - .filter_map(|u| { - use std::cmp::Ordering::*; - match v.cmp(&u) { - Equal => None, - Less => Some(u - 1), - Greater => Some(u), - } - }) - .collect::>(); - } - - #[allow(dead_code)] - pub fn replace(&mut self, old: VertexId, new: VertexId) { - self.0 = self - .0 - .clone() - .into_iter() - .filter_map(|v| { - if v == new { - None - } else if v == old { - Some(new) - } else { - Some(v) - } - }) - .collect(); + /// Merges deleted `v` into survivor `u < v`, shifting higher indices down and collapsing consecutive duplicates. + /// Returns whether the face survives with at least 3 vertices. + pub fn contract_vertex(&mut self, v: VertexId, u: VertexId) -> bool { + debug_assert!(u < v, "survivor must be the lower index"); + let mut out: Vec = Vec::with_capacity(self.0.len()); + for &x in &self.0 { + let x = match x { + x if x == v => u, + x if x > v => x - 1, + x => x, + }; + if out.last() != Some(&x) { + out.push(x); + } + } + while out.len() > 1 && out.first() == out.last() { + out.pop(); + } + // A non-consecutive duplicate means the contract set pinched a face, which no operation should produce. + debug_assert!( + out.len() < 3 + || out.iter().collect::>().len() == out.len(), + "contraction pinched a face: {out:?}" + ); + self.0 = out; + self.0.len() >= 3 } pub fn iter(&self) -> std::slice::Iter<'_, usize> { self.0.iter() } - - #[allow(dead_code)] - pub fn contains(&self, v: &VertexId) -> bool { - self.0.contains(v) - } - - #[allow(dead_code)] - pub fn push(&mut self, v: VertexId) { - self.0.push(v); - } -} - -impl From> for Cycle { - fn from(mut edges: Vec<[VertexId; 2]>) -> Self { - let mut first = false; - let mut face = vec![edges[0][0]]; - while !edges.is_empty() { - let v = if first { - *face.first().unwrap() - } else { - *face.last().unwrap() - }; - if let Some(i) = edges.iter().position(|e| e.contains(&v)) { - let next = if edges[i][0] == v { - edges[i][1] - } else { - edges[i][0] - }; - if !face.contains(&next) { - face.push(next); - } - edges.remove(i); - } else { - first ^= true; - } - } - Self(face) - } } diff --git a/src/polyhedron/shape/cycles/mod.rs b/src/polyhedron/shape/cycles/mod.rs index d2742f4..d90b138 100644 --- a/src/polyhedron/shape/cycles/mod.rs +++ b/src/polyhedron/shape/cycles/mod.rs @@ -1,5 +1,6 @@ mod cycle; -use crate::{polyhedron::VertexId, render::pipeline::ShapeVertex}; +use crate::polyhedron::{FaceId, VertexId}; +use crate::render::pipeline::ShapeVertex; pub use cycle::*; use std::{ collections::{HashMap, HashSet}, @@ -13,16 +14,66 @@ use super::Distance; pub(in super::super) struct Cycles { // Circular lists of Vertex Ids representing faces cycles: Vec, + /// Stable identity per face, parallel to `cycles`; survives sorts and operations. + ids: Vec, } impl Cycles { - pub fn new(cycles: Vec>) -> Self { + pub fn new(cycles: Vec>, ids: Vec) -> Self { + debug_assert_eq!(cycles.len(), ids.len()); Self { cycles: cycles.into_iter().map(Cycle).collect(), + ids, } } - #[allow(dead_code)] + /// Stable face ids, parallel to the cycle list. + pub fn ids(&self) -> &[FaceId] { + &self.ids + } + + /// Canonical face order: more sides first, then a more uniform neighborhood, then sorted vertices. + /// Total on these polyhedra (distinct faces have distinct vertex sets), so face 0 is deterministic. + pub fn sort(&mut self) { + let raw: Vec> = self.cycles.iter().map(|c| c.0.clone()).collect(); + let neighbor_uniformity: Vec = neighbor_type_signatures(&raw) + .iter() + .map(|sig| sig.iter().collect::>().len()) + .collect(); + let mut scored: Vec<(Cycle, FaceId, usize)> = std::mem::take(&mut self.cycles) + .into_iter() + .zip(std::mem::take(&mut self.ids)) + .zip(neighbor_uniformity) + .map(|((c, id), u)| (c, id, u)) + .collect(); + scored.sort_by_key(|(c, _, uniformity)| { + let mut sorted_vertices = c.0.clone(); + sorted_vertices.sort(); + (usize::MAX - c.len(), *uniformity, sorted_vertices) + }); + for (c, id, _) in scored { + self.cycles.push(c); + self.ids.push(id); + } + } + + /// Rediscovers faces from the distance matrix, minting fresh ids. + /// Only seed construction and the `release` fallback use this, operations build their cycles explicitly. + pub(super) fn discover(distance: &Distance, next_face_id: &mut FaceId) -> Self { + let raw = chordless_cycles(distance); + let ids = raw + .iter() + .map(|_| { + let id = *next_face_id; + *next_face_id += 1; + id + }) + .collect(); + let mut cycles = Cycles::new(raw, ids); + cycles.sort(); + cycles + } + pub fn len(&self) -> usize { self.cycles.len() } @@ -30,11 +81,6 @@ impl Cycles { pub fn iter(&self) -> std::slice::Iter<'_, Cycle> { self.cycles.iter() } - - #[allow(dead_code)] - pub fn into_iter(self) -> std::vec::IntoIter { - self.cycles.into_iter() - } /// Returns the pub fn sorted_connections(&self, v: VertexId) -> Vec { // We only care about cycles that contain the vertex @@ -125,19 +171,21 @@ impl IndexMut for Cycles { } impl Cycles { - #[allow(dead_code)] - pub fn delete(&mut self, v: VertexId) { - for cycle in &mut self.cycles { - cycle.delete(v); - } - } - - /// Replace all occurrence of one vertex with another - #[allow(dead_code)] - pub fn replace(&mut self, old: VertexId, new: VertexId) { - for cycle in &mut self.cycles { - cycle.replace(old, new); - } + /// Replays the same merge sequence as `Distance::contract_edges` on the face list. + /// Survivors keep their ids and faces that degenerate below 3 vertices are dropped. + pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) { + crate::polyhedron::contract_edge_indices(edges, |v, u| { + // Merge `v` into `u` in every face. + // Rebuild in a single pass; keep each surviving cycle paired with its id. + let cycles = std::mem::take(&mut self.cycles); + let ids = std::mem::take(&mut self.ids); + for (mut cycle, id) in cycles.into_iter().zip(ids) { + if cycle.contract_vertex(v, u) { + self.cycles.push(cycle); + self.ids.push(id); + } + } + }); } } @@ -173,65 +221,47 @@ fn neighbor_type_signatures(cycles: &[Vec]) -> Vec> { .collect() } -impl From<&Distance> for Cycles { - fn from(distance: &Distance) -> Self { - let mut triplets: Vec> = Default::default(); - let mut cycles: HashSet> = Default::default(); - // find all the triplets - for u in 0..distance.order() { - for x in (u + 1)..distance.order() { - for y in (x + 1)..distance.order() { - if distance[[u, x]] == 1 && distance[[u, y]] == 1 { - if distance[[x, y]] == 1 { - cycles.insert(vec![x, u, y]); - } else { - triplets.push(vec![x, u, y]); - } +/// Chordless-cycle face search over the distance matrix; expensive, unordered output. +fn chordless_cycles(distance: &Distance) -> Vec> { + let mut triplets: Vec> = Default::default(); + let mut cycles: HashSet> = Default::default(); + // find all the triplets + for u in 0..distance.order() { + for x in (u + 1)..distance.order() { + for y in (x + 1)..distance.order() { + if distance[[u, x]] == 1 && distance[[u, y]] == 1 { + if distance[[x, y]] == 1 { + cycles.insert(vec![x, u, y]); + } else { + triplets.push(vec![x, u, y]); } } } } + } - // while there are unparsed triplets - while !triplets.is_empty() && (cycles.len() as i64) < distance.face_count() { - let p = triplets.remove(0); - - // for each v adjacent to u_t - for v in distance.neighbors(p[p.len() - 1]) { - if v > p[1] { - let adj_v = distance.neighbors(v); - // if v is not a neighbor of u_2..u_t-1 - if !p[1..p.len() - 1].iter().any(|i| adj_v.contains(i)) { - let new = [p.clone(), vec![v]].concat(); - if distance.neighbors(p[0]).contains(&v) { - if distance.cycle_is_face(new.clone()) { - cycles.insert(new); - } - } else { - triplets.push(new); + // while there are unparsed triplets + while !triplets.is_empty() && (cycles.len() as i64) < distance.face_count() { + let p = triplets.remove(0); + + // for each v adjacent to u_t + for v in distance.neighbors(p[p.len() - 1]) { + if v > p[1] { + let adj_v = distance.neighbors(v); + // if v is not a neighbor of u_2..u_t-1 + if !p[1..p.len() - 1].iter().any(|i| adj_v.contains(i)) { + let new = [p.clone(), vec![v]].concat(); + if distance.neighbors(p[0]).contains(&v) { + if distance.cycle_is_face(new.clone()) { + cycles.insert(new); } + } else { + triplets.push(new); } } } } - - let cycles = cycles.into_iter().collect::>(); - - // Fewer distinct neighbor side-counts means a more locally symmetric/uniform face. - let neighbor_uniformity: Vec = neighbor_type_signatures(&cycles) - .iter() - .map(|sig| sig.iter().collect::>().len()) - .collect(); - - let mut scored: Vec<(Vec, usize)> = - cycles.into_iter().zip(neighbor_uniformity).collect(); - // Prefer more sides, then a more uniform neighborhood, then a deterministic tie-break. - scored.sort_by_key(|(c, uniformity)| { - let mut sorted_vertices = c.clone(); - sorted_vertices.sort(); - (usize::MAX - c.len(), *uniformity, sorted_vertices) - }); - let cycles: Vec> = scored.into_iter().map(|(c, _)| c).collect(); - Cycles::new(cycles) } + + cycles.into_iter().collect() } diff --git a/src/polyhedron/shape/distance/conway.rs b/src/polyhedron/shape/distance/conway.rs index b6c0ce7..ecf5e8b 100644 --- a/src/polyhedron/shape/distance/conway.rs +++ b/src/polyhedron/shape/distance/conway.rs @@ -9,32 +9,14 @@ impl Distance { self.connect([w, u]); self.disconnect([w, v]); } - // u now represents both original vertices - let absorbed = self.ancestors(v).clone(); - self.ancestors[u].extend(absorbed); - // Delete v + // Delete v; u now represents both original vertices self.delete(v); } - pub fn contract_edges(&mut self, mut edges: Vec<[VertexId; 2]>) { - while !edges.is_empty() { - // Pop an edge - let [w, x] = edges.remove(0); - let v = w.max(x); - let u = w.min(x); - - // Contract [v, u], deleting v + pub fn contract_edges(&mut self, edges: Vec<[VertexId; 2]>) { + crate::polyhedron::contract_edge_indices(edges, |v, u| { self.contract_edge([v, u]); - // Decrement the value of every vertex - for [x, w] in &mut edges { - if *x > v { - *x -= 1; - } - if *w > v { - *w -= 1; - } - } - } + }); } pub fn split_vertex(&mut self, v: VertexId, connections: Vec) -> Vec<[VertexId; 2]> { @@ -42,7 +24,7 @@ impl Distance { let new_cycle: Cycle = Cycle::from( vec![v] .into_iter() - .chain((1..connections.len()).map(|_| self.insert_from(&[v]))) + .chain((1..connections.len()).map(|_| self.insert())) .collect(), ); diff --git a/src/polyhedron/shape/distance/mod.rs b/src/polyhedron/shape/distance/mod.rs index d57917f..900e928 100644 --- a/src/polyhedron/shape/distance/mod.rs +++ b/src/polyhedron/shape/distance/mod.rs @@ -20,10 +20,6 @@ pub(super) struct Distance { /// The order is the number of vertices order: usize, distance: Vec, - /// Tags each vertex descends from; unioned into the survivor on a merge, copied to every copy on a split. - ancestors: Vec>, - /// Next never-yet-used tag, for genuinely new vertices only. - next_tag: u64, } impl PartialEq for Distance { @@ -44,8 +40,6 @@ impl Distance { distance: (0..n) .flat_map(|m| [vec![usize::MAX; m], vec![0]].concat()) .collect(), - ancestors: (0..n as u64).map(|tag| HashSet::from([tag])).collect(), - next_tag: n as u64, } } } @@ -65,43 +59,14 @@ impl Distance { } } - /// Inserts a new, genuinely-unrelated vertex in the matrix, with a fresh ancestor tag. - /// TODO: determine if we still even want this now that we're doing insert_from - #[allow(dead_code)] + /// Inserts a new vertex in the matrix. pub fn insert(&mut self) -> VertexId { - let v = self.insert_from(&[]); - self.ancestors[v].insert(self.next_tag); - self.next_tag += 1; - v - } - - /// Inserts a new vertex that's a copy of `parents`, inheriting the union of their ancestor sets. - pub fn insert_from(&mut self, parents: &[VertexId]) -> VertexId { self.distance .extend([vec![usize::MAX; self.order], vec![0]].concat()); self.order += 1; - let ancestors = parents.iter().fold(HashSet::new(), |mut acc, &p| { - acc.extend(&self.ancestors[p]); - acc - }); - self.ancestors.push(ancestors); self.order - 1 } - /// The set of persistent tags a vertex descends from. - pub fn ancestors(&self, v: VertexId) -> &HashSet { - &self.ancestors[v] - } - - /// 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); @@ -114,14 +79,6 @@ impl Distance { } } } - distance.ancestors = self - .ancestors - .iter() - .enumerate() - .filter(|&(i, _)| i != v) - .map(|(_, set)| set.clone()) - .collect(); - distance.next_tag = self.next_tag; *self = distance; } diff --git a/src/polyhedron/shape/distance/test.rs b/src/polyhedron/shape/distance/test.rs index 93c3686..2c108f7 100644 --- a/src/polyhedron/shape/distance/test.rs +++ b/src/polyhedron/shape/distance/test.rs @@ -90,6 +90,25 @@ fn contract_edge() { assert_eq!(graph, triangle); } +#[test] +fn contract_cycle_collapses_to_point() { + // A square attached to an outside vertex; contracting the whole 4-cycle must + // collapse it to one vertex still joined to the outsider, with no self-loop. + let mut graph = Distance::new(5); + graph.connect([0, 1]); + graph.connect([1, 2]); + graph.connect([2, 3]); + graph.connect([3, 0]); + graph.connect([0, 4]); // tail to an outside vertex + + graph.contract_edges(vec![[0, 1], [1, 2], [2, 3], [3, 0]]); + + // Four cycle vertices merged into one; the outsider survives as its neighbor. + assert_eq!(graph.order(), 2); + assert_eq!(graph.edges().count(), 1); + assert_eq!(graph[[0, 0]], 0, "no self-loop on the survivor"); +} + #[test] fn bfs_apsp() { let mut distance = Distance::new(4); diff --git a/src/polyhedron/shape/mod.rs b/src/polyhedron/shape/mod.rs index c6f9541..829ea2e 100644 --- a/src/polyhedron/shape/mod.rs +++ b/src/polyhedron/shape/mod.rs @@ -2,7 +2,8 @@ mod conway; mod cycles; mod distance; mod platonic; -use std::{collections::HashSet, fmt::Display, ops::Range}; +mod topology; +use std::{fmt::Display, ops::Range}; use cycles::*; use distance::*; @@ -21,6 +22,10 @@ pub(super) struct Shape { pub cycles: Cycles, /// Faces / chordless cycles pub springs: Vec<[VertexId; 2]>, + /// Next never-yet-used face id, for genuinely new faces only. + next_face_id: FaceId, + /// Fresh face id mapped to the face it was carved from, consumed and cleared by the color finalize. + pub birth_parents: std::collections::HashMap, } impl PartialEq for Shape { @@ -58,36 +63,62 @@ impl Shape { self.distance.vertices() } - /// Union of a face's vertices' ancestor sets. - fn face_ancestors(&self, face_index: usize) -> HashSet { - self.cycles[face_index] - .iter() - .fold(HashSet::new(), |mut acc, &v| { - acc.extend(self.distance.ancestors(v)); - acc - }) - } - - pub fn ancestors(&self) -> Vec> { - (0..self.cycles.len()) - .map(|i| self.face_ancestors(i)) - .collect() - } - - /// Wipes vertex ancestry back to a fresh singleton tag per current vertex; see `Distance::reset_ancestry`. - pub fn reset_ancestry(&mut self) { - self.distance.reset_ancestry(); + pub fn recompute(&mut self) { + // Find and save cycles + self.cycles = Cycles::discover(&self.distance, &mut self.next_face_id); + self.recompute_metrics(); } - pub fn recompute(&mut self) { + /// Recomputes distances and springs but not faces, for operations that build their cycles explicitly. + pub fn recompute_metrics(&mut self) { // Update the distance matrix in place self.distance.bfs_apsp(); - // Find and save cycles - self.cycles = Cycles::from(&self.distance); // Find and save springs self.springs = self.distance.springs(); } + /// Mints the next never-yet-used face id. + fn fresh_face_id(&mut self) -> FaceId { + let id = self.next_face_id; + self.next_face_id += 1; + id + } + + /// Installs an explicitly-built face list: canonically sort it and refresh derived metrics. + /// Callers that maintain the discovery invariant should follow with `assert_cycles_match_discovery`. + fn install_cycles(&mut self, cycles: Vec>, ids: Vec) { + self.cycles = Cycles::new(cycles, ids); + self.cycles.sort(); + self.recompute_metrics(); + } + + /// Debug oracle asserting operation-built cycles equal discovery's, as canonicalized faces in order. + /// This replaces the self-healing that per-op rediscovery used to provide. + pub fn assert_cycles_match_discovery(&self) { + #[cfg(debug_assertions)] + { + let canonical = |cycles: &Cycles| -> Vec> { + cycles + .iter() + .map(|c| { + let mut vs: Vec = c.iter().copied().collect(); + vs.sort_unstable(); + vs + }) + .collect() + }; + let mut scratch_id = 0; + let discovered = Cycles::discover(&self.distance, &mut scratch_id); + assert_eq!( + canonical(&self.cycles), + canonical(&discovered), + "operation-built cycles diverge from discovery" + ); + } + } + + /// Edge removal still falls back to full rediscovery, so face ids and colors reset here. + /// Only the unfinished Join operation uses it; switch to explicit cycle splicing when Join lands. pub fn release(&mut self, edges: &[[VertexId; 2]]) { for &edge in edges { self.distance.disconnect(edge); diff --git a/src/polyhedron/shape/test.rs b/src/polyhedron/shape/test.rs index 8e7e939..beec8c6 100644 --- a/src/polyhedron/shape/test.rs +++ b/src/polyhedron/shape/test.rs @@ -6,6 +6,211 @@ impl Shape { } } +#[test] +fn truncate_keeps_face_ids_on_2n_gons() { + let mut cube = Shape::prism(4); + let old: Vec<(FaceId, usize)> = cube + .cycles + .ids() + .iter() + .zip(cube.cycles.iter()) + .map(|(&id, c)| (id, c.len())) + .collect(); + + cube.truncate(); + + // Every original face survives under its id, with doubled side count. + for (id, n) in old { + let i = cube + .cycles + .ids() + .iter() + .position(|&x| x == id) + .unwrap_or_else(|| panic!("face id {id} lost by truncation")); + assert_eq!(cube.cycles[i].len(), 2 * n, "2n-gon side count for id {id}"); + } +} + +#[test] +fn expand_keeps_face_ids_on_corner_copies() { + let mut cube = Shape::prism(4); + let old: Vec<(FaceId, usize)> = cube + .cycles + .ids() + .iter() + .zip(cube.cycles.iter()) + .map(|(&id, c)| (id, c.len())) + .collect(); + + cube.expand(); + + // Every original face survives under its id, same side count (its corner-copy n-gon). + for (id, n) in old { + let i = cube + .cycles + .ids() + .iter() + .position(|&x| x == id) + .unwrap_or_else(|| panic!("face id {id} lost by expansion")); + assert_eq!( + cube.cycles[i].len(), + n, + "corner-copy side count for id {id}" + ); + } +} + +#[test] +fn op_chains_match_discovery() { + // The debug oracle inside each op asserts cycles match discovery on every chain here. + for seed in [ + Shape::pyramid(3), + Shape::prism(4), + Shape::anti_prism(3), + Shape::anti_prism(5), + ] { + let mut s = seed.clone(); + s.truncate(); + s.expand(); + + // Dual chain: expand, then contract the face-figure edges. + let mut s = seed.clone(); + let (_, face_edges) = s.expand(); + s.contract_edges(face_edges); + + // Double dual returns to the seed's face counts. + let (_, face_edges) = s.expand(); + s.contract_edges(face_edges); + assert_eq!(s.cycles.len(), seed.cycles.len(), "dd face count"); + } +} + +#[test] +fn chamfer_cube_counts_and_ids() { + let mut cube = Shape::prism(4); + let old_ids: Vec = cube.cycles.ids().to_vec(); + + cube.chamfer(); + + // Chamfered cube: V = 8 + 2E = 32, E = 4E = 48, F = 6 + 12 = 18. + assert_eq!(cube.order(), 32, "vertex count"); + assert_eq!(cube.edges().count(), 48, "edge count"); + assert_eq!(cube.cycles.len(), 18, "face count"); + // Original faces persist (shrunk) under their ids, still squares. + for id in old_ids { + let i = cube.cycles.ids().iter().position(|&x| x == id).unwrap(); + assert_eq!(cube.cycles[i].len(), 4, "shrunk face keeps side count"); + } + let hexes = cube.cycles.iter().filter(|c| c.len() == 6).count(); + assert_eq!(hexes, 12, "one hexagon per original edge"); +} + +#[test] +fn kis_children_record_their_parent() { + let mut tetra = Shape::pyramid(3); + let old_ids: Vec = tetra.cycles.ids().to_vec(); + + tetra.kis(None); + + // Kis tetrahedron: every face is a fresh triangle carved from an original face. + assert_eq!(tetra.cycles.len(), 12, "face count"); + for &id in tetra.cycles.ids() { + let parent = tetra.birth_parents.get(&id); + assert!( + parent.is_some_and(|p| old_ids.contains(p)), + "face {id} must record an original parent" + ); + } +} + +#[test] +fn contract_face_ring_keeps_survivor_ids() { + // Contracting a whole cube face ring chains merges until its last edge degenerates to [u, u]. + // The face collapses to a point, leaving a square pyramid. + let mut cube = Shape::prism(4); + let ring: Vec<[VertexId; 2]> = { + let cycle = &cube.cycles[0]; + (0..cycle.len()).map(|i| [cycle[i], cycle[i + 1]]).collect() + }; + let survivor_ids: Vec = cube + .cycles + .ids() + .iter() + .copied() + .skip(1) // face 0 is the one being collapsed + .collect(); + + cube.contract_edges(ring); + + // Square pyramid: V=5, E=8, F=5 (the opposite square + 4 side squares pinched to triangles). + assert_eq!(cube.order(), 5, "vertex count"); + assert_eq!(cube.edges().count(), 8, "edge count"); + assert_eq!(cube.cycles.len(), 5, "face count"); + // The collapsed face's id died; every other face survived with its id intact. + let mut expected = survivor_ids; + expected.sort_unstable(); + let mut actual: Vec = cube.cycles.ids().to_vec(); + actual.sort_unstable(); + assert_eq!(actual, expected, "survivors keep their face ids"); +} + +#[test] +fn expand_cube() { + let mut cube = Shape::prism(4); + assert_eq!(cube.order(), 8); + assert_eq!(cube.edges().count(), 12); + assert_eq!(cube.cycles.len(), 6); + + cube.expand(); + + // Rhombicuboctahedron: V=24, E=48, F=26 (6 squares + 8 triangles + 12 squares). + assert_eq!(cube.order(), 24, "vertex count"); + assert_eq!(cube.edges().count(), 48, "edge count"); + assert_eq!(cube.cycles.len(), 26, "face count"); + // Euler characteristic. + assert_eq!( + cube.order() as i64 - cube.edges().count() as i64 + cube.cycles.len() as i64, + 2 + ); + // Every vertex has degree 4 in a cantellation. + for v in cube.vertices() { + assert_eq!(cube.degree(v), 4, "vertex {v} degree"); + } + // Face multiset: triangles + quads only, in the expected counts. + let tris = cube.cycles.iter().filter(|c| c.len() == 3).count(); + let quads = cube.cycles.iter().filter(|c| c.len() == 4).count(); + assert_eq!(tris, 8, "triangle faces"); + assert_eq!(quads, 18, "quad faces"); +} + +#[test] +fn truncate_cube() { + let mut cube = Shape::prism(4); + assert_eq!(cube.order(), 8); + assert_eq!(cube.edges().count(), 12); + assert_eq!(cube.cycles.len(), 6); + + cube.truncate(); + + // Truncated cube: V=24, E=36, F=14 (8 triangles + 6 octagons). + assert_eq!(cube.order(), 24, "vertex count"); + assert_eq!(cube.edges().count(), 36, "edge count"); + assert_eq!(cube.cycles.len(), 14, "face count"); + // Euler characteristic. + assert_eq!( + cube.order() as i64 - cube.edges().count() as i64 + cube.cycles.len() as i64, + 2 + ); + // Every vertex has degree 3 in a truncation. + for v in cube.vertices() { + assert_eq!(cube.degree(v), 3, "vertex {v} degree"); + } + let tris = cube.cycles.iter().filter(|c| c.len() == 3).count(); + let octs = cube.cycles.iter().filter(|c| c.len() == 8).count(); + assert_eq!(tris, 8, "triangle faces"); + assert_eq!(octs, 6, "octagon faces"); +} + #[test] #[ignore] fn split_vertex_contract() { diff --git a/src/polyhedron/shape/topology.rs b/src/polyhedron/shape/topology.rs new file mode 100644 index 0000000..e66f74a --- /dev/null +++ b/src/polyhedron/shape/topology.rs @@ -0,0 +1,81 @@ +use super::Cycles; +use crate::polyhedron::{FaceId, VertexId}; +use std::collections::{HashMap, HashSet}; + +/// Order-independent key for an undirected edge. +/// TODO: maybe we should rewrite this as a custom type +pub(super) fn undirected(a: VertexId, b: VertexId) -> [VertexId; 2] { + if a < b { [a, b] } else { [b, a] } +} + +/// Read-only snapshot of a shape's faces taken at the start of a Conway operation +pub(super) struct FaceTopology { + /// The original faces + pub(super) cycles: Vec>, + /// The IDs of those original faces + pub(super) ids: Vec, + /// Vertex `v` in face `f` = pos[f][&v] + pub(super) pos: Vec>, + /// Undirected original edge bordering face indices + edge_faces: HashMap<[VertexId; 2], Vec>, +} + +impl FaceTopology { + pub(super) fn snapshot(cycles: &Cycles) -> Self { + let ids = cycles.ids().to_vec(); + let cycles: Vec> = + cycles.iter().map(|c| c.iter().copied().collect()).collect(); + let mut pos: Vec> = Vec::with_capacity(cycles.len()); + let mut edge_faces: HashMap<[VertexId; 2], Vec> = HashMap::new(); + for (f, cycle) in cycles.iter().enumerate() { + let n = cycle.len(); + let mut row = HashMap::with_capacity(n); + for k in 0..n { + row.insert(cycle[k], k); + edge_faces + .entry(undirected(cycle[k], cycle[(k + 1) % n])) + .or_default() + .push(f); + } + pos.push(row); + } + Self { + cycles, + ids, + pos, + edge_faces, + } + } + + /// Index of vertex `v` within face `f`. + pub(super) fn pos(&self, f: usize, v: VertexId) -> usize { + self.pos[f][&v] + } + + /// The face across `edge` from `f`, if `edge` is interior aka borders exactly two faces. + pub(super) fn other_face(&self, f: usize, a: VertexId, b: VertexId) -> Option { + let faces = self.edge_faces.get(&undirected(a, b))?; + (faces.len() == 2).then(|| if faces[0] == f { faces[1] } else { faces[0] }) + } + + /// Visits each interior original edge exactly once in face order then corner order. + /// Yields the face `f` it was found in, its endpoints `a,b` in `f`'s winding, and the opposite face `g`. + pub(super) fn for_each_interior_edge( + &self, + mut visit: impl FnMut(usize, VertexId, VertexId, usize), + ) { + let mut seen: HashSet<[VertexId; 2]> = HashSet::new(); + // For each face + for (f, cycle) in self.cycles.iter().enumerate() { + let n = cycle.len(); + for k in 0..n { + let (a, b) = (cycle[k], cycle[(k + 1) % n]); + if seen.insert(undirected(a, b)) + && let Some(g) = self.other_face(f, a, b) + { + visit(f, a, b, g); + } + } + } + } +} diff --git a/src/polyhedron/test.rs b/src/polyhedron/test.rs index d6939e3..b725988 100644 --- a/src/polyhedron/test.rs +++ b/src/polyhedron/test.rs @@ -1,6 +1,5 @@ use super::*; -use crate::render::message::PresetMessage::{self, *}; -use std::fs::create_dir_all; +use crate::render::message::PresetMessage::*; // impl Polyhedron {} @@ -9,14 +8,14 @@ use test_case::test_case; #[test_case(Polyhedron::preset(&Pyramid(3)); "T")] #[test_case(Polyhedron::preset(&Prism(4)); "C")] #[test_case(Polyhedron::preset(&Octahedron); "O")] -// #[test_case(Polyhedron::preset(&Dodecahedron); "D")] +#[test_case(Polyhedron::preset(&Dodecahedron); "D")] #[test_case(Polyhedron::preset(&Icosahedron); "I")] // #[test_case({ let mut g = Polyhedron::preset(&Prism(4)); g.truncate(0); g} ; "tC")] // #[test_case({ let mut g = Polyhedron::preset(&Octahedron); g.truncate(0); g} ; "tO")] // #[test_case({ let mut g = Polyhedron::preset(&Dodecahedron); g.truncate(0); g} ; "tD")] fn polytope_apsp(poly: Polyhedron) { let mut bfs = poly.clone(); - bfs.shape.recompute(); + bfs.shape.recompute_metrics(); let mut floyd = poly.clone(); floyd.shape.floyd(); assert_eq!(bfs.shape, poly.shape); @@ -33,21 +32,42 @@ fn truncate_contract() { } #[test] -#[ignore] fn ambo() { - use PresetMessage::*; - let prefix = "tests/ambo/"; - create_dir_all(prefix).unwrap(); + // Ambo tetrahedron == octahedron; exercises the one-shot truncate + contraction. let mut polyhedron = Polyhedron::preset(&Pyramid(3)); polyhedron.ambo_contract(); let octahedron = Polyhedron::preset(&Octahedron); assert_eq!(polyhedron.shape, octahedron.shape); } +#[test] +fn ambo_cube_gives_cuboctahedron() { + // Ambo cube: V=12, E=24, F=14 (8 triangles + 6 squares). + let mut polyhedron = Polyhedron::preset(&Prism(4)); + polyhedron.ambo_contract(); + assert_eq!(polyhedron.shape.order(), 12, "vertex count"); + assert_eq!(polyhedron.shape.edges().count(), 24, "edge count"); + assert_eq!(polyhedron.shape.cycles.len(), 14, "face count"); + assert_eq!( + polyhedron.render.positions.len(), + 12, + "render stays in sync" + ); +} + fn apply_ambo(polyhedron: &mut Polyhedron) { - polyhedron.cache_faces(); polyhedron.ambo_contract(); - polyhedron.reconcile_face_colors(); + polyhedron.finalize_face_colors(); +} + +fn apply_expand(polyhedron: &mut Polyhedron) { + polyhedron.expand(); + polyhedron.finalize_face_colors(); +} + +fn apply_truncate(polyhedron: &mut Polyhedron) { + polyhedron.truncate(0); + polyhedron.finalize_face_colors(); } /// Every face sharing a `FaceTypeSignature` must share a color. @@ -68,14 +88,23 @@ fn assert_uniform_colors_per_facetype(polyhedron: &Polyhedron) { } } -/// The current color for a specific facetype; panics if no face currently has that signature. -fn color_for_signature(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> usize { - let signatures = polyhedron.face_signatures(); - let i = signatures +/// Index of the first face with the given signature; panics if none matches. +fn signature_index(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> usize { + polyhedron + .face_signatures() .iter() .position(|sig| sig == target) - .unwrap_or_else(|| panic!("no face with signature {target:?}")); - polyhedron.face_coloring.colors[i] + .unwrap_or_else(|| panic!("no face with signature {target:?}")) +} + +/// The current color slot for a specific facetype. +fn color_for_signature(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> usize { + polyhedron.face_coloring.colors[signature_index(polyhedron, target)] +} + +/// The rendered palette index (what the UI actually shows) for a specific facetype. +fn render_index_for_signature(polyhedron: &Polyhedron, target: &FaceTypeSignature) -> usize { + polyhedron.face_coloring.render_indices[signature_index(polyhedron, target)] } #[test] @@ -126,6 +155,330 @@ fn ambo_twice_preserves_facetype_colors() { assert_ne!(vertex_figure_color, square_color); } +#[test] +fn expand_preserves_facetype_colors() { + // cube ("C"); every square borders four squares. + let mut polyhedron = Polyhedron::preset(&Prism(4)); + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![4, 4, 4, 4], + }; + let square_color = color_for_signature(&polyhedron, &square); + + // rhombicuboctahedron ("eC") + apply_expand(&mut polyhedron); + assert_uniform_colors_per_facetype(&polyhedron); + + // Original faces persist as squares bordered by squares; must keep their color via ancestry. + assert_eq!(color_for_signature(&polyhedron, &square), square_color); + + // Genuinely new facetypes must be distinct from the persisting square. + let vertex_figure = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![4, 4, 4], + }; + let edge_quad = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 4, 4], + }; + assert_ne!( + color_for_signature(&polyhedron, &vertex_figure), + square_color + ); + assert_ne!(color_for_signature(&polyhedron, &edge_quad), square_color); +} + +#[test] +fn truncate_preserves_facetype_colors() { + // cube ("C"); every square borders four squares. + let mut polyhedron = Polyhedron::preset(&Prism(4)); + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![4, 4, 4, 4], + }; + let square_color = color_for_signature(&polyhedron, &square); + + // truncated cube ("tC") + apply_truncate(&mut polyhedron); + assert_uniform_colors_per_facetype(&polyhedron); + + // Each original square becomes an octagon bordered by 4 triangles + 4 octagons; + // it must keep the square's color via ancestry. + let octagon = FaceTypeSignature { + side_count: 8, + neighbor_sides: vec![3, 3, 3, 3, 8, 8, 8, 8], + }; + assert_eq!(color_for_signature(&polyhedron, &octagon), square_color); + + // Vertex-figure triangles are a genuinely new facetype; must differ. + let triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![8, 8, 8], + }; + assert_ne!(color_for_signature(&polyhedron, &triangle), square_color); +} + +/// The color slot of the first face with the given side count; panics if none matches. +fn color_by_side(polyhedron: &Polyhedron, side: usize) -> usize { + let i = polyhedron + .shape + .cycles + .iter() + .position(|c| c.len() == side) + .unwrap_or_else(|| panic!("no face with {side} sides")); + polyhedron.face_coloring.colors[i] +} + +#[test] +fn truncate_cuboctahedron_keeps_square_color_on_octagons() { + // cube -> ambo -> cuboctahedron (6 squares + 8 triangles). + let mut polyhedron = Polyhedron::preset(&Prism(4)); + apply_ambo(&mut polyhedron); + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 3, 3], + }; + let square_color = color_for_signature(&polyhedron, &square); + + // truncate -> truncated cuboctahedron; each square becomes an octagon and must keep its color. + apply_truncate(&mut polyhedron); + assert_uniform_colors_per_facetype(&polyhedron); + + // The octagons descend from the squares, so they inherit the color; the new vertex-figure squares must not steal it. + assert_eq!( + color_by_side(&polyhedron, 8), + square_color, + "octagons keep the square color" + ); + assert_ne!( + color_by_side(&polyhedron, 4), + square_color, + "new squares must not steal the square color" + ); +} + +#[test] +fn dodecahedron_is_well_formed() { + // dual(antiprism 5) then truncate its two degree-5 apexes -> dodecahedron. + let polyhedron = Polyhedron::preset(&Dodecahedron); + assert_eq!(polyhedron.shape.order(), 20, "vertex count"); + assert_eq!(polyhedron.shape.edges().count(), 30, "edge count"); + assert_eq!(polyhedron.shape.cycles.len(), 12, "face count"); + for c in polyhedron.shape.cycles.iter() { + assert_eq!(c.len(), 5, "all faces are pentagons"); + } + for v in polyhedron.shape.vertices() { + assert_eq!(polyhedron.shape.degree(v), 3, "vertex {v} degree"); + } + assert_eq!( + polyhedron.render.positions.len(), + 20, + "render stays in sync" + ); +} + +#[test] +fn dual_cube_gives_octahedron() { + // Dual = expand, then contract the returned face-figure edges. + let mut polyhedron = Polyhedron::preset(&Prism(4)); + let edges = polyhedron.begin_dual(); + polyhedron.contract(edges); + + // Octahedron: V=6, E=12, F=8, all triangles. + assert_eq!(polyhedron.shape.order(), 6, "vertex count"); + assert_eq!(polyhedron.shape.edges().count(), 12, "edge count"); + assert_eq!(polyhedron.shape.cycles.len(), 8, "face count"); + for c in polyhedron.shape.cycles.iter() { + assert_eq!(c.len(), 3, "all faces are triangles"); + } + assert_eq!(polyhedron.render.positions.len(), 6, "render stays in sync"); +} + +#[test] +fn dual_preserves_triangle_color_continuity() { + // Mirrors the Dual transaction: expand (cube -> rhombicuboctahedron), then + // contract the face-figures (-> octahedron). The surviving vertex-figure + // triangles must keep their color across the contraction. + let mut polyhedron = Polyhedron::preset(&Prism(4)); + + let edges = polyhedron.begin_dual(); + polyhedron.finalize_face_colors(); + let triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![4, 4, 4], + }; + let pink_slot = color_for_signature(&polyhedron, &triangle); + let pink_render = render_index_for_signature(&polyhedron, &triangle); + + polyhedron.contract(edges); + polyhedron.finalize_face_colors(); + assert_uniform_colors_per_facetype(&polyhedron); + + let octahedron_triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![3, 3, 3], + }; + // Both the color slot and — crucially — the rendered palette index must carry over, + // since the UI shows `palette[render_index]`, not the slot. + assert_eq!( + color_for_signature(&polyhedron, &octahedron_triangle), + pink_slot, + "octahedron triangles keep the rhombicuboctahedron triangle color slot" + ); + assert_eq!( + render_index_for_signature(&polyhedron, &octahedron_triangle), + pink_render, + "octahedron triangles render the same palette color as before" + ); +} + +#[test] +fn expand_tetrahedron_survivors_win_normalization() { + // In the expanded tetrahedron, 4 surviving triangles and 4 fresh ones share one signature. + // The fresh faces must adopt the survivors' slot, since a member-count vote would tie 4-vs-4. + let mut polyhedron = Polyhedron::preset(&Pyramid(3)); + let tetra_triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![3, 3, 3], + }; + let tetra_slot = color_for_signature(&polyhedron, &tetra_triangle); + let tetra_render = render_index_for_signature(&polyhedron, &tetra_triangle); + + apply_expand(&mut polyhedron); + assert_uniform_colors_per_facetype(&polyhedron); + + let cubocta_triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![4, 4, 4], + }; + assert_eq!( + color_for_signature(&polyhedron, &cubocta_triangle), + tetra_slot, + "vertex figures adopt the surviving triangles' slot" + ); + assert_eq!( + render_index_for_signature(&polyhedron, &cubocta_triangle), + tetra_render, + "rendered color is unchanged" + ); + + // The doomed vertex-figure slot dies in normalization before it can touch the palette. + // So the edge quads, the only genuinely new facetype, render the very next entry. + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 3, 3], + }; + assert_eq!( + render_index_for_signature(&polyhedron, &square), + tetra_render + 1, + "a normalization-doomed slot must not consume a palette entry" + ); +} + +#[test] +fn kis_children_inherit_parent_color() { + // Kis splits every cube face into apex triangles, a brand-new signature. + // Parent records make them keep the squares' color instead of minting a fresh one. + let mut polyhedron = Polyhedron::preset(&Prism(4)); + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![4, 4, 4, 4], + }; + let square_slot = color_for_signature(&polyhedron, &square); + let square_render = render_index_for_signature(&polyhedron, &square); + + polyhedron.shape.kis(Option::None); + polyhedron.finalize_face_colors(); + assert_uniform_colors_per_facetype(&polyhedron); + + assert!( + polyhedron + .face_coloring + .colors + .iter() + .all(|&c| c == square_slot), + "every kis child inherits its parent's slot" + ); + let triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![3, 3, 3], + }; + assert_eq!( + render_index_for_signature(&polyhedron, &triangle), + square_render, + "rendered color is unchanged" + ); +} + +#[test] +fn survivor_keeps_color_while_freed_colors_rotate_to_the_back() { + // The tetrahedron is self-dual. Its surviving face color must never change, but the + // transient facetypes created along the way should advance through the palette: a color + // freed by a disappearing facetype goes to the back, so the next new facetype picks a + // fresh entry rather than recycling the one just freed. + let mut polyhedron = Polyhedron::preset(&Pyramid(3)); + polyhedron.face_coloring.set_palette_len(6); + let triangle = FaceTypeSignature { + side_count: 3, + neighbor_sides: vec![3, 3, 3], + }; + let square = FaceTypeSignature { + side_count: 4, + neighbor_sides: vec![3, 3, 3, 3], + }; + let tetra_color = render_index_for_signature(&polyhedron, &triangle); + + // First dual: capture the intermediate cuboctahedron's square palette entry. + let edges = polyhedron.begin_dual(); + polyhedron.finalize_face_colors(); + let first_square = render_index_for_signature(&polyhedron, &square); + polyhedron.contract(edges); + polyhedron.finalize_face_colors(); + assert_eq!( + render_index_for_signature(&polyhedron, &triangle), + tetra_color, + "tetrahedron keeps its color after one dual" + ); + + // Second dual: the recreated square advances to a fresh palette entry (the freed one is + // now at the back), and the surviving tetrahedron still holds its original color. + let edges = polyhedron.begin_dual(); + polyhedron.finalize_face_colors(); + let second_square = render_index_for_signature(&polyhedron, &square); + assert_ne!( + second_square, first_square, + "recreated square advances instead of recycling the just-freed color" + ); + assert_ne!( + second_square, tetra_color, + "recreated square never collides with the surviving facetype's color" + ); + polyhedron.contract(edges); + polyhedron.finalize_face_colors(); + assert_eq!( + render_index_for_signature(&polyhedron, &triangle), + tetra_color, + "tetrahedron keeps its color after a second dual" + ); +} + +#[test] +fn dual_twice_is_identity() { + // dd == identity: cube -> octahedron -> cube. + let mut polyhedron = Polyhedron::preset(&Prism(4)); + let edges = polyhedron.begin_dual(); + polyhedron.contract(edges); + let edges = polyhedron.begin_dual(); + polyhedron.contract(edges); + + assert_eq!(polyhedron.shape.order(), 8, "vertex count"); + assert_eq!(polyhedron.shape.edges().count(), 12, "edge count"); + assert_eq!(polyhedron.shape.cycles.len(), 6, "face count"); + for c in polyhedron.shape.cycles.iter() { + assert_eq!(c.len(), 4, "all faces are squares"); + } +} + #[test] fn ambo_octahedron_gives_distinct_facetype_colors() { // octahedron ("O") @@ -148,3 +501,61 @@ fn ambo_octahedron_gives_distinct_facetype_colors() { color_for_signature(&polyhedron, &square) ); } + +/// Drives the real per-frame loop of physics plus transactions until the queue drains. +fn run_transactions(polyhedron: &mut Polyhedron) { + let mut iterations: u64 = 0; + while !polyhedron.transactions.is_empty() { + // The app's defaults: speed 10.0, frame time capped at 1/60s (render/state.rs). + polyhedron.update(10.0, 1.0 / 60.0); + iterations += 1; + assert!( + iterations < 200_000, + "transactions failed to converge: {:?}", + polyhedron.transactions + ); + } +} + +#[test] +fn transaction_loop_end_to_end() { + use ConwayMessage::*; + // Each operation exercised through the animated transaction machinery on a fresh cube. + for conway in [Truncate, Dual, Expand, Ambo, Kis, Chamfer] { + let mut polyhedron = Polyhedron::preset(&Prism(4)); + polyhedron.face_coloring.set_palette_len(9); + polyhedron + .transactions + .push(Transaction::Conway(conway.clone())); + run_transactions(&mut polyhedron); + + assert_eq!( + polyhedron.render.positions.len(), + polyhedron.shape.order(), + "render in sync after {conway:?}" + ); + assert_eq!( + polyhedron.face_coloring.colors.len(), + polyhedron.shape.cycles.len(), + "colors parallel to faces after {conway:?}" + ); + assert_eq!( + polyhedron.face_coloring.render_indices.len(), + polyhedron.shape.cycles.len(), + "render indices parallel to faces after {conway:?}" + ); + assert_uniform_colors_per_facetype(&polyhedron); + } + + // Bevel composes truncate + ambo through nested transactions (with real waits). + let mut polyhedron = Polyhedron::preset(&Prism(4)); + polyhedron.face_coloring.set_palette_len(9); + polyhedron + .transactions + .push(Transaction::Conway(ConwayMessage::Bevel)); + run_transactions(&mut polyhedron); + // Bevel here queues truncate then ambo: V = E(tC) = 36, F = F(tC) + V(tC) = 38. + assert_eq!(polyhedron.shape.order(), 36, "bevel vertex count"); + assert_eq!(polyhedron.shape.cycles.len(), 38, "bevel face count"); + assert_uniform_colors_per_facetype(&polyhedron); +} diff --git a/src/render/palette.rs b/src/render/palette.rs index 2aca21b..f28e608 100644 --- a/src/render/palette.rs +++ b/src/render/palette.rs @@ -39,6 +39,12 @@ impl Palette { "#639bff", "#8854f3", "#ff79ae", "#ff8c5c", "#fff982", "#63ffba", ]) } + pub fn clement_extended() -> Self { + Self::new(&[ + "#8854f3", "#fff982", "#639bff", "#ff8c5c", "#63ffba", "#ff79ae", "#70f3ff", + ]) + } + pub fn dream_haze() -> Self { Self::new(&[ "#3c42c4", "#6e51c8", "#a065cd", "#ce79d2", "#d68fb8", "#dda2a3", "#eac4ae", "#f4dfbe", diff --git a/src/render/state.rs b/src/render/state.rs index 420482e..d0d14f3 100644 --- a/src/render/state.rs +++ b/src/render/state.rs @@ -115,6 +115,10 @@ impl AppState { frame_difference }; + self.model + .polyhedron + .face_coloring + .set_palette_len(self.render.picker.palette.colors.len()); self.model.polyhedron.update(self.render.speed, second); self.render.frame = time;