fj_host/
model.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4    process::Command,
5    str,
6};
7
8use fj::{abi, version::Version};
9use fj_operations::shape_processor;
10use tracing::debug;
11
12use crate::{platform::HostPlatform, Parameters};
13
14/// Represents a Fornjot model
15pub struct Model {
16    src_path: PathBuf,
17    lib_path: PathBuf,
18    manifest_path: PathBuf,
19    parameters: Parameters,
20}
21
22impl Model {
23    /// Initialize the model using the path to its crate
24    ///
25    /// The path expected here is the root directory of the model's Cargo
26    /// package, that is the folder containing `Cargo.toml`.
27    pub fn new(
28        path: impl AsRef<Path>,
29        parameters: Parameters,
30    ) -> Result<Self, Error> {
31        let path = path.as_ref();
32
33        let crate_dir = path.canonicalize()?;
34
35        let metadata = cargo_metadata::MetadataCommand::new()
36            .current_dir(&crate_dir)
37            .exec()?;
38
39        let pkg = package_associated_with_directory(&metadata, &crate_dir)?;
40        let src_path = crate_dir.join("src");
41
42        let lib_path = {
43            let name = pkg.name.replace('-', "_");
44            let file = HostPlatform::lib_file_name(&name);
45            let target_dir =
46                metadata.target_directory.clone().into_std_path_buf();
47            target_dir.join("debug").join(file)
48        };
49
50        Ok(Self {
51            src_path,
52            lib_path,
53            manifest_path: pkg.manifest_path.as_std_path().to_path_buf(),
54            parameters,
55        })
56    }
57
58    /// Access the path that needs to be watched for changes
59    pub fn watch_path(&self) -> PathBuf {
60        self.src_path.clone()
61    }
62
63    /// Evaluate the model
64    pub fn evaluate(&self) -> Result<Evaluation, Error> {
65        let manifest_path = self.manifest_path.display().to_string();
66
67        let cargo_output = Command::new("cargo")
68            .arg("rustc")
69            .args(["--manifest-path", &manifest_path])
70            .args(["--crate-type", "cdylib"])
71            .output()?;
72
73        if !cargo_output.status.success() {
74            let output =
75                String::from_utf8(cargo_output.stderr).unwrap_or_else(|_| {
76                    String::from("Failed to fetch command output")
77                });
78
79            return Err(Error::Compile { output });
80        }
81
82        let seconds_taken = str::from_utf8(&cargo_output.stderr)
83            .unwrap()
84            .rsplit_once(' ')
85            .unwrap()
86            .1
87            .trim();
88
89        let mut warnings = None;
90
91        // So, strictly speaking this is all unsound:
92        // - `Library::new` requires us to abide by the arbitrary requirements
93        //   of any library initialization or termination routines.
94        // - `Library::get` requires us to specify the correct type for the
95        //   model function.
96        // - The model function itself is `unsafe`, because it is a function
97        //   from across an FFI interface.
98        //
99        // Typical models won't have initialization or termination routines (I
100        // think), should abide by the `ModelFn` signature, and might not do
101        // anything unsafe. But we have no way to know that the library the user
102        // told us to load actually does (I think).
103        //
104        // I don't know of a way to fix this. We should take this as motivation
105        // to switch to a better technique:
106        // https://github.com/hannobraun/Fornjot/issues/71
107        let shape = unsafe {
108            let lib = libloading::Library::new(&self.lib_path)
109                .map_err(Error::LoadingLibrary)?;
110
111            let version_pkg_host = fj::version::VERSION_PKG.to_string();
112
113            let version_pkg_model: libloading::Symbol<*const Version> =
114                lib.get(b"VERSION_PKG").map_err(Error::LoadingVersion)?;
115            let version_pkg_model = (**version_pkg_model).to_string();
116
117            debug!(
118                "Comparing package versions (host: {}, model: {})",
119                version_pkg_host, version_pkg_model
120            );
121            if version_pkg_host != version_pkg_model {
122                let host = String::from_utf8_lossy(version_pkg_host.as_bytes())
123                    .into_owned();
124                let model = version_pkg_model;
125
126                return Err(Error::VersionMismatch { host, model });
127            }
128
129            let version_full_host = fj::version::VERSION_FULL.to_string();
130
131            let version_full_model: libloading::Symbol<*const Version> =
132                lib.get(b"VERSION_FULL").map_err(Error::LoadingVersion)?;
133            let version_full_model = (**version_full_model).to_string();
134
135            debug!(
136                "Comparing full versions (host: {}, model: {})",
137                version_full_host, version_full_model
138            );
139            if version_full_host != version_full_model {
140                let host =
141                    String::from_utf8_lossy(version_full_host.as_bytes())
142                        .into_owned();
143                let model = version_full_model;
144
145                warnings =
146                    Some(format!("{}", Error::VersionMismatch { host, model }));
147            }
148
149            let init: libloading::Symbol<abi::InitFunction> = lib
150                .get(abi::INIT_FUNCTION_NAME.as_bytes())
151                .map_err(Error::LoadingInit)?;
152
153            let mut host = Host::new(&self.parameters);
154
155            match init(&mut abi::Host::from(&mut host)) {
156                abi::ffi_safe::Result::Ok(_metadata) => {}
157                abi::ffi_safe::Result::Err(e) => {
158                    return Err(Error::InitializeModel(e.into()));
159                }
160            }
161
162            let model = host.take_model().ok_or(Error::NoModelRegistered)?;
163
164            model.shape(&host).map_err(Error::Shape)?
165        };
166
167        Ok(Evaluation {
168            shape,
169            compile_time: seconds_taken.into(),
170            warning: warnings,
171        })
172    }
173}
174
175/// The result of evaluating a model
176///
177/// See [`Model::evaluate`].
178#[derive(Debug)]
179pub struct Evaluation {
180    /// The shape
181    pub shape: fj::Shape,
182
183    /// The time it took to compile the shape, from the Cargo output
184    pub compile_time: String,
185
186    /// Warnings
187    pub warning: Option<String>,
188}
189
190pub struct Host<'a> {
191    args: &'a Parameters,
192    model: Option<Box<dyn fj::models::Model>>,
193}
194
195impl<'a> Host<'a> {
196    pub fn new(parameters: &'a Parameters) -> Self {
197        Self {
198            args: parameters,
199            model: None,
200        }
201    }
202
203    pub fn take_model(&mut self) -> Option<Box<dyn fj::models::Model>> {
204        self.model.take()
205    }
206}
207
208impl<'a> fj::models::Host for Host<'a> {
209    fn register_boxed_model(&mut self, model: Box<dyn fj::models::Model>) {
210        self.model = Some(model);
211    }
212}
213
214impl<'a> fj::models::Context for Host<'a> {
215    fn get_argument(&self, name: &str) -> Option<&str> {
216        self.args.get(name).map(String::as_str)
217    }
218}
219
220fn package_associated_with_directory<'m>(
221    metadata: &'m cargo_metadata::Metadata,
222    dir: &Path,
223) -> Result<&'m cargo_metadata::Package, Error> {
224    for pkg in metadata.workspace_packages() {
225        let crate_dir = pkg
226            .manifest_path
227            .parent()
228            .and_then(|p| p.canonicalize().ok());
229
230        if crate_dir.as_deref() == Some(dir) {
231            return Ok(pkg);
232        }
233    }
234
235    Err(ambiguous_path_error(metadata, dir))
236}
237
238fn ambiguous_path_error(
239    metadata: &cargo_metadata::Metadata,
240    dir: &Path,
241) -> Error {
242    let mut possible_paths = Vec::new();
243
244    for id in &metadata.workspace_members {
245        let cargo_toml = &metadata[id].manifest_path;
246        let crate_dir = cargo_toml
247            .parent()
248            .expect("A Cargo.toml always has a parent");
249        // Try to make the path relative to the workspace root so error messages
250        // aren't super long.
251        let simplified_path = crate_dir
252            .strip_prefix(&metadata.workspace_root)
253            .unwrap_or(crate_dir);
254
255        possible_paths.push(simplified_path.into());
256    }
257
258    Error::AmbiguousPath {
259        dir: dir.to_path_buf(),
260        possible_paths,
261    }
262}
263
264/// An error that can occur when loading or reloading a model
265#[derive(Debug, thiserror::Error)]
266pub enum Error {
267    /// Error loading model library
268    #[error(
269        "Failed to load model library\n\
270        This might be a bug in Fornjot, or, at the very least, this error \
271        message should be improved. Please report this!"
272    )]
273    LoadingLibrary(#[source] libloading::Error),
274
275    /// Error loading Fornjot version that the model uses
276    #[error(
277        "Failed to load the Fornjot version that the model uses\n\
278        - Is your model using the `fj` library? All models must!\n\
279        - Was your model created with a really old version of Fornjot?"
280    )]
281    LoadingVersion(#[source] libloading::Error),
282
283    /// Error loading the model's `init` function
284    #[error(
285        "Failed to load the model's `init` function\n\
286        - Did you define a model function using `#[fj::model]`?"
287    )]
288    LoadingInit(#[source] libloading::Error),
289
290    /// Host version and model version do not match
291    #[error("Host version ({host}) and model version ({model}) do not match")]
292    VersionMismatch {
293        /// The host version
294        host: String,
295
296        /// The model version
297        model: String,
298    },
299
300    /// Model failed to compile
301    #[error("Error compiling model\n{output}")]
302    Compile {
303        /// The compiler output
304        output: String,
305    },
306
307    /// I/O error while loading the model
308    #[error("I/O error while loading model")]
309    Io(#[from] io::Error),
310
311    /// Initializing a model failed.
312    #[error("Unable to initialize the model")]
313    InitializeModel(#[source] fj::models::Error),
314
315    /// The user forgot to register a model when calling
316    /// [`fj::register_model!()`].
317    #[error("No model was registered")]
318    NoModelRegistered,
319
320    /// An error was returned from [`fj::models::Model::shape()`].
321    #[error("Unable to determine the model's geometry")]
322    Shape(#[source] fj::models::Error),
323
324    /// An error was returned from
325    /// [`fj_operations::shape_processor::ShapeProcessor::process()`].
326    #[error("Shape processing error")]
327    ShapeProcessor(#[from] shape_processor::Error),
328
329    /// Error while watching the model code for changes
330    #[error("Error watching model for changes")]
331    Notify(#[from] notify::Error),
332
333    /// An error occurred while trying to use evaluate
334    /// [`cargo_metadata::MetadataCommand`].
335    #[error("Unable to determine the crate's metadata")]
336    CargoMetadata(#[from] cargo_metadata::Error),
337
338    /// The user pointed us to a directory, but it doesn't look like that was
339    /// a crate root (i.e. the folder containing `Cargo.toml`).
340    #[error(
341        "It doesn't look like \"{}\" is a crate directory. Did you mean one of {}?",
342        dir.display(),
343        possible_paths.iter().map(|p| p.display().to_string())
344        .collect::<Vec<_>>()
345        .join(", ")
346    )]
347    AmbiguousPath {
348        /// The model directory supplied by the user.
349        dir: PathBuf,
350        /// The directories for each crate in the workspace, relative to the
351        /// workspace root.
352        possible_paths: Vec<PathBuf>,
353    },
354}