use super::{
RESIZE_QUIET, ViewState, WHEEL_QUIET,
picking::{BoxTarget, PICK_RADIUS_PX, pick_box},
render::{Hover, camera_for, refit_distance, reset_pose},
request_render,
scene::{SCENE_RADIUS, SceneKind, StoneInfo},
state::DOUBLE_CLICK_INTERVAL,
};
use crate::{
RoughPlanModel,
gui::rough_plan::{
cut_faces::{canonical_normal, default_face},
editing,
format::to_i32,
host::{Host, on_host},
},
};
use indicatrix_cut_core::rough_plan::RoughCut;
use indicatrix_solid::preview::camera::{orbit_step, standard_view, zoom_step};
use slint::{ComponentHandle, TimerMode};
use std::{rc::Rc, sync::PoisonError, time::Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClickAction {
AddBoxCut(BoxTarget),
AddFaceCut(usize),
SelectCut(usize),
SelectStone(usize),
ClearGroup,
Nothing,
}
#[derive(Debug, Clone, Copy)]
struct ClickContext {
picked: Picked,
editable: bool,
building: bool,
result_view: bool,
group_selected: bool,
}
#[must_use]
const fn click_action(context: &ClickContext) -> ClickAction {
if context.building {
return ClickAction::Nothing;
}
match context.picked {
Picked::Box(target) if context.editable => ClickAction::AddBoxCut(target),
Picked::Facet(facet) if context.editable => ClickAction::AddFaceCut(facet),
Picked::Cut(cut) => ClickAction::SelectCut(cut),
Picked::Stone(stone) => ClickAction::SelectStone(stone),
Picked::Nothing if context.result_view && context.group_selected => ClickAction::ClearGroup,
_ => ClickAction::Nothing,
}
}
const FACE_HINT: &str = "Click to cut this face flat";
const LOCKED_HINT: &str = "Editing is locked while planning";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Picked {
Nothing,
Box(BoxTarget),
Facet(usize),
Cut(usize),
Stone(usize),
}
pub(super) fn register(host: &Rc<Host>) {
let model = host.window.global::<RoughPlanModel>();
model.on_view_resized(|width, height| on_host(|host| resized(host, width, height)));
model.on_view_orbit(|dx, dy| on_host(|host| orbit(host, dx, dy)));
model.on_view_zoom(|delta| on_host(|host| zoom(host, delta)));
model.on_view_drag_begin(|| on_host(drag_begin));
model.on_view_drag_end(|| on_host(drag_end));
model.on_view_hover(|x, y| on_host(|host| hover(host, x, y)));
model.on_view_click(|x, y| on_host(|host| click(host, x, y)));
model.on_view_double_click(|x, y| on_host(|host| double_click(host, x, y)));
model.on_view_preset(|kind| on_host(|host| preset(host, kind)));
model.on_view_toggles_changed(|| on_host(request_render));
model.on_cut_selected(|_row| on_host(super::cut_selected));
}
fn edit_view(host: &Rc<Host>, change: impl FnOnce(&mut ViewState)) {
change(&mut host.session.borrow_mut().view);
}
fn request_rehover(host: &Rc<Host>) {
edit_view(host, |view| view.rehover = true);
}
fn aspect_of(size: (f32, f32)) -> f32 {
if size.1 > 0.0 { size.0 / size.1 } else { 1.5 }
}
fn resized(host: &Rc<Host>, width: f32, height: f32) {
if !(width > 0.0 && height > 0.0) {
return;
}
edit_view(host, |view| {
let old = view.view_size;
view.view_size = (width, height);
if old.0 <= 0.0 {
view.pose = reset_pose(width / height);
} else if old != (width, height) {
view.pose.distance = refit_distance(view.pose.distance, aspect_of(old), width / height);
view.resizing = true;
view.resize_timer
.start(TimerMode::SingleShot, RESIZE_QUIET, || {
on_host(resize_settled);
});
}
});
request_render(host);
}
fn resize_settled(host: &Rc<Host>) {
edit_view(host, |view| view.resizing = false);
request_rehover(host);
request_render(host);
}
pub(super) fn sync_size(host: &Rc<Host>) {
resized(
host,
host.window.get_view_area_width(),
host.window.get_view_area_height(),
);
}
fn orbit(host: &Rc<Host>, dx: f32, dy: f32) {
edit_view(host, |view| view.pose = orbit_step(view.pose, dx, dy));
request_render(host);
}
fn zoom(host: &Rc<Host>, delta: f32) {
if delta == 0.0 {
return;
}
edit_view(host, |view| {
view.pose.distance = zoom_step(view.pose.distance, delta, SCENE_RADIUS);
view.wheeling = true;
view.wheel_timer
.start(TimerMode::SingleShot, WHEEL_QUIET, || {
on_host(wheel_settled);
});
});
request_rehover(host);
request_render(host);
}
fn wheel_settled(host: &Rc<Host>) {
edit_view(host, |view| view.wheeling = false);
request_rehover(host);
request_render(host);
}
fn drag_begin(host: &Rc<Host>) {
edit_view(host, |view| {
view.dragging = true;
view.hover = Hover::None;
});
host.window
.global::<RoughPlanModel>()
.set_view_hint("".into());
}
fn drag_end(host: &Rc<Host>) {
edit_view(host, |view| view.dragging = false);
request_rehover(host);
request_render(host);
}
fn reset_view(host: &Rc<Host>) {
edit_view(host, |view| {
view.pose = reset_pose(aspect_of(view.view_size));
});
request_rehover(host);
request_render(host);
}
fn preset(host: &Rc<Host>, kind: i32) {
if kind == 3 {
reset_view(host);
return;
}
let standard = match kind {
1 => 1,
2 => 4,
_ => 0,
};
edit_view(host, |view| {
view.pose = standard_view(standard, view.pose, SCENE_RADIUS);
});
request_rehover(host);
request_render(host);
}
fn facet_under(view: &ViewState, serial: u64, cursor: (f32, f32)) -> Option<usize> {
let picks = &view.runtime.as_ref()?.picks;
let snapshot = picks.lock().unwrap_or_else(PoisonError::into_inner).clone();
if snapshot.serial != serial || view.view_size.0 <= 0.0 || view.view_size.1 <= 0.0 {
return None;
}
snapshot.facet_at(cursor.0 / view.view_size.0, cursor.1 / view.view_size.1)
}
fn pick_at(view: &ViewState, cursor: (f32, f32), armed: bool) -> Picked {
let Some(scene) = &view.scene else {
return Picked::Nothing;
};
match &scene.kind {
SceneKind::Model(_) if armed => {
facet_under(view, scene.serial, cursor).map_or(Picked::Nothing, Picked::Facet)
}
SceneKind::Model(model) => {
let camera = camera_for(view.pose);
let size = (
view.view_size.0.round() as u32,
view.view_size.1.round() as u32,
);
if model.block
&& let Some(target) = pick_box(
&camera,
size,
model.half_extents,
&model.cut_planes,
cursor,
PICK_RADIUS_PX,
)
{
return Picked::Box(target);
}
facet_under(view, scene.serial, cursor)
.and_then(|facet| model.cut_of_facet(facet))
.map_or(Picked::Nothing, Picked::Cut)
}
SceneKind::Fit(_) if !view.fit_pickable() => Picked::Nothing,
SceneKind::Fit(fit) => facet_under(view, scene.serial, cursor)
.and_then(|facet| fit.stone_at_facet(facet))
.map_or(Picked::Nothing, Picked::Stone),
}
}
fn describe(view: &ViewState, picked: Picked) -> (Hover, String) {
match picked {
Picked::Nothing => (Hover::None, String::new()),
Picked::Box(target) => (Hover::Box(target), target.hint()),
Picked::Facet(facet) => (Hover::Facet(facet), FACE_HINT.to_string()),
Picked::Cut(cut) => (Hover::None, format!("Click to select cut {}", cut + 1)),
Picked::Stone(stone) => {
let hint = match view.scene.as_deref().map(|scene| &scene.kind) {
Some(SceneKind::Fit(fit)) => fit.info.get(stone).map(StoneInfo::hint),
_ => None,
};
(Hover::None, hint.unwrap_or_default())
}
}
}
fn model_shown(view: &ViewState) -> bool {
matches!(
view.scene.as_deref().map(|scene| &scene.kind),
Some(SceneKind::Model(_))
)
}
fn hover(host: &Rc<Host>, x: f32, y: f32) {
let model = host.window.global::<RoughPlanModel>();
let armed = model.get_face_from_view_armed();
let editable = !model.get_running();
let inside = x >= 0.0 && y >= 0.0;
let (mark, hint) = {
let mut guard = host.session.borrow_mut();
let view = &mut guard.view;
view.pointer = inside.then_some((x, y));
if !inside || view.dragging {
(Hover::None, String::new())
} else if model_shown(view) && !editable {
(Hover::None, LOCKED_HINT.to_string())
} else {
describe(view, pick_at(view, (x, y), armed))
}
};
if model.get_view_hint().as_str() != hint {
model.set_view_hint(hint.into());
}
let changed = {
let mut session = host.session.borrow_mut();
let changed = session.view.hover != mark;
session.view.hover = mark;
changed
};
if changed {
request_render(host);
}
}
pub(super) fn rehover(host: &Rc<Host>) {
let pointer = host.session.borrow().view.pointer;
if let Some((x, y)) = pointer {
hover(host, x, y);
}
}
fn queue_add(host: &Rc<Host>, picked: Picked, at: (f32, f32)) {
let earlier = {
let mut guard = host.session.borrow_mut();
let session = &mut *guard;
session
.view
.adds
.settle(at, Instant::now(), session.revision)
};
if let Some(earlier) = earlier {
perform_add(host, earlier);
}
let mut guard = host.session.borrow_mut();
let session = &mut *guard;
session
.view
.adds
.click(picked, at, Instant::now(), session.revision);
session
.view
.add_timer
.start(TimerMode::SingleShot, DOUBLE_CLICK_INTERVAL, || {
on_host(add_due);
});
}
fn add_due(host: &Rc<Host>) {
let running = host.window.global::<RoughPlanModel>().get_running();
let due = {
let mut guard = host.session.borrow_mut();
let session = &mut *guard;
let may_edit = !running && model_shown(&session.view) && !session.view.building;
session.view.adds.due(session.revision, may_edit)
};
if let Some(picked) = due {
perform_add(host, picked);
}
}
fn perform_add(host: &Rc<Host>, picked: Picked) {
let model = host.window.global::<RoughPlanModel>();
if model.get_running() {
return;
}
if !editing::commit_pending_sizes(host) {
return;
}
let cut = match picked {
Picked::Box(target) => {
let extents = host.session.borrow().model.base.bounding_box_extents();
Some(target.default_cut(extents))
}
Picked::Facet(facet) => cut_from_facet(host, facet),
_ => None,
};
let Some(cut) = cut else {
return;
};
if matches!(picked, Picked::Facet(_)) {
model.set_face_from_view_armed(false);
}
edit_view(host, |view| view.hover = Hover::None);
model.set_view_hint("".into());
editing::add_committed_cut(host, cut);
request_rehover(host);
}
fn double_click(host: &Rc<Host>, x: f32, y: f32) {
let model = host.window.global::<RoughPlanModel>();
let running = model.get_running();
let armed = model.get_face_from_view_armed();
let pending = {
let mut guard = host.session.borrow_mut();
let session = &mut *guard;
session.view.add_timer.stop();
let may_edit = !running && model_shown(&session.view) && !session.view.building;
session.view.adds.due(session.revision, may_edit)
};
if let Some(picked) = pending {
perform_add(host, picked);
return;
}
let on_nothing = pick_at(&host.session.borrow().view, (x, y), armed) == Picked::Nothing;
if on_nothing {
reset_view(host);
}
}
fn cut_from_facet(host: &Rc<Host>, facet: usize) -> Option<RoughCut> {
let session = host.session.borrow();
let scene = session.view.scene.as_deref()?;
let SceneKind::Model(model) = &scene.kind else {
return None;
};
let normal = model.facet_normal(facet)?;
Some(default_face(
&session.model.base,
canonical_normal([normal.x, normal.y, normal.z]),
))
}
fn click(host: &Rc<Host>, x: f32, y: f32) {
let model = host.window.global::<RoughPlanModel>();
let armed = model.get_face_from_view_armed();
let (picked, building) = {
let session = host.session.borrow();
(pick_at(&session.view, (x, y), armed), session.view.building)
};
let context = ClickContext {
picked,
editable: !model.get_running(),
building,
result_view: model.get_view_mode() == 1,
group_selected: model.get_selected_group() >= 0,
};
match click_action(&context) {
ClickAction::AddBoxCut(_) | ClickAction::AddFaceCut(_) => queue_add(host, picked, (x, y)),
ClickAction::SelectCut(cut) => {
model.set_selected_cut(to_i32(cut));
super::cut_selected(host);
}
ClickAction::SelectStone(stone) => select_stone(host, stone),
ClickAction::ClearGroup => {
model.set_selected_group(-1);
request_render(host);
}
ClickAction::Nothing => {}
}
request_rehover(host);
}
fn select_stone(host: &Rc<Host>, stone: usize) {
let group = {
let session = host.session.borrow();
let view = &session.view;
if !view.fit_pickable() {
return;
}
view.scene.as_deref().and_then(|scene| match &scene.kind {
SceneKind::Fit(fit) => fit.stone_group.get(stone).copied(),
SceneKind::Model(_) => None,
})
};
if let Some(group) = group {
host.window
.global::<RoughPlanModel>()
.set_selected_group(to_i32(group));
request_render(host);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gui::rough_plan::view::scene::{FitScene, ModelScene, PiecePosition, Scene};
use indicatrix::geometry::stone_metrics::SolidMesh;
use indicatrix_solid::raster::project_point;
use std::sync::Arc;
const VIEW: (f32, f32) = (800.0, 600.0);
fn model_view(block: bool) -> ViewState {
ViewState {
view_size: VIEW,
scene: Some(Arc::new(Scene::new(SceneKind::Model(ModelScene {
mesh: SolidMesh::default(),
base_facets: 6,
cut_count: 1,
half_extents: [0.6, 0.5, 0.4],
block,
cut_planes: Vec::new(),
})))),
..ViewState::default()
}
}
fn fit_view() -> ViewState {
let fit = FitScene {
info: vec![StoneInfo {
title: "Barion".to_string(),
carat: 0.62,
position: Some(PiecePosition {
slab: 2,
bar: 1,
piece: 3,
}),
note: String::new(),
}],
..FitScene::default()
};
ViewState {
view_size: VIEW,
scene: Some(Arc::new(Scene::new(SceneKind::Fit(Box::new(fit))))),
..ViewState::default()
}
}
fn near_top_front_edge(view: &ViewState) -> (f32, f32) {
let camera = camera_for(view.pose);
let ends = BoxTarget::Edge(0).points([0.6, 0.5, 0.4]);
let a = project_point(&camera, ends[0], 800, 600).expect("in front of the camera");
let b = project_point(&camera, ends[1], 800, 600).expect("in front of the camera");
(a.0.midpoint(b.0), a.1.midpoint(b.1) + 4.0)
}
fn context(picked: Picked) -> ClickContext {
ClickContext {
picked,
editable: true,
building: false,
result_view: false,
group_selected: false,
}
}
#[test]
fn a_click_on_the_box_asks_for_its_cut_unless_the_planner_runs() {
let edge = BoxTarget::Edge(3);
assert_eq!(
click_action(&context(Picked::Box(edge))),
ClickAction::AddBoxCut(edge)
);
assert_eq!(
click_action(&context(Picked::Facet(4))),
ClickAction::AddFaceCut(4)
);
for picked in [Picked::Box(edge), Picked::Facet(4)] {
let running = ClickContext {
editable: false,
..context(picked)
};
assert_eq!(click_action(&running), ClickAction::Nothing, "{picked:?}");
}
}
#[test]
fn selecting_clicks_work_while_a_plan_runs() {
let busy = ClickContext {
editable: false,
..context(Picked::Cut(2))
};
assert_eq!(click_action(&busy), ClickAction::SelectCut(2));
let busy_stone = ClickContext {
picked: Picked::Stone(7),
..busy
};
assert_eq!(click_action(&busy_stone), ClickAction::SelectStone(7));
}
#[test]
fn nothing_is_clicked_while_a_result_is_being_built() {
for picked in [
Picked::Box(BoxTarget::Corner(1)),
Picked::Facet(2),
Picked::Cut(0),
Picked::Stone(3),
Picked::Nothing,
] {
let building = ClickContext {
building: true,
result_view: true,
group_selected: true,
..context(picked)
};
assert_eq!(click_action(&building), ClickAction::Nothing, "{picked:?}");
}
}
#[test]
fn a_click_on_nothing_clears_the_outline_only_in_a_result_with_one() {
let mut nothing = context(Picked::Nothing);
assert_eq!(click_action(¬hing), ClickAction::Nothing);
nothing.result_view = true;
assert_eq!(click_action(¬hing), ClickAction::Nothing);
nothing.group_selected = true;
assert_eq!(click_action(¬hing), ClickAction::ClearGroup);
nothing.result_view = false;
assert_eq!(click_action(¬hing), ClickAction::Nothing);
}
#[test]
fn nothing_is_picked_without_a_scene() {
let view = ViewState {
view_size: VIEW,
..ViewState::default()
};
assert_eq!(pick_at(&view, (400.0, 300.0), false), Picked::Nothing);
assert_eq!(pick_at(&view, (400.0, 300.0), true), Picked::Nothing);
}
#[test]
fn the_edge_under_the_pointer_is_picked_on_a_block() {
let view = model_view(true);
let cursor = near_top_front_edge(&view);
assert_eq!(
pick_at(&view, cursor, false),
Picked::Box(BoxTarget::Edge(0))
);
assert_eq!(pick_at(&view, (3.0, 3.0), false), Picked::Nothing);
}
#[test]
fn a_cut_away_edge_is_not_picked() {
let mut view = model_view(true);
let cursor = near_top_front_edge(&view);
let Some(scene) = view.scene.take() else {
panic!("a scene");
};
let Ok(mut scene) = Arc::try_unwrap(scene) else {
panic!("the only owner");
};
let SceneKind::Model(model) = &mut scene.kind else {
panic!("a model scene");
};
model.cut_planes = vec![(glam::Vec3::Y, 0.3)];
view.scene = Some(Arc::new(scene));
assert_ne!(
pick_at(&view, cursor, false),
Picked::Box(BoxTarget::Edge(0)),
"the edge no longer exists"
);
}
#[test]
fn other_bases_offer_no_edges_and_the_face_mode_looks_for_facets_only() {
let cylinder = model_view(false);
let cursor = near_top_front_edge(&cylinder);
assert_eq!(pick_at(&cylinder, cursor, false), Picked::Nothing);
let block = model_view(true);
assert_eq!(pick_at(&block, cursor, true), Picked::Nothing);
}
#[test]
fn a_result_scene_picks_stones_from_the_frame_only() {
let view = fit_view();
assert_eq!(pick_at(&view, (400.0, 300.0), false), Picked::Nothing);
}
#[test]
fn a_result_scene_offers_no_stones_while_another_result_is_built() {
let mut view = fit_view();
assert!(view.fit_pickable());
view.begin_build(1);
assert!(!view.fit_pickable());
assert_eq!(pick_at(&view, (400.0, 300.0), false), Picked::Nothing);
let mut model = model_view(true);
model.begin_build(1);
let cursor = near_top_front_edge(&model);
assert_eq!(
pick_at(&model, cursor, false),
Picked::Box(BoxTarget::Edge(0))
);
}
#[test]
fn the_hover_mark_and_the_hint_follow_what_is_picked() {
let model = model_view(true);
assert_eq!(
describe(&model, Picked::Nothing),
(Hover::None, String::new())
);
let edge = BoxTarget::Edge(0);
assert_eq!(
describe(&model, Picked::Box(edge)),
(Hover::Box(edge), "Click to cut edge Top-Front".to_string())
);
assert_eq!(
describe(&model, Picked::Facet(4)),
(Hover::Facet(4), FACE_HINT.to_string())
);
assert_eq!(
describe(&model, Picked::Cut(2)),
(Hover::None, "Click to select cut 3".to_string()),
"rows are numbered from 1"
);
}
#[test]
fn a_stone_hint_names_design_weight_and_position_in_the_cut_plan() {
let view = fit_view();
assert_eq!(
describe(&view, Picked::Stone(0)),
(
Hover::None,
"Barion \u{00B7} 0.62 ct \u{00B7} slab 2, bar 1, piece 3".to_string()
)
);
assert_eq!(describe(&view, Picked::Stone(9)).1, "");
assert_eq!(describe(&model_view(true), Picked::Stone(0)).1, "");
}
}