Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Back | 3dModel | opencascade-rs #6

Closed
12 tasks done
novartole opened this issue Aug 23, 2024 · 15 comments
Closed
12 tasks done

Back | 3dModel | opencascade-rs #6

novartole opened this issue Aug 23, 2024 · 15 comments
Assignees

Comments

@novartole
Copy link
Collaborator

novartole commented Aug 23, 2024

  • cxx crate
    • When ~ called (if applied): automatically, manually
    • What exactly cxx_name = do
  • Life cycle of C++ objects
  • Code quality
  • How to optimize operations (Boolean, Splitter, etc.)
    • SetRunParallel
    • SetFuzzyValue
    • SetUseOBB
    • SetGlueMode *
  • Way to integrate measurement module (length, area, etc.)
  • Way to extend the crate
@novartole
Copy link
Collaborator Author

What exactly cxx_name = do?

rust_name and cxx_name are attributes for renaming functions, opaque types, shared structs and enums, and enum variants. For instance, consider a function defined in extern "c++ {}, which should be called from Rust code. It's definition might be annotated with either #[cxx_name = "func_cpp"] or #[rust_name = "..."] attribute. The first one allows to redirect call to another function named func_cpp, which must be defined at C++ side. The second one allows to use another name when calling at Rust side. More details here.

@novartole
Copy link
Collaborator Author

When ~ called (if applied): automatically, manually?

Calling a function or method of C++ world (including object creation) can only be done via smart pointers of cxx (SharedPtr, UniquePtr, etc.). The smart pointers take care about freeing memory properly. For instance, if a variable goes out of scope in Rust, its appropriate destructor is called at C++ side.

@novartole
Copy link
Collaborator Author

Life cycle of C++ objects.

Custom structures (smart pointers) of cxx are greatly described in documentation.

  • Class method creates and returns an instance of another class / casting to another type:
    The way how cxx works doesn't allow to pop-out instances from C++ to Rust directly. It's fine for most use cases, but sometimes an additional layer of indirection should be used.

  • Constructor call:
    A static function is required to create a class instance. This instance have to be wrapped into a smart pointer (usually shared_ptr or unique_ptr). To make it less verbose, the power of C++ templates is used.

  • Method call:
    cxx doesn't allow to bind Pin<&mut Self> to a const method. Same for shared ref in Rust - &Self cannot be bind to a non-const method at C++ side.

@novartole
Copy link
Collaborator Author

Code quality.

Code source is well organized especially in build related parts. It also seems as a good choice for small projects (as the author originally created the crate for). Intense development may lead to loads of logic duplicates of OCCT and its Rust representation.

opencascade-rs structure overview:

  • core of the crate

    • occt-sys - OCCT (C++) wrapper; static build of the C++ project for use as Rust dependency; no need if there is system-wide OOCT installed ('builtin' feature should be on)
    • opencascade-sys - implementation of bridge between OCCT (built or installed) and an interface written in Rust
    • opencascade - high level Rust wrapper; any logic can be written using only opencascade-sys, but this crate provides more natural way of doing that from (think of as decorator);
  • I would call them utils:

    • kicad-parser - a parser of specific format; opencascade has a ref to it in dependencies
    • model-api - wasm binding; related to viewer
    • wasm-example - example of model compiled to wasm to be used in viewer
    • viewer - demo app to demonstrate how to render objects of various types: wasm, kicad, shaders, etc.

@novartole
Copy link
Collaborator Author

How to optimize operations (Boolean, Splitter, etc.)?

Implementation of boolean operations:

  • There are two libraries providing Boolean Operations:

    • Old (deprecated) Boolean Operations (BOA) provided by BRepAlgo package designed and developed in Open CASCADE 6x in 2000; its architecture and content are out of date.
    • New Boolean Operations (NBOA) provided by BRepAlgoAPI package designed and developed in 2001 and completely revised in 2013.
  • Boolean operation algorithm in OCCT provides a self-diagnostic feature which can help to do that step.
    This feature can be activated by defining environment variable CSF_DEBUG_BOP, which should specify an existing writeable directory.
    The diagnostic code checks validity of the input arguments and the result of each Boolean operation. When an invalid situation is detected, the report consisting of argument shapes and a DRAW script to reproduce the problematic operation is saved to the directory pointed by CSF_DEBUG_BOP.

