Skip to content
Merged
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "polyblade"
version = "0.3.3"
version = "0.3.4"
edition = "2024"
description = "Make shapes dance."
readme = "README.md"
Expand Down
9 changes: 9 additions & 0 deletions assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions assets/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,10 @@ video {
isolation: isolate;
}

.hidden {
display: none;
}

.shrink {
flex-shrink: 1;
}
Expand Down
53 changes: 51 additions & 2 deletions src/components/menu.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -49,10 +52,53 @@ fn SizedPresetMenu(name: String, make: Callback<usize, PresetMessage>) -> 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::<FaceTypeOption>::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<bool>) -> Element {
rsx! {
div { class: "menu-group",
div { class: "menu-btn", "Preset" }
Expand Down Expand Up @@ -105,5 +151,8 @@ pub fn MenuBar() -> Element {
}
}
}
if schlegel() {
SchlegelFaceMenu {}
}
}
}
29 changes: 21 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -61,23 +65,33 @@ 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::<Route> {}
}
}
}

/// 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<KeyboardData>) {
fn handle_key(evt: Event<KeyboardData>, schlegel: &mut Signal<bool>) {
use dioxus::html::Key;

let Key::Character(ch) = evt.key() else {
return;
};
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() {
Expand Down Expand Up @@ -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 {
Expand Down
134 changes: 134 additions & 0 deletions src/polyhedron/face.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
use std::collections::HashSet;

#[derive(Debug, Default, Clone, PartialEq)]
struct FaceCache {
ancestors: Vec<HashSet<u64>>,
colors: Vec<usize>,
}

/// 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<usize>,
/// 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<usize>,
/// 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<usize>,
}

/// 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<HashSet<u64>>) {
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<usize>, 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<HashSet<u64>>, 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<Option<usize>> = 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<usize>)> = 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>, 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<usize> {
let mut distinct = colors.to_vec();
distinct.sort_unstable();
distinct.dedup();
colors
.iter()
.map(|slot| distinct.binary_search(slot).unwrap())
.collect()
}
Loading