diff --git a/Cargo.lock b/Cargo.lock index bf13810e6..cadb5a575 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1181,10 +1181,13 @@ dependencies = [ "cargo_metadata", "crossbeam-channel", "fj", + "fj-interop", + "fj-operations", "libloading", "notify", "thiserror", "tracing", + "winit", ] [[package]] diff --git a/crates/fj-host/Cargo.toml b/crates/fj-host/Cargo.toml index 02a37f5f2..f47e374cf 100644 --- a/crates/fj-host/Cargo.toml +++ b/crates/fj-host/Cargo.toml @@ -15,7 +15,10 @@ categories.workspace = true cargo_metadata = "0.15.2" crossbeam-channel = "0.5.6" fj.workspace = true +fj-interop.workspace = true +fj-operations.workspace = true libloading = "0.7.4" notify = "5.0.0" thiserror = "1.0.35" tracing = "0.1.37" +winit = "0.27.5" diff --git a/crates/fj-host/src/evaluator.rs b/crates/fj-host/src/evaluator.rs deleted file mode 100644 index 307fa95f3..000000000 --- a/crates/fj-host/src/evaluator.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::thread; - -use crossbeam_channel::{Receiver, SendError, Sender}; - -use crate::{Error, Evaluation, Model}; - -/// Evaluates a model in a background thread -pub struct Evaluator { - trigger_tx: Sender, - event_rx: Receiver, -} - -impl Evaluator { - /// Create an `Evaluator` from a model - pub fn from_model(model: Model) -> Self { - let (event_tx, event_rx) = crossbeam_channel::bounded(0); - let (trigger_tx, trigger_rx) = crossbeam_channel::bounded(0); - - thread::spawn(move || { - while matches!(trigger_rx.recv(), Ok(TriggerEvaluation)) { - if let Err(SendError(_)) = - event_tx.send(ModelEvent::ChangeDetected) - { - break; - } - - let evaluation = match model.evaluate() { - Ok(evaluation) => evaluation, - Err(err) => { - if let Err(SendError(_)) = - event_tx.send(ModelEvent::Error(err)) - { - break; - } - continue; - } - }; - - if let Err(SendError(_)) = - event_tx.send(ModelEvent::Evaluation(evaluation)) - { - break; - }; - } - - // The channel is disconnected, which means this instance of - // `Evaluator`, as well as all `Sender`s created from it, have been - // dropped. We're done. - }); - - Self { - trigger_tx, - event_rx, - } - } - - /// Access a channel for triggering evaluations - pub fn trigger(&self) -> Sender { - self.trigger_tx.clone() - } - - /// Access a channel for receiving status updates - pub fn events(&self) -> Receiver { - self.event_rx.clone() - } -} - -/// Command received by [`Evaluator`] through its channel -pub struct TriggerEvaluation; - -/// An event emitted by [`Evaluator`] -pub enum ModelEvent { - /// A change in the model has been detected - ChangeDetected, - - /// The model has been evaluated - Evaluation(Evaluation), - - /// An error - Error(Error), -} diff --git a/crates/fj-host/src/host.rs b/crates/fj-host/src/host.rs index d681347a0..c973a0ec1 100644 --- a/crates/fj-host/src/host.rs +++ b/crates/fj-host/src/host.rs @@ -1,31 +1,88 @@ -use crossbeam_channel::Receiver; +use std::thread::JoinHandle; -use crate::{Error, Evaluator, Model, ModelEvent, Watcher}; +use crossbeam_channel::Sender; +use fj_operations::shape_processor::ShapeProcessor; +use winit::event_loop::EventLoopProxy; -/// A Fornjot model host +use crate::{EventLoopClosed, HostThread, Model, ModelEvent}; + +/// A host for watching models and responding to model updates pub struct Host { - evaluator: Evaluator, - _watcher: Watcher, + command_tx: Sender, + host_thread: Option>>, + model_loaded: bool, } impl Host { - /// Create a new instance of `Host` - /// - /// This is only useful, if you want to continuously watch the model for - /// changes. If you don't, just keep using `Model`. - pub fn from_model(model: Model) -> Result { - let watch_path = model.watch_path(); - let evaluator = Evaluator::from_model(model); - let watcher = Watcher::watch_model(watch_path, &evaluator)?; - - Ok(Self { - evaluator, - _watcher: watcher, - }) + /// Create a host with a shape processor and a send channel to the event + /// loop. + pub fn new( + shape_processor: ShapeProcessor, + event_loop_proxy: EventLoopProxy, + ) -> Self { + let (command_tx, host_thread) = + HostThread::spawn(shape_processor, event_loop_proxy); + + Self { + command_tx, + host_thread: Some(host_thread), + model_loaded: false, + } } - /// Access a channel with evaluation events - pub fn events(&self) -> Receiver { - self.evaluator.events() + /// Send a model to the host for evaluation and processing. + pub fn load_model(&mut self, model: Model) { + self.command_tx + .try_send(HostCommand::LoadModel(model)) + .expect("Host channel disconnected unexpectedly"); + self.model_loaded = true; } + + /// Whether a model has been sent to the host yet + pub fn is_model_loaded(&self) -> bool { + self.model_loaded + } + + /// Check if the host thread has exited with a panic. This method runs at + /// each tick of the event loop. Without an explicit check, an operation + /// will appear to hang forever (e.g. processing a model). An error + /// will be printed to the terminal, but the gui will not notice until + /// a new `HostCommand` is issued on the disconnected channel. + /// + /// # Panics + /// + /// This method panics on purpose so the main thread can exit on an + /// unrecoverable error. + pub fn propagate_panic(&mut self) { + if self.host_thread.is_none() { + unreachable!("Constructor requires host thread") + } + if let Some(host_thread) = &self.host_thread { + // The host thread should not finish while this handle holds the + // `command_tx` channel open, so an exit means the thread panicked. + if host_thread.is_finished() { + let host_thread = self.host_thread.take().unwrap(); + match host_thread.join() { + Ok(_) => { + unreachable!( + "Host thread cannot exit until host handle disconnects" + ) + } + // The error value has already been reported by the panic + // in the host thread, so just ignore it here. + Err(_) => { + panic!("Host thread panicked") + } + } + } + } + } +} + +/// Commands that can be sent to a host +pub enum HostCommand { + /// Load a model to be evaluated and processed + LoadModel(Model), + /// Used by a `Watcher` to trigger evaluation when a model is edited + TriggerEvaluation, } diff --git a/crates/fj-host/src/host_thread.rs b/crates/fj-host/src/host_thread.rs new file mode 100644 index 000000000..e72d01196 --- /dev/null +++ b/crates/fj-host/src/host_thread.rs @@ -0,0 +1,141 @@ +use std::thread::{self, JoinHandle}; + +use crossbeam_channel::{self, Receiver, Sender}; +use fj_interop::processed_shape::ProcessedShape; +use fj_operations::shape_processor::ShapeProcessor; +use winit::event_loop::EventLoopProxy; + +use crate::{Error, HostCommand, Model, Watcher}; + +// Use a zero-sized error type to silence `#[warn(clippy::result_large_err)]`. +// The only error from `EventLoopProxy::send_event` is `EventLoopClosed`, +// so we don't need the actual value. We just need to know there was an error. +pub(crate) struct EventLoopClosed; + +pub(crate) struct HostThread { + shape_processor: ShapeProcessor, + event_loop_proxy: EventLoopProxy, + command_tx: Sender, + command_rx: Receiver, +} + +impl HostThread { + // Spawn a background thread that will process models for an event loop. + pub(crate) fn spawn( + shape_processor: ShapeProcessor, + event_loop_proxy: EventLoopProxy, + ) -> (Sender, JoinHandle>) { + let (command_tx, command_rx) = crossbeam_channel::unbounded(); + let command_tx_2 = command_tx.clone(); + + let host_thread = Self { + shape_processor, + event_loop_proxy, + command_tx, + command_rx, + }; + + let join_handle = host_thread.spawn_thread(); + + (command_tx_2, join_handle) + } + + fn spawn_thread(mut self) -> JoinHandle> { + thread::Builder::new() + .name("host".to_string()) + .spawn(move || -> Result<(), EventLoopClosed> { + let mut model: Option = None; + let mut _watcher: Option = None; + + while let Ok(command) = self.command_rx.recv() { + match command { + HostCommand::LoadModel(new_model) => { + // Right now, `fj-app` will only load a new model + // once. The gui does not have a feature to load a + // new model after the initial load. If that were + // to change, there would be a race condition here + // if the prior watcher sent `TriggerEvaluation` + // before it and the model were replaced. + match Watcher::watch_model( + new_model.watch_path(), + self.command_tx.clone(), + ) { + Ok(watcher) => { + _watcher = Some(watcher); + self.send_event(ModelEvent::StartWatching)?; + } + + Err(err) => { + self.send_event(ModelEvent::Error(err))?; + continue; + } + } + self.process_model(&new_model)?; + model = Some(new_model); + } + HostCommand::TriggerEvaluation => { + self.send_event(ModelEvent::ChangeDetected)?; + if let Some(model) = &model { + self.process_model(model)?; + } + } + } + } + + Ok(()) + }) + .expect("Cannot create OS thread for host") + } + + // Evaluate and process a model. + fn process_model(&mut self, model: &Model) -> Result<(), EventLoopClosed> { + let evaluation = match model.evaluate() { + Ok(evaluation) => evaluation, + + Err(err) => { + self.send_event(ModelEvent::Error(err))?; + return Ok(()); + } + }; + + self.send_event(ModelEvent::Evaluated)?; + + match self.shape_processor.process(&evaluation.shape) { + Ok(shape) => self.send_event(ModelEvent::ProcessedShape(shape))?, + + Err(err) => { + self.send_event(ModelEvent::Error(err.into()))?; + } + } + + Ok(()) + } + + // Send a message to the event loop. + fn send_event(&mut self, event: ModelEvent) -> Result<(), EventLoopClosed> { + self.event_loop_proxy + .send_event(event) + .map_err(|_| EventLoopClosed)?; + + Ok(()) + } +} + +/// An event emitted by the host thread +#[derive(Debug)] +pub enum ModelEvent { + /// A new model is being watched + StartWatching, + + /// A change in the model has been detected + ChangeDetected, + + /// The model has been evaluated + Evaluated, + + /// The model has been processed + ProcessedShape(ProcessedShape), + + /// An error + Error(Error), +} diff --git a/crates/fj-host/src/lib.rs b/crates/fj-host/src/lib.rs index 1e6526830..9c4558b1a 100644 --- a/crates/fj-host/src/lib.rs +++ b/crates/fj-host/src/lib.rs @@ -15,16 +15,18 @@ #![warn(missing_docs)] -mod evaluator; mod host; +mod host_thread; mod model; mod parameters; mod platform; mod watcher; +pub(crate) use self::host_thread::{EventLoopClosed, HostThread}; + pub use self::{ - evaluator::{Evaluator, ModelEvent}, - host::Host, + host::{Host, HostCommand}, + host_thread::ModelEvent, model::{Error, Evaluation, Model}, parameters::Parameters, watcher::Watcher, diff --git a/crates/fj-host/src/model.rs b/crates/fj-host/src/model.rs index 0e752215e..88f4b2f00 100644 --- a/crates/fj-host/src/model.rs +++ b/crates/fj-host/src/model.rs @@ -1,3 +1,5 @@ +#![allow(clippy::result_large_err)] + use std::{ io, path::{Path, PathBuf}, @@ -6,6 +8,7 @@ use std::{ }; use fj::{abi, version::Version}; +use fj_operations::shape_processor; use tracing::{debug, warn}; use crate::{platform::HostPlatform, Parameters}; @@ -170,6 +173,7 @@ impl Model { /// The result of evaluating a model /// /// See [`Model::evaluate`]. +#[derive(Debug)] pub struct Evaluation { /// The shape pub shape: fj::Shape, @@ -253,6 +257,7 @@ fn ambiguous_path_error( } /// An error that can occur when loading or reloading a model +#[allow(clippy::large_enum_variant)] #[derive(Debug, thiserror::Error)] pub enum Error { /// Error loading model library @@ -312,6 +317,11 @@ pub enum Error { #[error("Unable to determine the model's geometry")] Shape(#[source] fj::models::Error), + /// An error was returned from + /// [`fj_operations::shape_processor::ShapeProcessor::process()`]. + #[error("Shape processing error")] + ShapeProcessor(#[from] shape_processor::Error), + /// Error while watching the model code for changes #[error("Error watching model for changes")] Notify(#[from] notify::Error), diff --git a/crates/fj-host/src/watcher.rs b/crates/fj-host/src/watcher.rs index 4f56ff4bb..bf3761fbb 100644 --- a/crates/fj-host/src/watcher.rs +++ b/crates/fj-host/src/watcher.rs @@ -1,8 +1,11 @@ -use std::{collections::HashSet, ffi::OsStr, path::Path, thread}; +#![allow(clippy::result_large_err)] +use std::{collections::HashSet, ffi::OsStr, path::Path}; + +use crossbeam_channel::Sender; use notify::Watcher as _; -use crate::{evaluator::TriggerEvaluation, Error, Evaluator}; +use crate::{Error, HostCommand}; /// Watches a model for changes, reloading it continually pub struct Watcher { @@ -13,13 +16,10 @@ impl Watcher { /// Watch the provided model for changes pub fn watch_model( watch_path: impl AsRef, - evaluator: &Evaluator, + host_tx: Sender, ) -> Result { let watch_path = watch_path.as_ref(); - let watch_tx = evaluator.trigger(); - let watch_tx_2 = evaluator.trigger(); - let mut watcher = notify::recommended_watcher( move |event: notify::Result| { // Unfortunately the `notify` documentation doesn't say when @@ -59,8 +59,8 @@ impl Watcher { // application is being shut down. // // Either way, not much we can do about it here. - watch_tx - .send(TriggerEvaluation) + host_tx + .send(HostCommand::TriggerEvaluation) .expect("Channel is disconnected"); } }, @@ -68,21 +68,6 @@ impl Watcher { watcher.watch(watch_path, notify::RecursiveMode::Recursive)?; - // To prevent a race condition between the initial load and the start of - // watching, we'll trigger the initial load here, after having started - // watching. - // - // This happens in a separate thread, because the channel is bounded and - // has no buffer. - // - // Will panic, if the receiving end has panicked. Not much we can do - // about that, if it happened. - thread::spawn(move || { - watch_tx_2 - .send(TriggerEvaluation) - .expect("Channel is disconnected"); - }); - Ok(Self { _watcher: Box::new(watcher), }) diff --git a/crates/fj-window/src/event_loop_handler.rs b/crates/fj-window/src/event_loop_handler.rs index 996676d2b..1b99dcbaf 100644 --- a/crates/fj-window/src/event_loop_handler.rs +++ b/crates/fj-window/src/event_loop_handler.rs @@ -1,5 +1,5 @@ use fj_host::{Host, Model, ModelEvent, Parameters}; -use fj_operations::shape_processor::{self, ShapeProcessor}; +use fj_operations::shape_processor; use fj_viewer::{ GuiState, InputEvent, NormalizedScreenPosition, Screen, ScreenSize, StatusReport, Viewer, @@ -17,11 +17,10 @@ use crate::window::Window; pub struct EventLoopHandler { pub invert_zoom: bool, - pub shape_processor: ShapeProcessor, pub window: Window, pub viewer: Viewer, pub egui_winit_state: egui_winit::State, - pub host: Option, + pub host: Host, pub status: StatusReport, pub held_mouse_button: Option, @@ -37,50 +36,11 @@ impl EventLoopHandler { #[allow(clippy::result_large_err)] pub fn handle_event( &mut self, - event: Event<()>, + event: Event, control_flow: &mut ControlFlow, ) -> Result<(), Error> { - if let Some(host) = &self.host { - loop { - let events = host.events(); - let event = events - .try_recv() - .map_err(|err| { - assert!( - !err.is_disconnected(), - "Expected channel to never disconnect" - ); - }) - .ok(); - - let Some(event) = event else { - break - }; - - match event { - ModelEvent::ChangeDetected => { - self.status.update_status( - "Change in model detected. Evaluating model...", - ); - } - ModelEvent::Evaluation(evaluation) => { - self.status.update_status( - "Model evaluated. Processing model...", - ); - - let shape = - self.shape_processor.process(&evaluation.shape)?; - self.viewer.handle_shape_update(shape); - - self.status.update_status("Model processed."); - } - - ModelEvent::Error(err) => { - return Err(err.into()); - } - } - } - } + // Trigger a panic if the host thead has panicked. + self.host.propagate_panic(); if let Event::WindowEvent { event, .. } = &event { let egui_winit::EventResponse { @@ -99,8 +59,42 @@ impl EventLoopHandler { } } + let input_event = input_event( + &event, + &self.window, + &self.held_mouse_button, + &mut self.viewer.cursor, + self.invert_zoom, + ); + if let Some(input_event) = input_event { + self.viewer.handle_input_event(input_event); + } + // fj-window events match event { + Event::UserEvent(event) => match event { + ModelEvent::StartWatching => { + self.status + .update_status("New model loaded. Evaluating model..."); + } + ModelEvent::ChangeDetected => { + self.status.update_status( + "Change in model detected. Evaluating model...", + ); + } + ModelEvent::Evaluated => { + self.status + .update_status("Model evaluated. Processing model..."); + } + ModelEvent::ProcessedShape(shape) => { + self.viewer.handle_shape_update(shape); + self.status.update_status("Model processed."); + } + + ModelEvent::Error(err) => { + return Err(err.into()); + } + }, Event::WindowEvent { event: WindowEvent::CloseRequested, .. @@ -183,7 +177,7 @@ impl EventLoopHandler { let gui_state = GuiState { status: &self.status, - model_available: self.host.is_some(), + model_available: self.host.is_model_loaded(), }; let new_model_path = self.viewer.draw( pixels_per_point, @@ -191,27 +185,15 @@ impl EventLoopHandler { gui_state, ); if let Some(model_path) = new_model_path { - let model = Model::new(model_path, Parameters::empty()) - .unwrap(); - let new_host = Host::from_model(model)?; - self.host = Some(new_host); + let model = + Model::new(model_path, Parameters::empty())?; + self.host.load_model(model); } } } _ => {} } - let input_event = input_event( - &event, - &self.window, - &self.held_mouse_button, - &mut self.viewer.cursor, - self.invert_zoom, - ); - if let Some(input_event) = input_event { - self.viewer.handle_input_event(input_event); - } - Ok(()) } } diff --git a/crates/fj-window/src/run.rs b/crates/fj-window/src/run.rs index 35f57c194..b83e0b6e6 100644 --- a/crates/fj-window/src/run.rs +++ b/crates/fj-window/src/run.rs @@ -3,17 +3,19 @@ //! Provides the functionality to create a window and perform basic viewing //! with programmed models. +#![allow(clippy::result_large_err)] + use std::{ error, fmt::{self, Write}, }; -use fj_host::{Host, Model}; +use fj_host::{Host, Model, ModelEvent}; use fj_operations::shape_processor::ShapeProcessor; use fj_viewer::{RendererInitError, StatusReport, Viewer}; use futures::executor::block_on; use tracing::trace; -use winit::event_loop::EventLoop; +use winit::event_loop::EventLoopBuilder; use crate::{ event_loop_handler::{self, EventLoopHandler}, @@ -26,17 +28,20 @@ pub fn run( shape_processor: ShapeProcessor, invert_zoom: bool, ) -> Result<(), Error> { - let event_loop = EventLoop::new(); + let event_loop = EventLoopBuilder::::with_user_event().build(); let window = Window::new(&event_loop)?; let viewer = block_on(Viewer::new(&window))?; let egui_winit_state = egui_winit::State::new(&event_loop); - let host = model.map(Host::from_model).transpose()?; + let mut host = Host::new(shape_processor, event_loop.create_proxy()); + + if let Some(model) = model { + host.load_model(model); + } let mut handler = EventLoopHandler { invert_zoom, - shape_processor, window, viewer, egui_winit_state,