diff --git a/Cargo.lock b/Cargo.lock index dd7521047..e9be735fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -994,6 +994,7 @@ name = "fj-host" version = "0.20.0" dependencies = [ "cargo_metadata", + "crossbeam-channel", "fj", "fj-interop", "libloading", diff --git a/crates/fj-app/src/main.rs b/crates/fj-app/src/main.rs index 283efecc7..6a96e86b5 100644 --- a/crates/fj-app/src/main.rs +++ b/crates/fj-app/src/main.rs @@ -19,7 +19,7 @@ use std::path::PathBuf; use anyhow::{anyhow, Context as _}; use fj_export::export; -use fj_host::{Model, Parameters}; +use fj_host::{Model, Parameters, Watcher}; use fj_interop::status_report::StatusReport; use fj_operations::shape_processor::ShapeProcessor; use fj_window::run::run; @@ -29,7 +29,7 @@ use tracing_subscriber::EnvFilter; use crate::{args::Args, config::Config}; fn main() -> anyhow::Result<()> { - let mut status = StatusReport::new(); + let status = StatusReport::new(); // Respect `RUST_LOG`. If that's not defined or erroneous, log warnings and // above. // @@ -58,7 +58,7 @@ fn main() -> anyhow::Result<()> { { let mut model_path = path; model_path.push(model); - Model::from_path(model_path.clone()).with_context(|| { + Model::new(model_path.clone(), parameters).with_context(|| { if path_of_model.as_os_str().is_empty() { format!( "Model is not defined, can't find model defined inside the default-model also, add model like \n cargo run -- -m {}", model.display() @@ -80,8 +80,8 @@ fn main() -> anyhow::Result<()> { if let Some(export_path) = args.export { // export only mode. just load model, process, export and exit - let shape = model.load_once(¶meters, &mut status)?; - let shape = shape_processor.process(&shape)?; + let shape = model.load()?; + let shape = shape_processor.process(&shape.shape)?; export(&shape.mesh, &export_path)?; @@ -90,7 +90,7 @@ fn main() -> anyhow::Result<()> { let invert_zoom = config.invert_zoom.unwrap_or(false); - let watcher = model.load_and_watch(parameters)?; + let watcher = Watcher::watch_model(model)?; run(watcher, shape_processor, status, invert_zoom)?; Ok(()) diff --git a/crates/fj-host/Cargo.toml b/crates/fj-host/Cargo.toml index c02df4934..8cd36049e 100644 --- a/crates/fj-host/Cargo.toml +++ b/crates/fj-host/Cargo.toml @@ -13,6 +13,7 @@ categories.workspace = true [dependencies] cargo_metadata = "0.15.0" +crossbeam-channel = "0.5.6" fj.workspace = true fj-interop.workspace = true libloading = "0.7.2" diff --git a/crates/fj-host/src/host.rs b/crates/fj-host/src/host.rs new file mode 100644 index 000000000..e66a738dd --- /dev/null +++ b/crates/fj-host/src/host.rs @@ -0,0 +1,31 @@ +use crate::Parameters; + +pub struct Host<'a> { + args: &'a Parameters, + model: Option>, +} + +impl<'a> Host<'a> { + pub fn new(parameters: &'a Parameters) -> Self { + Self { + args: parameters, + model: None, + } + } + + pub fn take_model(&mut self) -> Option> { + self.model.take() + } +} + +impl<'a> fj::models::Host for Host<'a> { + fn register_boxed_model(&mut self, model: Box) { + self.model = Some(model); + } +} + +impl<'a> fj::models::Context for Host<'a> { + fn get_argument(&self, name: &str) -> Option<&str> { + self.args.get(name).map(|s| s.as_str()) + } +} diff --git a/crates/fj-host/src/lib.rs b/crates/fj-host/src/lib.rs index 50c50e822..9f39e6281 100644 --- a/crates/fj-host/src/lib.rs +++ b/crates/fj-host/src/lib.rs @@ -15,465 +15,14 @@ #![warn(missing_docs)] +mod host; +mod model; +mod parameters; mod platform; +mod watcher; -use fj_interop::status_report::StatusReport; -use std::{ - collections::{HashMap, HashSet}, - ffi::OsStr, - io, - ops::{Deref, DerefMut}, - path::{Path, PathBuf}, - process::Command, - str, - sync::mpsc, - thread, +pub use self::{ + model::{Error, LoadedShape, Model}, + parameters::Parameters, + watcher::{Watcher, WatcherEvent}, }; - -use fj::{abi, version::RawVersion}; -use notify::Watcher as _; -use thiserror::Error; - -use self::platform::HostPlatform; - -/// Represents a Fornjot model -pub struct Model { - src_path: PathBuf, - lib_path: PathBuf, - manifest_path: PathBuf, -} - -impl Model { - /// Initialize the model using the path to its crate - /// - /// The path expected here is the root directory of the model's Cargo - /// package, that is the folder containing `Cargo.toml`. - pub fn from_path(path: PathBuf) -> Result { - let crate_dir = path.canonicalize()?; - - let metadata = cargo_metadata::MetadataCommand::new() - .current_dir(&crate_dir) - .exec()?; - - let pkg = package_associated_with_directory(&metadata, &crate_dir)?; - let src_path = crate_dir.join("src"); - - let lib_path = { - let name = pkg.name.replace('-', "_"); - let file = HostPlatform::lib_file_name(&name); - let target_dir = - metadata.target_directory.clone().into_std_path_buf(); - target_dir.join("debug").join(file) - }; - - Ok(Self { - src_path, - lib_path, - manifest_path: pkg.manifest_path.as_std_path().to_path_buf(), - }) - } - - /// Load the model once - /// - /// The passed arguments are provided to the model. Returns the shape that - /// the model returns. - /// - /// Please refer to [`Model::load_and_watch`], if you want to watch the - /// model for changes, reloading it continually. - pub fn load_once( - &self, - arguments: &Parameters, - status: &mut StatusReport, - ) -> Result { - let manifest_path = self.manifest_path.display().to_string(); - - let mut command_root = Command::new("cargo"); - - let command = command_root - .arg("rustc") - .args(["--manifest-path", &manifest_path]) - .args(["--crate-type", "cdylib"]); - - let cargo_output = command.output()?; - let exit_status = cargo_output.status; - - if exit_status.success() { - let seconds_taken = str::from_utf8(&cargo_output.stderr) - .unwrap() - .rsplit_once(' ') - .unwrap() - .1 - .trim(); - status.update_status( - format!("Model compiled successfully in {seconds_taken}!") - .as_str(), - ); - } else { - let output = match command.output() { - Ok(output) => { - String::from_utf8(output.stderr).unwrap_or_else(|_| { - String::from("Failed to fetch command output") - }) - } - Err(_) => String::from("Failed to fetch command output"), - }; - status.clear_status(); - status.update_status(&format!( - "Failed to compile model:\n{}", - output - )); - return Err(Error::Compile); - } - - // So, strictly speaking this is all unsound: - // - `Library::new` requires us to abide by the arbitrary requirements - // of any library initialization or termination routines. - // - `Library::get` requires us to specify the correct type for the - // model function. - // - The model function itself is `unsafe`, because it is a function - // from across an FFI interface. - // - // Typical models won't have initialization or termination routines (I - // think), should abide by the `ModelFn` signature, and might not do - // anything unsafe. But we have no way to know that the library the user - // told us to load actually does (I think). - // - // I don't know of a way to fix this. We should take this as motivation - // to switch to a better technique: - // https://github.com/hannobraun/Fornjot/issues/71 - let shape = unsafe { - let lib = libloading::Library::new(&self.lib_path)?; - - let version_pkg: libloading::Symbol RawVersion> = - lib.get(b"version_pkg")?; - - let version_pkg = version_pkg(); - if fj::version::VERSION_PKG != version_pkg.as_str() { - let host = String::from_utf8_lossy( - fj::version::VERSION_PKG.as_bytes(), - ) - .into_owned(); - let model = - String::from_utf8_lossy(version_pkg.as_str().as_bytes()) - .into_owned(); - - return Err(Error::VersionMismatch { host, model }); - } - - let init: libloading::Symbol = - lib.get(abi::INIT_FUNCTION_NAME.as_bytes())?; - - let mut host = Host { - args: arguments, - model: None, - }; - - match init(&mut abi::Host::from(&mut host)) { - abi::ffi_safe::Result::Ok(_metadata) => {} - abi::ffi_safe::Result::Err(e) => { - return Err(Error::InitializeModel(e.into())); - } - } - - let model = host.model.take().ok_or(Error::NoModelRegistered)?; - - model.shape(&host).map_err(Error::Shape)? - }; - - Ok(shape) - } - - /// Load the model, then watch it for changes - /// - /// Whenever a change is detected, the model is being reloaded. - /// - /// Consumes this instance of `Model` and returns a [`Watcher`], which can - /// be queried for changes to the model. - pub fn load_and_watch( - self, - parameters: Parameters, - ) -> Result { - let (tx, rx) = mpsc::sync_channel(0); - let tx2 = tx.clone(); - - let watch_path = self.src_path.clone(); - - let mut watcher = notify::recommended_watcher( - move |event: notify::Result| { - // Unfortunately the `notify` documentation doesn't say when - // this might happen, so no idea if it needs to be handled. - let event = event.expect("Error handling watch event"); - - // Various acceptable ModifyKind kinds. Varies across platforms - // (e.g. MacOs vs. Windows10) - if let notify::EventKind::Modify( - notify::event::ModifyKind::Any, - ) - | notify::EventKind::Modify( - notify::event::ModifyKind::Data( - notify::event::DataChange::Any, - ), - ) - | notify::EventKind::Modify( - notify::event::ModifyKind::Data( - notify::event::DataChange::Content, - ), - ) = event.kind - { - let file_ext = event - .paths - .get(0) - .expect("File path missing in watch event") - .extension(); - - let black_list = HashSet::from([ - OsStr::new("swp"), - OsStr::new("tmp"), - OsStr::new("swx"), - ]); - - if let Some(ext) = file_ext { - if black_list.contains(ext) { - return; - } - } - - // This will panic, if the other end is disconnected, which - // is probably the result of a panic on that thread, or the - // application is being shut down. - // - // Either way, not much we can do about it here. - tx.send(()).expect("Channel is disconnected"); - } - }, - )?; - - 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. - // - // Will panic, if the receiving end has panicked. Not much we can do - // about that, if it happened. - thread::spawn(move || tx2.send(()).expect("Channel is disconnected")); - - Ok(Watcher { - _watcher: Box::new(watcher), - channel: rx, - model: self, - parameters, - }) - } -} - -fn package_associated_with_directory<'m>( - metadata: &'m cargo_metadata::Metadata, - dir: &Path, -) -> Result<&'m cargo_metadata::Package, Error> { - for pkg in metadata.workspace_packages() { - let crate_dir = pkg - .manifest_path - .parent() - .and_then(|p| p.canonicalize().ok()); - - if crate_dir.as_deref() == Some(dir) { - return Ok(pkg); - } - } - - Err(ambiguous_path_error(metadata, dir)) -} - -fn ambiguous_path_error( - metadata: &cargo_metadata::Metadata, - dir: &Path, -) -> Error { - let mut possible_paths = Vec::new(); - - for id in &metadata.workspace_members { - let cargo_toml = &metadata[id].manifest_path; - let crate_dir = cargo_toml - .parent() - .expect("A Cargo.toml always has a parent"); - // Try to make the path relative to the workspace root so error messages - // aren't super long. - let simplified_path = crate_dir - .strip_prefix(&metadata.workspace_root) - .unwrap_or(crate_dir); - - possible_paths.push(simplified_path.into()); - } - - Error::AmbiguousPath { - dir: dir.to_path_buf(), - possible_paths, - } -} - -/// Watches a model for changes, reloading it continually -pub struct Watcher { - _watcher: Box, - channel: mpsc::Receiver<()>, - model: Model, - parameters: Parameters, -} - -impl Watcher { - /// Receive an updated shape that the reloaded model created - /// - /// Returns `None`, if the model has not changed since the last time this - /// method was called. - pub fn receive_shape( - &self, - status: &mut StatusReport, - ) -> Result, Error> { - match self.channel.try_recv() { - Ok(()) => { - let shape = match self.model.load_once(&self.parameters, status) - { - Ok(shape) => shape, - Err(Error::Compile) => { - // An error is being displayed to the user via the - // `StatusReport that is passed to `load_once` above, so - // no need to do anything else here. - return Ok(None); - } - Err(err) => { - return Err(err); - } - }; - - Ok(Some(shape)) - } - Err(mpsc::TryRecvError::Empty) => { - // Nothing to receive from the channel. - Ok(None) - } - Err(mpsc::TryRecvError::Disconnected) => { - // The other end has disconnected. This is probably the result - // of a panic on the other thread, or a program shutdown in - // progress. In any case, not much we can do here. - panic!(); - } - } - } -} - -/// Parameters that are passed to a model. -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct Parameters(pub HashMap); - -impl Parameters { - /// Construct an empty instance of `Parameters` - pub fn empty() -> Self { - Self(HashMap::new()) - } - - /// Insert a value into the [`Parameters`] dictionary, implicitly converting - /// the arguments to strings and returning `&mut self` to enable chaining. - pub fn insert( - &mut self, - key: impl Into, - value: impl ToString, - ) -> &mut Self { - self.0.insert(key.into(), value.to_string()); - self - } -} - -impl Deref for Parameters { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for Parameters { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -/// An error that can occur when loading or reloading a model -#[derive(Debug, Error)] -pub enum Error { - /// Model failed to compile - #[error("Error compiling model")] - Compile, - - /// I/O error while loading the model - #[error("I/O error while loading model")] - Io(#[from] io::Error), - - /// Failed to load the model's dynamic library - #[error("Error loading model from dynamic library")] - LibLoading(#[from] libloading::Error), - - /// Host version and model version do not match - #[error("Host version ({host}) and model version ({model}) do not match")] - VersionMismatch { - /// The host version - host: String, - - /// The model version - model: String, - }, - - /// Initializing a model failed. - #[error("Unable to initialize the model")] - InitializeModel(#[source] fj::models::Error), - - /// The user forgot to register a model when calling - /// [`fj::register_model!()`]. - #[error("No model was registered")] - NoModelRegistered, - - /// An error was returned from [`fj::models::Model::shape()`]. - #[error("Unable to determine the model's geometry")] - Shape(#[source] fj::models::Error), - - /// Error while watching the model code for changes - #[error("Error watching model for changes")] - Notify(#[from] notify::Error), - - /// An error occurred while trying to use evaluate - /// [`cargo_metadata::MetadataCommand`]. - #[error("Unable to determine the crate's metadata")] - CargoMetadata(#[from] cargo_metadata::Error), - - /// The user pointed us to a directory, but it doesn't look like that was - /// a crate root (i.e. the folder containing `Cargo.toml`). - #[error( - "It doesn't look like \"{}\" is a crate directory. Did you mean one of {}?", - dir.display(), - possible_paths.iter().map(|p| p.display().to_string()) - .collect::>() - .join(", ") - )] - AmbiguousPath { - /// The model directory supplied by the user. - dir: PathBuf, - /// The directories for each crate in the workspace, relative to the - /// workspace root. - possible_paths: Vec, - }, -} - -struct Host<'a> { - args: &'a Parameters, - model: Option>, -} - -impl<'a> fj::models::Host for Host<'a> { - fn register_boxed_model(&mut self, model: Box) { - self.model = Some(model); - } -} - -impl<'a> fj::models::Context for Host<'a> { - fn get_argument(&self, name: &str) -> Option<&str> { - self.args.get(name).map(|s| s.as_str()) - } -} diff --git a/crates/fj-host/src/model.rs b/crates/fj-host/src/model.rs new file mode 100644 index 000000000..a27039018 --- /dev/null +++ b/crates/fj-host/src/model.rs @@ -0,0 +1,264 @@ +use std::{ + io, + path::{Path, PathBuf}, + process::Command, + str, +}; + +use fj::{abi, version::RawVersion}; + +use crate::{host::Host, platform::HostPlatform, Parameters}; + +/// Represents a Fornjot model +pub struct Model { + src_path: PathBuf, + lib_path: PathBuf, + manifest_path: PathBuf, + parameters: Parameters, +} + +impl Model { + /// Initialize the model using the path to its crate + /// + /// The path expected here is the root directory of the model's Cargo + /// package, that is the folder containing `Cargo.toml`. + pub fn new(path: PathBuf, parameters: Parameters) -> Result { + let crate_dir = path.canonicalize()?; + + let metadata = cargo_metadata::MetadataCommand::new() + .current_dir(&crate_dir) + .exec()?; + + let pkg = package_associated_with_directory(&metadata, &crate_dir)?; + let src_path = crate_dir.join("src"); + + let lib_path = { + let name = pkg.name.replace('-', "_"); + let file = HostPlatform::lib_file_name(&name); + let target_dir = + metadata.target_directory.clone().into_std_path_buf(); + target_dir.join("debug").join(file) + }; + + Ok(Self { + src_path, + lib_path, + manifest_path: pkg.manifest_path.as_std_path().to_path_buf(), + parameters, + }) + } + + pub(crate) fn src_path(&self) -> PathBuf { + self.src_path.clone() + } + + /// Load the model once + /// + /// The passed arguments are provided to the model. Returns the shape that + /// the model returns. + pub fn load(&self) -> Result { + let manifest_path = self.manifest_path.display().to_string(); + + let cargo_output = Command::new("cargo") + .arg("rustc") + .args(["--manifest-path", &manifest_path]) + .args(["--crate-type", "cdylib"]) + .output()?; + + if !cargo_output.status.success() { + let output = + String::from_utf8(cargo_output.stderr).unwrap_or_else(|_| { + String::from("Failed to fetch command output") + }); + + return Err(Error::Compile { output }); + } + + let seconds_taken = str::from_utf8(&cargo_output.stderr) + .unwrap() + .rsplit_once(' ') + .unwrap() + .1 + .trim(); + + // So, strictly speaking this is all unsound: + // - `Library::new` requires us to abide by the arbitrary requirements + // of any library initialization or termination routines. + // - `Library::get` requires us to specify the correct type for the + // model function. + // - The model function itself is `unsafe`, because it is a function + // from across an FFI interface. + // + // Typical models won't have initialization or termination routines (I + // think), should abide by the `ModelFn` signature, and might not do + // anything unsafe. But we have no way to know that the library the user + // told us to load actually does (I think). + // + // I don't know of a way to fix this. We should take this as motivation + // to switch to a better technique: + // https://github.com/hannobraun/Fornjot/issues/71 + let shape = unsafe { + let lib = libloading::Library::new(&self.lib_path)?; + + let version_pkg: libloading::Symbol RawVersion> = + lib.get(b"version_pkg")?; + + let version_pkg = version_pkg(); + if fj::version::VERSION_PKG != version_pkg.as_str() { + let host = String::from_utf8_lossy( + fj::version::VERSION_PKG.as_bytes(), + ) + .into_owned(); + let model = + String::from_utf8_lossy(version_pkg.as_str().as_bytes()) + .into_owned(); + + return Err(Error::VersionMismatch { host, model }); + } + + let init: libloading::Symbol = + lib.get(abi::INIT_FUNCTION_NAME.as_bytes())?; + + let mut host = Host::new(&self.parameters); + + match init(&mut abi::Host::from(&mut host)) { + abi::ffi_safe::Result::Ok(_metadata) => {} + abi::ffi_safe::Result::Err(e) => { + return Err(Error::InitializeModel(e.into())); + } + } + + let model = host.take_model().ok_or(Error::NoModelRegistered)?; + + model.shape(&host).map_err(Error::Shape)? + }; + + Ok(LoadedShape { + shape, + compile_time: seconds_taken.into(), + }) + } +} + +/// A loaded shape, together with additional metadata +/// +/// See [`Model::load`]. +pub struct LoadedShape { + /// The shape + pub shape: fj::Shape, + + /// The time it took to compile the shape, from the Cargo output + pub compile_time: String, +} + +fn package_associated_with_directory<'m>( + metadata: &'m cargo_metadata::Metadata, + dir: &Path, +) -> Result<&'m cargo_metadata::Package, Error> { + for pkg in metadata.workspace_packages() { + let crate_dir = pkg + .manifest_path + .parent() + .and_then(|p| p.canonicalize().ok()); + + if crate_dir.as_deref() == Some(dir) { + return Ok(pkg); + } + } + + Err(ambiguous_path_error(metadata, dir)) +} + +fn ambiguous_path_error( + metadata: &cargo_metadata::Metadata, + dir: &Path, +) -> Error { + let mut possible_paths = Vec::new(); + + for id in &metadata.workspace_members { + let cargo_toml = &metadata[id].manifest_path; + let crate_dir = cargo_toml + .parent() + .expect("A Cargo.toml always has a parent"); + // Try to make the path relative to the workspace root so error messages + // aren't super long. + let simplified_path = crate_dir + .strip_prefix(&metadata.workspace_root) + .unwrap_or(crate_dir); + + possible_paths.push(simplified_path.into()); + } + + Error::AmbiguousPath { + dir: dir.to_path_buf(), + possible_paths, + } +} + +/// An error that can occur when loading or reloading a model +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Model failed to compile + #[error("Error compiling model")] + Compile { + /// The compiler output + output: String, + }, + + /// I/O error while loading the model + #[error("I/O error while loading model")] + Io(#[from] io::Error), + + /// Failed to load the model's dynamic library + #[error("Error loading model from dynamic library")] + LibLoading(#[from] libloading::Error), + + /// Host version and model version do not match + #[error("Host version ({host}) and model version ({model}) do not match")] + VersionMismatch { + /// The host version + host: String, + + /// The model version + model: String, + }, + + /// Initializing a model failed. + #[error("Unable to initialize the model")] + InitializeModel(#[source] fj::models::Error), + + /// The user forgot to register a model when calling + /// [`fj::register_model!()`]. + #[error("No model was registered")] + NoModelRegistered, + + /// An error was returned from [`fj::models::Model::shape()`]. + #[error("Unable to determine the model's geometry")] + Shape(#[source] fj::models::Error), + + /// Error while watching the model code for changes + #[error("Error watching model for changes")] + Notify(#[from] notify::Error), + + /// An error occurred while trying to use evaluate + /// [`cargo_metadata::MetadataCommand`]. + #[error("Unable to determine the crate's metadata")] + CargoMetadata(#[from] cargo_metadata::Error), + + /// The user pointed us to a directory, but it doesn't look like that was + /// a crate root (i.e. the folder containing `Cargo.toml`). + #[error( + "It doesn't look like \"{}\" is a crate directory. Did you mean one of {}?", + dir.display(), + possible_paths.iter().map(|p| p.display().to_string()) + .collect::>() + .join(", ") + )] + AmbiguousPath { + /// The model directory supplied by the user. + dir: PathBuf, + /// The directories for each crate in the workspace, relative to the + /// workspace root. + possible_paths: Vec, + }, +} diff --git a/crates/fj-host/src/parameters.rs b/crates/fj-host/src/parameters.rs new file mode 100644 index 000000000..bdfd658a0 --- /dev/null +++ b/crates/fj-host/src/parameters.rs @@ -0,0 +1,40 @@ +use std::{ + collections::HashMap, + ops::{Deref, DerefMut}, +}; + +/// Parameters that are passed to a model. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Parameters(pub HashMap); + +impl Parameters { + /// Construct an empty instance of `Parameters` + pub fn empty() -> Self { + Self(HashMap::new()) + } + + /// Insert a value into the [`Parameters`] dictionary, implicitly converting + /// the arguments to strings and returning `&mut self` to enable chaining. + pub fn insert( + &mut self, + key: impl Into, + value: impl ToString, + ) -> &mut Self { + self.0.insert(key.into(), value.to_string()); + self + } +} + +impl Deref for Parameters { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Parameters { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/crates/fj-host/src/watcher.rs b/crates/fj-host/src/watcher.rs new file mode 100644 index 000000000..0456ef6fc --- /dev/null +++ b/crates/fj-host/src/watcher.rs @@ -0,0 +1,144 @@ +use std::{collections::HashSet, ffi::OsStr, thread}; + +use crossbeam_channel::Receiver; +use notify::Watcher as _; + +use crate::{Error, LoadedShape, Model}; + +/// Watches a model for changes, reloading it continually +pub struct Watcher { + _watcher: Box, + event_rx: Receiver, +} + +impl Watcher { + /// Watch the provided model for changes + pub fn watch_model(model: Model) -> Result { + let (event_tx, event_rx) = crossbeam_channel::bounded(0); + + let (watch_tx, watch_rx) = crossbeam_channel::bounded(0); + let watch_tx_2 = watch_tx.clone(); + + let watch_path = model.src_path(); + + let mut watcher = notify::recommended_watcher( + move |event: notify::Result| { + // Unfortunately the `notify` documentation doesn't say when + // this might happen, so no idea if it needs to be handled. + let event = event.expect("Error handling watch event"); + + // Various acceptable ModifyKind kinds. Varies across platforms + // (e.g. MacOs vs. Windows10) + if let notify::EventKind::Modify( + notify::event::ModifyKind::Any, + ) + | notify::EventKind::Modify( + notify::event::ModifyKind::Data( + notify::event::DataChange::Any, + ), + ) + | notify::EventKind::Modify( + notify::event::ModifyKind::Data( + notify::event::DataChange::Content, + ), + ) = event.kind + { + let file_ext = event + .paths + .get(0) + .expect("File path missing in watch event") + .extension(); + + let black_list = HashSet::from([ + OsStr::new("swp"), + OsStr::new("tmp"), + OsStr::new("swx"), + ]); + + if let Some(ext) = file_ext { + if black_list.contains(ext) { + return; + } + } + + // This will panic, if the other end is disconnected, which + // is probably the result of a panic on that thread, or the + // application is being shut down. + // + // Either way, not much we can do about it here. + watch_tx.send(()).expect("Channel is disconnected"); + } + }, + )?; + + 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(()).expect("Channel is disconnected") + }); + + // Listen on the watcher channel and rebuild the model. This happens in + // a separate thread from the watcher to allow us to trigger compiles + // without the watcher having registered a change, as is done above. + thread::spawn(move || loop { + let () = watch_rx + .recv() + .expect("Expected channel to never disconnect"); + + let shape = match model.load() { + Ok(shape) => shape, + Err(Error::Compile { output }) => { + event_tx + .send(WatcherEvent::StatusUpdate(format!( + "Failed to compile model:\n{}", + output + ))) + .expect("Expected channel to never disconnect"); + + return; + } + Err(err) => { + event_tx + .send(WatcherEvent::Error(err)) + .expect("Expected channel to never disconnect"); + return; + } + }; + + event_tx + .send(WatcherEvent::Shape(shape)) + .expect("Expected channel to never disconnect"); + }); + + Ok(Self { + _watcher: Box::new(watcher), + event_rx, + }) + } + + /// Access a channel for receiving status updates + pub fn events(&self) -> Receiver { + self.event_rx.clone() + } +} + +/// An event emitted by the [`Watcher`] +pub enum WatcherEvent { + /// A status update about the model + StatusUpdate(String), + + /// A shape has been loaded from the model + Shape(LoadedShape), + + /// An error + Error(Error), +} diff --git a/crates/fj-interop/src/debug.rs b/crates/fj-interop/src/debug.rs index c8086df82..035d1a23a 100644 --- a/crates/fj-interop/src/debug.rs +++ b/crates/fj-interop/src/debug.rs @@ -7,7 +7,7 @@ use fj_math::{Point, Segment}; /// Debug info from the CAD kernel that can be visualized -#[derive(Clone, Default)] +#[derive(Clone, Debug, Default)] pub struct DebugInfo { /// Rays being used during face triangulation pub triangle_edge_checks: Vec, @@ -30,7 +30,7 @@ impl DebugInfo { } /// Record of a check to determine if a triangle edge is within a face -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct TriangleEdgeCheck { /// The origin of the ray used to perform the check pub origin: Point<3>, diff --git a/crates/fj-interop/src/processed_shape.rs b/crates/fj-interop/src/processed_shape.rs index 5042edb62..6a112bd7d 100644 --- a/crates/fj-interop/src/processed_shape.rs +++ b/crates/fj-interop/src/processed_shape.rs @@ -5,7 +5,7 @@ use fj_math::{Aabb, Point}; use crate::{debug::DebugInfo, mesh::Mesh}; /// A processed shape -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ProcessedShape { /// The axis-aligned bounding box of the shape pub aabb: Aabb<3>, diff --git a/crates/fj-viewer/src/camera.rs b/crates/fj-viewer/src/camera.rs index 044909973..5cb41ed40 100644 --- a/crates/fj-viewer/src/camera.rs +++ b/crates/fj-viewer/src/camera.rs @@ -25,9 +25,6 @@ pub struct Camera { /// The locational part of the transform pub translation: Transform, - - /// Tracks whether the camera has been initialized - is_initialized: bool, } impl Camera { @@ -44,8 +41,6 @@ impl Camera { rotation: Transform::identity(), translation: Transform::identity(), - - is_initialized: false, } } @@ -133,59 +128,56 @@ impl Camera { transform } + /// Initialize the planes + /// + /// Call this, if a shape is available for the first time. + pub fn init_planes(&mut self, aabb: &Aabb<3>) { + let initial_distance = { + // Let's make sure we choose a distance, so that the model fills + // most of the screen. + // + // To do that, first compute the model's highest point, as well + // as the furthest point from the origin, in x and y. + let highest_point = aabb.max.z; + let furthest_point = + [aabb.min.x.abs(), aabb.max.x, aabb.min.y.abs(), aabb.max.y] + .into_iter() + .reduce(Scalar::max) + // `reduce` can only return `None`, if there are no items in + // the iterator. And since we're creating an array full of + // items above, we know this can't panic. + .expect("Array should have contained items"); + + // The actual furthest point is not far enough. We don't want + // the model to fill the whole screen. + let furthest_point = furthest_point * 2.; + + // Having computed those points, figuring out how far the camera + // needs to be from the model is just a bit of trigonometry. + let distance_from_model = + furthest_point / (Self::INITIAL_FIELD_OF_VIEW_IN_X / 2.).atan(); + + // And finally, the distance from the origin is trivial now. + highest_point + distance_from_model + }; + + let initial_offset = { + let mut offset = aabb.center(); + offset.z = Scalar::ZERO; + -offset + }; + + let translation = Transform::translation([ + initial_offset.x, + initial_offset.y, + -initial_distance, + ]); + + self.translation = translation; + } + /// Update the max and minimum rendering distance for this camera. pub fn update_planes(&mut self, aabb: &Aabb<3>) { - if !self.is_initialized { - let initial_distance = { - // Let's make sure we choose a distance, so that the model fills - // most of the screen. - // - // To do that, first compute the model's highest point, as well - // as the furthest point from the origin, in x and y. - let highest_point = aabb.max.z; - let furthest_point = [ - aabb.min.x.abs(), - aabb.max.x, - aabb.min.y.abs(), - aabb.max.y, - ] - .into_iter() - .reduce(Scalar::max) - // `reduce` can only return `None`, if there are no items in - // the iterator. And since we're creating an array full of - // items above, we know this can't panic. - .expect("Array should have contained items"); - - // The actual furthest point is not far enough. We don't want - // the model to fill the whole screen. - let furthest_point = furthest_point * 2.; - - // Having computed those points, figuring out how far the camera - // needs to be from the model is just a bit of trigonometry. - let distance_from_model = furthest_point - / (Self::INITIAL_FIELD_OF_VIEW_IN_X / 2.).atan(); - - // And finally, the distance from the origin is trivial now. - highest_point + distance_from_model - }; - - let initial_offset = { - let mut offset = aabb.center(); - offset.z = Scalar::ZERO; - -offset - }; - - let translation = Transform::translation([ - initial_offset.x, - initial_offset.y, - -initial_distance, - ]); - - self.translation = translation * self.translation; - - self.is_initialized = true; - } - let view_transform = self.camera_to_model(); let view_direction = Vector::from([0., 0., -1.]); diff --git a/crates/fj-viewer/src/viewer.rs b/crates/fj-viewer/src/viewer.rs index d3456cb68..c2ec17af6 100644 --- a/crates/fj-viewer/src/viewer.rs +++ b/crates/fj-viewer/src/viewer.rs @@ -77,7 +77,11 @@ impl Viewer { pub fn handle_shape_update(&mut self, shape: ProcessedShape) { self.renderer .update_geometry((&shape.mesh).into(), (&shape.debug_info).into()); - self.shape = Some(shape); + + let aabb = shape.aabb; + if self.shape.replace(shape).is_none() { + self.camera.init_planes(&aabb) + } } /// Handle an input event diff --git a/crates/fj-window/src/run.rs b/crates/fj-window/src/run.rs index 000fc9b67..ad6bd589b 100644 --- a/crates/fj-window/src/run.rs +++ b/crates/fj-window/src/run.rs @@ -5,7 +5,7 @@ use std::error; -use fj_host::Watcher; +use fj_host::{Watcher, WatcherEvent}; use fj_interop::status_report::StatusReport; use fj_operations::shape_processor::ShapeProcessor; use fj_viewer::{ @@ -36,6 +36,8 @@ pub fn run( let window = Window::new(&event_loop)?; let mut viewer = block_on(Viewer::new(&window))?; + let events = watcher.events(); + let mut held_mouse_button = None; let mut egui_winit_state = egui_winit::State::new(&event_loop); @@ -49,10 +51,32 @@ pub fn run( event_loop.run(move |event, _, control_flow| { trace!("Handling event: {:?}", event); - match watcher.receive_shape(&mut status) { - Ok(shape) => { - if let Some(shape) = shape { - match shape_processor.process(&shape) { + loop { + let event = events + .try_recv() + .map_err(|err| { + if err.is_disconnected() { + panic!("Expected channel to never disconnect"); + } + }) + .ok(); + + let event = match event { + Some(status_update) => status_update, + None => break, + }; + + match event { + WatcherEvent::StatusUpdate(status_update) => { + status.update_status(&status_update) + } + WatcherEvent::Shape(shape) => { + status.update_status(&format!( + "Model compiled successfully in {}!", + shape.compile_time + )); + + match shape_processor.process(&shape.shape) { Ok(shape) => { viewer.handle_shape_update(shape); } @@ -73,24 +97,24 @@ pub fn run( } } } - } - Err(err) => { - // Can be cleaned up, once `Report` is stable: - // https://doc.rust-lang.org/std/error/struct.Report.html + WatcherEvent::Error(err) => { + // Can be cleaned up, once `Report` is stable: + // https://doc.rust-lang.org/std/error/struct.Report.html - println!("Error receiving updated shape: {}", err); + println!("Error receiving updated shape: {}", err); - let mut current_err = &err as &dyn error::Error; - while let Some(err) = current_err.source() { - println!(); - println!("Caused by:"); - println!(" {}", err); + let mut current_err = &err as &dyn error::Error; + while let Some(err) = current_err.source() { + println!(); + println!("Caused by:"); + println!(" {}", err); - current_err = err; - } + current_err = err; + } - *control_flow = ControlFlow::Exit; - return; + *control_flow = ControlFlow::Exit; + return; + } } }