Useful links:

@novartole
Copy link
Collaborator Author

SetUseOBB.

From docs(see Bounding box section): Bounding boxes are used in many OCCT algorithms. The most common use is as a filter avoiding check of excess interferences between pairs of shapes (check of interferences between bounding boxes is much simpler then between shapes and if they do not interfere then there is no point in searching interferences between the corresponding shapes). Generally, bounding boxes can be divided into two main types:

  • axis-aligned bounding box (AABB) is the box whose edges are parallel to the axes of the World Coordinate System (WCS);
  • oriented BndBox (OBB) is defined in its own coordinate system that can be rotated with respect to the WCS. Indeed, an AABB is a specific case of OBB.
    image

Tests (average time):

  • intersection (Common boolean) works slightly better with OBB set true on functional models: 1.5 sec vs. 1.7 sec (~12%),
  • but behaves even slower on models imported from STEP: 630 mls vs. 620 mls (~2%).

@novartole
Copy link
Collaborator Author

SetFuzzyValue.

Generally, it shouldn't be used as an optimization option. The real goal is to fix modeling mistakes.

From docs (see Fuzzy Boolean Operation section): Fuzzy Boolean operation is the option of Basic Operations such as General Fuse, Splitting, Boolean, Section, Maker Volume and Cells building operations, in which additional user-specified tolerance is used. This option allows operators to handle robustly cases of touching and near-coincident, misaligned entities of the arguments.

The Fuzzy option is useful on the shapes with gaps or embeddings between the entities of these shapes, which are not covered by the tolerance values of these entities. Such shapes can be the result of modeling mistakes, or translating process, or import from other systems with loss of precision, or errors in some algorithms.

@novartole
Copy link
Collaborator Author

SetRunParallel.

This option is the real game changer. It affects both on functional and STEP modeling algorithms. Moreover, in INTEL machines additional set true of USE_TBB before compiling OCCT gives even more speed gain:

  • on models imported from STEP: 610 mls vs 470 mls (~23%),
  • on functional models: 1.67 sec vs. 0.45 sec (~70%!).

Useful links:

@novartole
Copy link
Collaborator Author

SetGlueMode.

Setting this mode to non-default may increase speed of Boolean operations in case of shifted or overlapping objects, but it shouldn't be used with Intersection. More details here.

@bschwind
Copy link

Hi!

I'm the author of the opencascade-rs bindings, please let me know if you have any questions :)

It's generally usable, but I would say it's still in the experimentation phase of figuring out the best way to organize bindings, especially if I ever want to automate the generation of those bindings. Right now they're all thrown into one cxx.rs bridge file, and one wrapper.hxx file. I could probably split these up into sensible modules, at the very least.

The major thing I've been focusing on is building out a WASM API to allow for more rapid iteration when modeling with Rust code directly. The vanilla Rust API should more or less get developed in lockstep with that API though so no worries there.

All of this has so far been developed with code-based CAD as the main goal, similar to OpenSCAD. But of course that doesn't exclude other crates using it for their own geometrical processing purposes!

Nice research, by the way! It was interesting to read all these evaluation notes on my own project from someone I don't know.

@novartole
Copy link
Collaborator Author

Hi @bschwind ,

Nice to see you here! Thanks for your reply and great tool! It builds like a charm and its structure looks pretty intuitive, so there have been no questions so far, even after almost a month of playing with it.

Modularity is a topic I also think about. Such reusing existing binding types could help in a way to separate, say, mandatory logic and some user API. I have a plan to try my hand at deeper research in this area. I would share results if you're interested.

The purpose for which we use the crate, is to import a STEP model, apply 3D transformations if necessary and then measure some related metrics such as length, area, etc. Unfortunately, several purely rust-written 3D engines that I've tried are either at too early stage (and don't even have stable support of boolean operations), or give artifacts when importing and further work doesn't make any sense. Fortunately, your solution fits many requirements nicely even it's still in the experimentation phase!

@novartole
Copy link
Collaborator Author

Way to integrate measurement module (length, area, etc.).

GProp_GProps class us used for this purpose. Its result depends on dimension and type of object it calls on.

Here is an example taken from opencascade-rs, where Mass() result turns into area.

