use super::{
diagram2d::{self, DiagramConfig, DiagramStyle},
edges_layer::render_edges_layer,
mesh_cache::MeshCache,
raster::{SolidRasterizer, SolidStyle},
to_diagram_pixel_buffer, to_pixel_buffer,
};
#[cfg(feature = "editor")]
use super::{facet_map::FacetMap, live_update};
use glam::Vec3;
use indicatrix::{
geometry::{meet_solver::SolvedTier, stone_metrics::SolidMesh},
optics::raytracer::Camera,
};
use std::sync::{
Arc, Mutex, PoisonError,
mpsc::{self, Sender},
};
use crate::bridge::render_thread::RedrawGate;
pub type SolidLastSolved = Arc<Mutex<Option<Vec<SolvedTier>>>>;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CameraPose {
pub yaw: f32,
pub pitch: f32,
pub distance: f32,
}
#[derive(Debug, Clone)]
pub struct PickBuffer {
pub width: u32,
pub height: u32,
pick: Vec<u32>,
}
impl PickBuffer {
#[must_use]
pub fn facet_at(&self, x: u32, y: u32) -> Option<u32> {
if x >= self.width || y >= self.height {
return None;
}
let v = self.pick[(y * self.width + x) as usize];
(v != 0).then(|| v - 1)
}
}
enum RedrawRequest {
Reproject {
planes: Vec<(Vec3, f32)>,
camera: CameraPose,
size: (u32, u32),
view_mode: u8,
gear: Option<(u32, f32)>,
},
#[cfg(feature = "editor")]
Replan {
design: Box<indicatrix_cut_core::Design>,
dirty: std::collections::BTreeSet<usize>,
last_solved: Option<Vec<SolvedTier>>,
camera: CameraPose,
size: (u32, u32),
selected_tier: Option<usize>,
n_d: f64,
view_mode: u8,
},
}
#[cfg(feature = "editor")]
pub struct ReplanRequest {
pub design: indicatrix_cut_core::Design,
pub dirty: std::collections::BTreeSet<usize>,
pub last_solved: Option<Vec<SolvedTier>>,
pub camera: CameraPose,
pub size: (u32, u32),
pub selected_tier: Option<usize>,
pub n_d: f64,
pub view_mode: u8,
}
pub trait PreviewSink: Send + Sync + 'static {
fn apply(&self, frame: PreviewFrame);
}
pub struct PreviewFrame {
pub image: slint::SharedPixelBuffer<slint::Rgba8Pixel>,
pub has_solid: bool,
pub status: String,
pub solved: Option<Vec<SolvedTier>>,
pub stale: bool,
pub pick: PickBuffer,
pub edges_image: Option<slint::SharedPixelBuffer<slint::Rgba8Pixel>>,
pub diagram_image: Option<slint::SharedPixelBuffer<slint::Rgba8Pixel>>,
pub has_diagram: bool,
pub diagram_pick: Option<PickBuffer>,
pub diagram_hover_text: Option<Vec<String>>,
pub diagram_facet_tier: Option<Vec<Option<usize>>>,
}
struct DiagramMemory {
gear_teeth: u32,
gear_reference_angle: f32,
style: DiagramStyle,
hover_text: Vec<String>,
facet_tier: Vec<Option<usize>>,
}
impl Default for DiagramMemory {
fn default() -> Self {
Self {
gear_teeth: 96,
gear_reference_angle: 0.0,
style: DiagramStyle::default(),
hover_text: Vec::new(),
facet_tier: Vec::new(),
}
}
}
#[cfg(feature = "editor")]
type PlannedStyle = (Vec<(Vec3, f32)>, SolidStyle, Option<Vec<SolvedTier>>, bool);
#[cfg(feature = "editor")]
fn plan_and_style(
design: &indicatrix_cut_core::Design,
last_solved: Option<&[SolvedTier]>,
dirty: &std::collections::BTreeSet<usize>,
budget: std::time::Duration,
selected_tier: Option<usize>,
n_d: f64,
) -> PlannedStyle {
let plan =
live_update::plan_preview(design, last_solved, dirty, budget, &live_update::RealSolver);
let pending_tier = match &plan.freshness {
live_update::Freshness::Stale { pending } => pending.iter().next().copied(),
_ => None,
};
let facet_map = FacetMap::from_design(design, plan.solved.as_deref().unwrap_or(&[]));
let overlay = facet_map.overlay_flags(design, n_d, selected_tier, pending_tier);
let style = SolidStyle {
flagged: overlay.flagged,
pending: overlay.pending,
selected: overlay.selected,
..SolidStyle::default()
};
let stale = matches!(plan.freshness, live_update::Freshness::Stale { .. });
(plan.planes, style, plan.solved, stale)
}
#[cfg(feature = "editor")]
fn update_diagram_memory_from_design(
last_diagram: &mut DiagramMemory,
design: &indicatrix_cut_core::Design,
solved: Option<&[SolvedTier]>,
solid_style: &SolidStyle,
n_d: f64,
) {
let facet_map = FacetMap::from_design(design, solved.unwrap_or(&[]));
let facet_count = facet_map.facet_count();
let mut labels = vec![String::new(); facet_count];
let mut hover_text = vec![String::new(); facet_count];
let mut facet_tier = vec![None; facet_count];
for id in 0..facet_count {
labels[id] = facet_map.facet_label(id).to_string();
hover_text[id] = facet_map.hover_text(id, n_d);
facet_tier[id] = facet_map.tier_of(id);
}
last_diagram.style = DiagramStyle {
flagged: solid_style.flagged.clone(),
pending: solid_style.pending.clone(),
selected: solid_style.selected.clone(),
facet_labels: labels,
..DiagramStyle::default()
};
last_diagram.hover_text = hover_text;
last_diagram.facet_tier = facet_tier;
}
type DiagramOutputs = (
Option<slint::SharedPixelBuffer<slint::Rgba8Pixel>>,
bool,
Option<PickBuffer>,
Option<Vec<String>>,
Option<Vec<Option<usize>>>,
);
fn build_diagram_outputs(
mesh_cache: &mut MeshCache,
planes: &[(Vec3, f32)],
size: (u32, u32),
view_mode: u8,
last_diagram: &DiagramMemory,
) -> DiagramOutputs {
if view_mode != 3 {
return (None, false, None, None, None);
}
let (image, has_diagram, pick) =
mesh_cache
.get_or_build(planes)
.map_or((None, false, None), |cached| {
let config = DiagramConfig {
width: size.0,
height: size.1,
gear_teeth: last_diagram.gear_teeth,
gear_reference_angle: last_diagram.gear_reference_angle,
};
let diagram_frame =
diagram2d::render_diagram(&cached.mesh, &config, &last_diagram.style);
let image = to_diagram_pixel_buffer(&diagram_frame);
let pick = PickBuffer {
width: diagram_frame.width,
height: diagram_frame.height,
pick: diagram_frame.pick,
};
(Some(image), true, Some(pick))
});
(
image,
has_diagram,
pick,
Some(last_diagram.hover_text.clone()),
Some(last_diagram.facet_tier.clone()),
)
}
const fn apply_reproject_gear(last_diagram: &mut DiagramMemory, gear: Option<(u32, f32)>) {
if let Some((gear_teeth, gear_reference_angle)) = gear {
last_diagram.gear_teeth = gear_teeth;
last_diagram.gear_reference_angle = gear_reference_angle;
}
}
type RequestState = (
Vec<(Vec3, f32)>,
CameraPose,
(u32, u32),
u8,
SolidStyle,
Option<Vec<SolvedTier>>,
bool,
);
fn resolve_request_state(
last_style: &mut SolidStyle,
last_diagram: &mut DiagramMemory,
request: RedrawRequest,
) -> RequestState {
match request {
RedrawRequest::Reproject {
planes,
camera,
size,
view_mode,
gear,
} => {
apply_reproject_gear(last_diagram, gear);
(
planes,
camera,
size,
view_mode,
last_style.clone(),
None,
false,
)
}
#[cfg(feature = "editor")]
RedrawRequest::Replan {
design,
dirty,
last_solved,
camera,
size,
selected_tier,
n_d,
view_mode,
} => {
let (planes, style, solved, stale) = plan_and_style(
&design,
last_solved.as_deref(),
&dirty,
live_update::DEFAULT_PREVIEW_BUDGET,
selected_tier,
n_d,
);
*last_style = style.clone();
last_diagram.gear_teeth = design.meta.gear_teeth_abs();
last_diagram.gear_reference_angle = design.meta.gear_reference_angle as f32;
if view_mode == 3 {
update_diagram_memory_from_design(
last_diagram,
&design,
solved.as_deref(),
&style,
n_d,
);
}
(planes, camera, size, view_mode, style, solved, stale)
}
}
}
fn render_request(
mesh_cache: &mut MeshCache,
rasterizer: &mut SolidRasterizer,
edges_rasterizer: &mut SolidRasterizer,
last_style: &mut SolidStyle,
last_diagram: &mut DiagramMemory,
request: RedrawRequest,
) -> PreviewFrame {
let (planes, camera_pose, size, view_mode, style, solved, stale) =
resolve_request_state(last_style, last_diagram, request);
rasterizer.resize(size.0, size.1);
let camera = Camera::new(
camera_pose.yaw,
camera_pose.pitch,
camera_pose.distance,
42.0,
);
let (image, has_solid, status) = if let Some(cached) = mesh_cache.get_or_build(&planes) {
rasterizer.render_prepared(cached, &camera, &style);
(to_pixel_buffer(rasterizer), true, String::new())
} else {
rasterizer.render(&SolidMesh::default(), &camera, &style);
(
to_pixel_buffer(rasterizer),
false,
mesh_cache.status_message(),
)
};
let pick = PickBuffer {
width: rasterizer.width,
height: rasterizer.height,
pick: rasterizer.pick.clone(),
};
let edges_image = if view_mode == 2 {
mesh_cache.get_or_build(&planes).map(|cached| {
edges_rasterizer.resize(size.0, size.1);
render_edges_layer(edges_rasterizer, cached, &camera, &style);
to_pixel_buffer(edges_rasterizer)
})
} else {
None
};
let (diagram_image, has_diagram, diagram_pick, diagram_hover_text, diagram_facet_tier) =
build_diagram_outputs(mesh_cache, &planes, size, view_mode, last_diagram);
PreviewFrame {
image,
has_solid,
status,
solved,
stale,
pick,
edges_image,
diagram_image,
has_diagram,
diagram_pick,
diagram_hover_text,
diagram_facet_tier,
}
}
pub struct SolidPreviewState {
sink: Arc<dyn PreviewSink>,
gate: Arc<RedrawGate<RedrawRequest>>,
wake: Mutex<Option<Sender<()>>>,
}
impl SolidPreviewState {
#[must_use]
pub fn new(sink: Arc<dyn PreviewSink>) -> Arc<Self> {
Arc::new(Self {
sink,
gate: Arc::new(RedrawGate::new()),
wake: Mutex::new(None),
})
}
pub fn request_redraw(
&self,
planes: Vec<(Vec3, f32)>,
camera: CameraPose,
size: (u32, u32),
view_mode: u8,
) {
self.request_redraw_with_gear(planes, camera, size, view_mode, None);
}
pub fn request_redraw_with_gear(
&self,
planes: Vec<(Vec3, f32)>,
camera: CameraPose,
size: (u32, u32),
view_mode: u8,
gear: Option<(u32, f32)>,
) {
self.submit(RedrawRequest::Reproject {
planes,
camera,
size,
view_mode,
gear,
});
}
#[cfg(feature = "editor")]
pub fn request_replan(&self, request: ReplanRequest) {
self.submit(RedrawRequest::Replan {
design: Box::new(request.design),
dirty: request.dirty,
last_solved: request.last_solved,
camera: request.camera,
size: request.size,
selected_tier: request.selected_tier,
n_d: request.n_d,
view_mode: request.view_mode,
});
}
fn submit(&self, request: RedrawRequest) {
let tx = {
let mut guard = self.wake.lock().unwrap_or_else(PoisonError::into_inner);
if guard.is_none() {
*guard = Some(self.spawn_worker());
}
guard
.clone()
.expect("just initialized above if it was empty")
};
if self.gate.submit(request).is_some() {
let _ = tx.send(());
}
}
fn spawn_worker(&self) -> Sender<()> {
let (tx, rx) = mpsc::channel::<()>();
let sink = Arc::clone(&self.sink);
let gate = Arc::clone(&self.gate);
std::thread::spawn(move || {
let mut mesh_cache = MeshCache::default();
let mut rasterizer = SolidRasterizer::new(1, 1);
let mut edges_rasterizer = SolidRasterizer::new(1, 1);
let mut last_style = SolidStyle::default();
let mut last_diagram = DiagramMemory::default();
for () in rx {
let Some(request) = gate.take() else {
continue;
};
let frame = render_request(
&mut mesh_cache,
&mut rasterizer,
&mut edges_rasterizer,
&mut last_style,
&mut last_diagram,
request,
);
sink.apply(frame);
}
});
tx
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
struct FakeSink {
calls: Mutex<Vec<(bool, String)>>,
}
impl FakeSink {
fn new() -> Arc<Self> {
Arc::new(Self {
calls: Mutex::new(Vec::new()),
})
}
fn calls(&self) -> Vec<(bool, String)> {
self.calls
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
}
impl PreviewSink for FakeSink {
fn apply(&self, frame: PreviewFrame) {
self.calls
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push((frame.has_solid, frame.status));
}
}
fn wait_for_settled<T: PartialEq + Clone>(mut f: impl FnMut() -> T, timeout: Duration) -> T {
let deadline = Instant::now() + timeout;
let mut last = f();
let mut stable_polls = 0u32;
loop {
std::thread::sleep(Duration::from_millis(15));
let current = f();
if current == last {
stable_polls += 1;
if stable_polls >= 4 {
return current;
}
} else {
stable_polls = 0;
last = current;
}
assert!(Instant::now() < deadline, "worker thread never settled");
}
}
fn box_planes(y_half: f32) -> Vec<(Vec3, f32)> {
vec![
(Vec3::X, 1.0),
(Vec3::NEG_X, 1.0),
(Vec3::Y, y_half),
(Vec3::NEG_Y, y_half),
(Vec3::Z, 1.0),
(Vec3::NEG_Z, 1.0),
]
}
fn unbounded_planes() -> Vec<(Vec3, f32)> {
vec![(Vec3::X, 1.0), (Vec3::NEG_X, 1.0)]
}
const CAMERA: CameraPose = CameraPose {
yaw: 0.0,
pitch: 0.0,
distance: 5.0,
};
#[test]
fn a_single_request_eventually_reaches_the_sink() {
let sink = FakeSink::new();
let state = SolidPreviewState::new(sink.clone());
state.request_redraw(box_planes(0.6), CAMERA, (16, 16), 0);
let calls = wait_for_settled(|| sink.calls().len(), Duration::from_secs(5));
assert_eq!(calls, 1);
assert!(sink.calls()[0].0, "a closed box must report has_solid");
}
#[test]
fn coalesces_a_burst_of_requests_to_the_latest() {
let sink = FakeSink::new();
let state = SolidPreviewState::new(sink.clone());
for _ in 0..9 {
state.request_redraw(unbounded_planes(), CAMERA, (16, 16), 0);
}
state.request_redraw(box_planes(0.6), CAMERA, (16, 16), 0);
let final_len = wait_for_settled(|| sink.calls().len(), Duration::from_secs(5));
assert!(
final_len < 10,
"expected coalescing to avoid one render per request, got {final_len}"
);
let calls = sink.calls();
let (has_solid, _status) = calls.last().expect("at least one call must have landed");
assert!(
*has_solid,
"the last frame the sink receives must reflect the LAST submitted request"
);
}
#[test]
fn a_non_closed_request_reports_has_solid_false_with_a_reason() {
let sink = FakeSink::new();
let state = SolidPreviewState::new(sink.clone());
state.request_redraw(unbounded_planes(), CAMERA, (16, 16), 0);
wait_for_settled(|| sink.calls().len(), Duration::from_secs(5));
let calls = sink.calls();
let (has_solid, status) = calls.last().unwrap();
assert!(!has_solid);
assert!(status.contains("Unbounded"), "got: {status}");
}
#[cfg(feature = "editor")]
mod replan {
use super::*;
use indicatrix::geometry::meet_solver::MeetConstraint;
use indicatrix_cut_core::{ConstraintTier, Design, PreformSpec, ScheduleMeta};
fn tier(name: &str, angle_deg: f64, constraint: MeetConstraint) -> ConstraintTier {
ConstraintTier {
angle_deg,
name: name.to_string(),
indices: vec![0.0],
constraint,
imported_meet: None,
detached: Vec::new(),
}
}
fn pinned_design() -> Design {
Design::new(
PreformSpec::block(1.0, 1.0, 1.0),
ScheduleMeta {
gear_teeth: 96,
..ScheduleMeta::default()
},
vec![
tier("Table", 0.0, MeetConstraint::ScaleReference(0.5)),
tier("Pavilion", -40.0, MeetConstraint::ScaleReference(0.6)),
],
)
}
fn closed_design() -> Design {
const GIRDLE_INDICES: [f64; 16] = [
0.0, 6.0, 12.0, 18.0, 24.0, 30.0, 36.0, 42.0, 48.0, 54.0, 60.0, 66.0, 72.0, 78.0,
84.0, 90.0,
];
const BREAK_INDICES: [f64; 16] = [
95.0, 1.0, 11.0, 13.0, 23.0, 25.0, 35.0, 37.0, 47.0, 49.0, 59.0, 61.0, 71.0, 73.0,
83.0, 85.0,
];
const MAIN_INDICES: [f64; 8] = [0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0];
const STAR_INDICES: [f64; 8] = [6.0, 18.0, 30.0, 42.0, 54.0, 66.0, 78.0, 90.0];
fn rbc_tier(name: &str, angle_deg: f64, indices: &[f64], mast: f64) -> ConstraintTier {
ConstraintTier {
angle_deg,
name: name.to_string(),
indices: indices.to_vec(),
constraint: MeetConstraint::ScaleReference(mast),
imported_meet: None,
detached: Vec::new(),
}
}
Design::new(
PreformSpec::block(2.0, 1.0, 2.0),
ScheduleMeta {
gemcad_version: "GemCad 5.0".to_string(),
gear_teeth: 96,
gear_reference_angle: 0.0,
symmetry_order: 8,
mirror: true,
refractive_index: 1.54,
headers: Vec::new(),
footnotes: Vec::new(),
},
vec![
rbc_tier("Table", 0.0, &[], 0.32),
rbc_tier("Star", 15.0, &STAR_INDICES, 0.45),
rbc_tier("Crown Main", 34.5, &MAIN_INDICES, 0.59),
rbc_tier("Upper Girdle", 41.0, &BREAK_INDICES, 0.67),
rbc_tier("Girdle", 90.0, &GIRDLE_INDICES, 1.0),
rbc_tier("Pavilion Main", -41.0, &MAIN_INDICES, 0.67),
rbc_tier("Lower Girdle", -42.5, &BREAK_INDICES, 0.68),
rbc_tier("Culet", -0.0, &[], 0.88),
],
)
}
fn free_design() -> Design {
Design::new(
PreformSpec::block(2.0, 1.0, 2.0),
ScheduleMeta {
gear_teeth: 96,
..ScheduleMeta::default()
},
vec![
tier("C1", 30.0, MeetConstraint::ScaleReference(0.6)),
tier("C2", 40.0, MeetConstraint::MeetExisting),
],
)
}
#[test]
fn plan_and_style_returns_the_solved_masts_for_a_pinned_design() {
let design = pinned_design();
let (planes, _style, solved, stale) = plan_and_style(
&design,
None,
&std::collections::BTreeSet::new(),
live_update::DEFAULT_PREVIEW_BUDGET,
None,
design.effective_refractive_index(),
);
assert_ne!(planes, Vec::<(Vec3, f32)>::new());
assert_eq!(solved.map(|s| s.len()), Some(2));
assert!(!stale, "a pinned design never goes over budget");
}
#[test]
fn a_zero_budget_forces_stale_and_marks_the_dirty_tier_pending() {
let design = free_design();
let previous = design.solve().expect("fixture must solve");
let dirty = std::collections::BTreeSet::from([1]);
let (_planes, style, solved, stale) = plan_and_style(
&design,
Some(&previous),
&dirty,
Duration::ZERO,
None,
design.effective_refractive_index(),
);
assert!(
stale,
"a real resolve_dirty call can never finish within 0ns"
);
assert!(
solved.is_some(),
"the fresh (late) result must still be chained forward"
);
let facet_map = FacetMap::from_design(&design, solved.as_deref().unwrap_or(&[]));
assert!(
facet_map
.facets_of_tier(1)
.iter()
.all(|&id| style.pending[id as usize]),
"the edited tier's own facets must be marked pending"
);
}
#[test]
fn selected_tier_flags_reach_solid_style_selected() {
let design = pinned_design();
let (_planes, style, _solved, _stale) = plan_and_style(
&design,
None,
&std::collections::BTreeSet::new(),
live_update::DEFAULT_PREVIEW_BUDGET,
Some(0),
design.effective_refractive_index(),
);
let solved = design.solve().expect("every tier is pinned");
let facet_map = FacetMap::from_design(&design, &solved);
assert!(
facet_map
.facets_of_tier(0)
.iter()
.all(|&id| style.selected[id as usize]),
"tier 0's own facets must be marked selected"
);
assert!(
facet_map
.facets_of_tier(1)
.iter()
.all(|&id| !style.selected[id as usize]),
"tier 1 was never selected"
);
}
#[test]
fn render_request_carries_solved_and_freshness_through_to_the_worker_frame() {
let design = closed_design();
let mut mesh_cache = MeshCache::default();
let mut rasterizer = SolidRasterizer::new(16, 16);
let mut edges_rasterizer = SolidRasterizer::new(16, 16);
let mut last_style = SolidStyle::default();
let mut last_diagram = DiagramMemory::default();
let n_d = design.effective_refractive_index();
let frame = render_request(
&mut mesh_cache,
&mut rasterizer,
&mut edges_rasterizer,
&mut last_style,
&mut last_diagram,
RedrawRequest::Replan {
design: Box::new(design),
dirty: std::collections::BTreeSet::new(),
last_solved: None,
camera: CAMERA,
size: (16, 16),
selected_tier: None,
n_d,
view_mode: 0,
},
);
assert!(frame.has_solid);
assert!(!frame.stale);
assert_eq!(frame.solved.map(|s| s.len()), Some(8));
assert!(
frame.edges_image.is_none(),
"view_mode 0 never builds edges"
);
assert!(
frame.diagram_image.is_none(),
"view_mode 0 never builds the diagram"
);
}
#[test]
fn view_mode_both_also_produces_an_edges_image() {
let design = closed_design();
let mut mesh_cache = MeshCache::default();
let mut rasterizer = SolidRasterizer::new(16, 16);
let mut edges_rasterizer = SolidRasterizer::new(16, 16);
let mut last_style = SolidStyle::default();
let mut last_diagram = DiagramMemory::default();
let n_d = design.effective_refractive_index();
let frame = render_request(
&mut mesh_cache,
&mut rasterizer,
&mut edges_rasterizer,
&mut last_style,
&mut last_diagram,
RedrawRequest::Replan {
design: Box::new(design),
dirty: std::collections::BTreeSet::new(),
last_solved: None,
camera: CAMERA,
size: (16, 16),
selected_tier: None,
n_d,
view_mode: 2,
},
);
assert!(frame.edges_image.is_some());
assert!(
frame.diagram_image.is_none(),
"view_mode 2 never builds the diagram"
);
}
#[test]
fn view_mode_diagram_produces_the_diagram_image_and_its_side_tables() {
let design = closed_design();
let mut mesh_cache = MeshCache::default();
let mut rasterizer = SolidRasterizer::new(16, 16);
let mut edges_rasterizer = SolidRasterizer::new(16, 16);
let mut last_style = SolidStyle::default();
let mut last_diagram = DiagramMemory::default();
let n_d = design.effective_refractive_index();
let frame = render_request(
&mut mesh_cache,
&mut rasterizer,
&mut edges_rasterizer,
&mut last_style,
&mut last_diagram,
RedrawRequest::Replan {
design: Box::new(design),
dirty: std::collections::BTreeSet::new(),
last_solved: None,
camera: CAMERA,
size: (240, 120),
selected_tier: None,
n_d,
view_mode: 3,
},
);
assert!(frame.has_diagram);
assert!(frame.diagram_image.is_some());
let pick = frame
.diagram_pick
.expect("view_mode 3 must produce a diagram pick buffer");
assert_eq!((pick.width, pick.height), (240, 120));
let hover_text = frame
.diagram_hover_text
.expect("view_mode 3 must produce a facet-id-indexed hover-text table");
assert_eq!(
hover_text.len(),
frame.diagram_facet_tier.as_ref().unwrap().len()
);
assert!(hover_text.iter().any(|t| t.contains("Table")));
}
}
}