@novartole
Copy link
Collaborator Author

Way to extend the crate.

Results of last two section can be wrapped up into the following example. Please pay attention on Required changes section below.

Crate: try-occt-rs

src/main.rs

mod cli;

use std::time::Instant;

use clap::Parser;
use cli::{Cli, Cmd, Examples, Orientation};
use glam::dvec3;
use opencascade::{
    primitives::{Compound, Edge, IntoShape, Shape, Solid},
    workplane::Workplane,
};

fn main() {
    let args = Cli::parse();

    // grab result into one model object
    let output_model = {
        // target is usually an input model,
        // result is result model of chosen cmd
        let (target, result) = match args.command {
            Cmd::Volume {
                orientation,
                width,
                heigh,
                depth,
                input,
            } => {
                // expect model to be Shell internally
                let model = Shape::read_step(&input).expect("Failed while reading STEP");
                // plane should intersect target model
                let plane = build_plane(orientation, width, heigh, depth);
                // if two models aren't intersected, it gives nothing as result
                let under_plane = Solid::volume([&model, &plane], true).into_shape();

                // cacl some metrics:
                // - total area of result of volume operation (VO)
                // - area of plane, which is an argument of VO
                // - area of result model of VO - area of argument plane
                let (total, top_plane, valume) = calc_volumed_area(&under_plane, &plane);
                println!(
                    "AREA (sq. m.):\n- total={:.4}\n- top plane={:.4}\n- under plane solid={:.4}",
                    total, top_plane, valume
                );

                (model, under_plane)
            }
            Cmd::Intersect {
                orientation,
                heigh,
                width,
                depth,
                input,
            } => {
                let model = Shape::read_step(input).expect("Failed while reading STEP");
                let plane = build_plane(orientation, width, heigh, depth);

                #[cfg(feature = "verbose")]
                let instant = Instant::now();

                // use extended implementation of intersection
                // to take into account params of operation builder
                let result = model
                    .intersect_with_params(
                        &plane,
                        args.run_parallel,
                        args.fuzzy_value,
                        args.use_obb,
                        args.glue,
                    )
                    .into_shape();

                #[cfg(feature = "verbose")]
                dbg!(instant.elapsed());

                (model, result)
            }
            #[cfg(feature = "verbose")]
            Cmd::Example { name } => match name {
                Examples::CableBracketSelfIntersection => {
                    let model_a = examples::cable_bracket::shape();
                    let model_b = examples::cable_bracket::shape();

                    let instant = Instant::now();
                    let result = model_b.intersect_with_params(
                        &model_a,
                        args.run_parallel,
                        args.fuzzy_value,
                        args.use_obb,
                        args.glue,
                    );
                    dbg!(
                        instant.elapsed(),
                        args.run_parallel,
                        args.fuzzy_value,
                        args.use_obb,
                        args.glue
                    );

                    (model_b, result.into_shape())
                }
            },
        };

        // bake target and result
        if args.join_all {
            Compound::from_shapes([target, result]).into_shape()
        // remain only result edges
        } else if args.only_edges {
            Compound::from_shapes(result.edges().map(Edge::into_shape)).into_shape()
        // don't format result
        } else {
            result
        }
    };

    output_model
        .write_step(args.output)
        .expect("Failed while writting STEP");
}

/// Build a plane to use with boolean and volume operations.
fn build_plane(o: Orientation, w: f64, h: f64, d: f64) -> Shape {
    let (plane, dir) = match o {
        Orientation::Xy => (Workplane::xy(), dvec3(0.0, 0.0, d)),
        Orientation::Xz => (Workplane::xz(), dvec3(0.0, d, 0.0)),
        Orientation::Yz => (Workplane::yz(), dvec3(0.0, 0.0, d)),
    };

    plane.translated(dir).rect(w, h).to_face().into_shape()
}

/// Calculate areas of volume operation result.
/// It might take time due to call of boolean operation internally.
fn calc_volumed_area(shape: &Shape, top_plane: &Shape) -> (f64, f64, f64) {
    // normalize to square meters
    let normalizer = 1e6;

    let top_plane_area = shape
        .intersect(top_plane)
        .faces()
        .next()
        .unwrap()
        .surface_area();
    let total_area: f64 = shape.faces().map(|face| face.surface_area()).sum();

    (
        // total area of shape
        total_area / normalizer,
        // area of shape and plane intersection
        top_plane_area / normalizer,
        // total are - plane area
        (total_area - top_plane_area) / normalizer,
    )
}

src/cli.rs

use clap::{Parser, Subcommand, ValueEnum};

use std::path::PathBuf;

#[derive(ValueEnum, Clone)]
pub enum Orientation {
    Xy,
    Xz,
    Yz,
}

#[derive(ValueEnum, Clone)]
pub enum Examples {
    /// Common of cable_bracket and keycap models
    #[clap(name = "CabBrSelfInt")]
    CableBracketSelfIntersection,
}
/// Examples:
/// - build solid by intersecting plane and hull shell model:
///   ```bash
///   cargo run -rF verbose -- volume xy 250000.0 250000.0 5000.0 ../3ds/hull_shell_centered.step
///   ```
/// - get result face joined with original model after intersection of plane and hull shell:
///   ```bash
///   cargo run -rF verbose -- \
///     --run-parallel --join-all \
///     intersect yz 200000.0 200000.0 112000.0 ../3ds/hull_shell_centered.step
///   ```
/// - run prepared example:
///   ```bash
///   cargo run -rF verbose -- --glue=2 --fuzzy-value=1e-2 example CabBrSelfInt
///   ```
#[derive(Parser, Clone)]
pub struct Cli {
    /// Output file
    #[clap(short, long, default_value = "output.step")]
    pub output: PathBuf,

    /// Join all parts
    #[clap(long, default_value_t = false, group = "view")]
    pub join_all: bool,

    /// Save only edges
    #[clap(long, default_value_t = false, group = "view")]
    pub only_edges: bool,

    #[command(subcommand)]
    pub command: Cmd,

    /// Parallel optimization
    #[clap(long)]
    pub run_parallel: bool,

    /// OBB usage
    #[clap(long)]
    pub use_obb: bool,

    /// Glue option
    #[clap(long, default_value_t = 0)]
    pub glue: u8,

    /// Fuzzy value (aka tolerance) for boolean operations
    #[clap(long, default_value_t = 1e-7)]
    pub fuzzy_value: f64,
}

#[derive(Subcommand, Clone)]
pub enum Cmd {
    /// Split shell using Volume maker
    Volume {
        orientation: Orientation,
        width: f64,
        heigh: f64,
        depth: f64,

        /// Input file path
        #[clap(value_parser = input_parser)]
        input: PathBuf,
    },

    /// Build plane intersection
    Intersect {
        orientation: Orientation,
        width: f64,
        heigh: f64,
        depth: f64,

        /// Input file path
        #[clap(value_parser = input_parser)]
        input: PathBuf,
    },

    /// Prepared examples
    #[cfg(feature = "verbose")]
    Example { name: Examples },
}

fn input_parser(arg: &str) -> anyhow::Result<PathBuf> {
    use anyhow::bail;
    use std::{os::unix::ffi::OsStrExt, str::FromStr};

    let input = PathBuf::from_str(arg)?;

    if !matches!(
        input.extension().map(OsStrExt::as_bytes),
        Some(b"stp" | b"step")
    ) {
        bail!("Supported output format is .step (.stp).");
    }

    Ok(input)
}

Cargo.toml

[package]
name = "try-occt-rs"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1.0.86"
cxx = "1.0.128"

[dependencies.glam]
version = "0.24.2"
features = ["bytemuck"]

[dependencies.clap]
version = "4"
features = ["derive"]

[dependencies.opencascade]
version = "0.2"
path = "../opencascade-rs/crates/opencascade"

[dependencies.examples]
version = "0.2.0"
path = "../opencascade-rs/examples"

[features]
default = ["verbose"]
verbose = []

Required changes in opencascade-rs crate

I would suggest to clone repository to the root folder of try-occt-rs crate (see above) and then apply the changes. Tested with this commit.

crates/occt-sys/build.rs (before/after)

        .define("USE_TBB", "FALSE")
        .define("USE_TBB", "TRUE")

crates/opencascade-sys/include/wrapper.hxx

inline void shape_list_append_shape(TopTools_ListOfShape &list, const TopoDS_Shape &shape) { list.Append(shape); }

inline void SetRunParallel_BRepAlgoAPI_Common(BRepAlgoAPI_Common &theBOP, bool theFlag) {
  theBOP.SetRunParallel(theFlag);
}
inline void SetUseOBB_BRepAlgoAPI_Common(BRepAlgoAPI_Common &theBOP, bool theFlag) { theBOP.SetUseOBB(theFlag); }
inline void SetFuzzyValue_BRepAlgoAPI_Common(BRepAlgoAPI_Common &theBOP, double theFuzz) {
  theBOP.SetFuzzyValue(theFuzz);
}

inline bool HasErrors_BRepAlgoAPI_Common(const BRepAlgoAPI_Common &theBOP) { return theBOP.HasErrors(); }

inline const TopoDS_Shape &BOPAlgo_MakerVolume_Shape(const BOPAlgo_MakerVolume &aMV) { return aMV.Shape(); }

crates/opencascade-sys/src/lib.rs

pub mod ffi {
    #[derive(Debug)]
    #[repr(u32)]
    pub enum BOPAlgo_Operation {
        BOPAlgo_COMMON,
        BOPAlgo_FUSE,
        BOPAlgo_CUT,
        BOPAlgo_CUT21,
        BOPAlgo_SECTION,
        BOPAlgo_UNKNOWN,
    }
/* ... */
        pub fn shape_list_append_shape(list: Pin<&mut TopTools_ListOfShape>, face: &TopoDS_Shape);
/* ... */
        type BOPAlgo_MakerVolume;

        #[cxx_name = "construct_unique"]
        pub fn BOPAlgo_MakerVolume_ctor() -> UniquePtr<BOPAlgo_MakerVolume>;
        pub fn SetArguments(self: Pin<&mut BOPAlgo_MakerVolume>, the_ls: &TopTools_ListOfShape);
        pub fn Perform(self: Pin<&mut BOPAlgo_MakerVolume>, the_range: &Message_ProgressRange);
        pub fn BOPAlgo_MakerVolume_Shape(theMV: &BOPAlgo_MakerVolume) -> &TopoDS_Shape;
/* ... */
        type BOPAlgo_Operation;

        #[cxx_name = "construct_unique"]
        pub fn BRepAlgoAPI_Common_ctor() -> UniquePtr<BRepAlgoAPI_Common>;
/* ... */
        /// Obsolete.
        #[rust_name = "BRepAlgoAPI_Common_ctor2"]
        pub fn Build(self: Pin<&mut BRepAlgoAPI_Common>, the_range: &Message_ProgressRange);
        pub fn SetTools(self: Pin<&mut BRepAlgoAPI_Common>, the_ls: &TopTools_ListOfShape);
        pub fn SetArguments(self: Pin<&mut BRepAlgoAPI_Common>, the_ls: &TopTools_ListOfShape);
        pub fn HasErrors_BRepAlgoAPI_Common(the_bop: &BRepAlgoAPI_Common) -> bool;
        pub fn SetFuzzyValue_BRepAlgoAPI_Common(
            the_bop: Pin<&mut BRepAlgoAPI_Common>,
            the_fuzz: f64,
        );
        pub fn SetRunParallel_BRepAlgoAPI_Common(
            the_bop: Pin<&mut BRepAlgoAPI_Common>,
            the_flag: bool,
        );
        pub fn SetUseOBB_BRepAlgoAPI_Common(
            the_bop: Pin<&mut BRepAlgoAPI_Common>,
            the_use_obb: bool,
        );
        pub fn SetGlue(self: Pin<&mut BRepAlgoAPI_Common>, glue: BOPAlgo_GlueEnum);
/* ... */
}

crates/opencascade/src/primitives/shape.rs

use crate::angle::Angle;
/* ... before/after */
        // let mut fuse_operation = ffi::BRepAlgoAPI_Common_ctor(&self.inner, &other.inner);
        let mut fuse_operation = ffi::BRepAlgoAPI_Common_ctor2(&self.inner, &other.inner);
/* ... */
impl Shape {
  pub fn intersect_with_params(
          &self,
          other: &Shape,
          parallel: bool,
          fuzzy: f64,
          obb: bool,
          glue: u8,
    ) -> BooleanShape {
        let mut common_operation = ffi::BRepAlgoAPI_Common_ctor();

        // set tools
        let mut tools = ffi::new_list_of_shape();
        ffi::shape_list_append_shape(tools.pin_mut(), &self.inner);
        common_operation.pin_mut().SetTools(&tools);

        // set arguments
        let mut arguments = ffi::new_list_of_shape();
        ffi::shape_list_append_shape(arguments.pin_mut(), &other.inner);
        common_operation.pin_mut().SetArguments(&arguments);

        // set additional options
        ffi::SetFuzzyValue_BRepAlgoAPI_Common(common_operation.pin_mut(), fuzzy);
        ffi::SetRunParallel_BRepAlgoAPI_Common(common_operation.pin_mut(), parallel);
        ffi::SetUseOBB_BRepAlgoAPI_Common(common_operation.pin_mut(), obb);
        match glue {
            2 => common_operation.pin_mut().SetGlue(ffi::BOPAlgo_GlueEnum::BOPAlgo_GlueFull),
            1 => common_operation.pin_mut().SetGlue(ffi::BOPAlgo_GlueEnum::BOPAlgo_GlueShift),
            _ => common_operation.pin_mut().SetGlue(ffi::BOPAlgo_GlueEnum::BOPAlgo_GlueOff),
        }

        // perform operation
        common_operation.pin_mut().Build(&ffi::Message_ProgressRange_ctor());

        // if ffi::HasErrors_BRepAlgoAPI_Common(&common_operation) {
        //     panic!("something went wrong");
        // }

        // get result edges
        let edge_list = common_operation.pin_mut().SectionEdges();
        let vec = ffi::shape_list_to_vector(edge_list);
        let mut new_edges = vec![];
        for shape in vec.iter() {
            let edge = ffi::TopoDS_cast_to_edge(shape);
            new_edges.push(Edge::from_edge(edge));
        }

        // get result shape
        let shape = Self::from_shape(common_operation.pin_mut().Shape());

        BooleanShape { shape, new_edges }
    }

    pub fn rotate(mut self, rotation_axis: DVec3, angle: Angle) -> Self {
        // create general transformation object
        let mut transform = ffi::new_transform();

        // apply rotation to transformation
        let rotation_axis_vec =
            ffi::gp_Ax1_ctor(&make_point(DVec3::ZERO), &make_dir(rotation_axis));
        transform.pin_mut().SetRotation(&rotation_axis_vec, angle.radians());

        // get result location
        let location = ffi::TopLoc_Location_from_transform(&transform);

        // apply transformation to shape
        self.inner.pin_mut().translate(&location, false);
        self
    }
/* ... */
}
/* ... */

crates/opencascade/src/primitives/solid.rs

/* ... before/after */
        // let mut fuse_operation = ffi::BRepAlgoAPI_Common_ctor(inner_shape, other_inner_shape);
        let mut fuse_operation = ffi::BRepAlgoAPI_Common_ctor2(inner_shape, other_inner_shape);
/* ... */
impl Solid {
    pub fn volume<'a, T>(
        shells_as_shapes: impl IntoIterator<Item = &'a T>,
        _avoid_internal_shapes: bool,
    ) -> Self
    where
        T: AsRef<Shape> + 'a,
    {
        // create Volume maker
        let mut maker = ffi::BOPAlgo_MakerVolume_ctor();

        // set shells to make solid from
        let mut arguments = ffi::new_list_of_shape();
        for shape in shells_as_shapes {
            ffi::shape_list_append_shape(arguments.pin_mut(), &shape.as_ref().inner);
        }
        maker.pin_mut().SetArguments(&arguments);

        // perform the opearation
        maker.pin_mut().Perform(&ffi::Message_ProgressRange_ctor());
        // cast result to solid according to doc
        let genaral_shape = ffi::BOPAlgo_MakerVolume_Shape(&maker);
        let solid = ffi::TopoDS_cast_to_solid(genaral_shape);
        Solid::from_solid(solid)
    }
/* ... */
}

@novartole
Copy link
Collaborator Author

Useful links for further research:

@a-givertzman a-givertzman mentioned this issue Sep 25, 2024
5 tasks
@novartole
Copy link
Collaborator Author

Current research became the basis for the repo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants