hannobraun / fornjot

Early-stage b-rep CAD kernel, written in the Rust programming language.
https://www.fornjot.app/
Other
2.04k stars 116 forks source link

Introduction of assemblies? #220

Closed therealprof closed 2 years ago

therealprof commented 2 years ago

Sometimes you don't want to consider a model made from a single piece but rather multiple pieces which are then produced individually and then assembled together (think furniture, boxes or other containers, 3D printed gears, etc.). Those would show up in the same model, allowing to verify correct dimensioning and completeness but internally will be treated as individual objects, allowing to e.g. produce exploded views, individual parts exports and parts lists.

hannobraun commented 2 years ago

What you describe here are definitely desired features. I don't have a concrete plan for them though, and not having thought about it from an implementation perspective much, I don't know if that's something that could be added in the short-term, or if it requires the underlying infrastructure to mature.

Some notes:

Given that there's no concrete plan for implementing this, I'm tempted to close this issue and move it to the Feature Wishlist. And to be clear, such a plan isn't something that I would have to come up with. Anyone is free to work out the requirements for such a feature, and how it can be implemented (which could then be tracked in one or multiple issues). I just like to keep issues actionable, and leave idea-stage features to the wishlist.

What do you think? Do you have specific ideas for improvements that could be made in the short-term, or do you think moving this to the wishlist would be appropriate?

therealprof commented 2 years ago

Distributing assembly parts over multiple files sounds painful. Especially since in my particular use case (furniture) I usually have multiple identical parts with relationships to other parts (both in dimensioning and placement) which I'd like to have parametrizeable.

Other softwares do have various degrees of support (FreeCAD even multiple incompatible approaches) but many of them are really lackluster for what I need to do.

What I'd like to end up with is:

At the moment I'm using solvespace for that purpose (really fed up with FreeCADs slowness and dozens of incompatible modules/approaches) but it's really limited for what I want to do, requiring a lot of manual steps and quadruple checking to make sure everything comes together as planned in the end. For your reference, this is something I'm currently working on:

Screenshot 2022-02-20 at 15 06 23
hannobraun commented 2 years ago

Thank you for explaining your use case, @therealprof, that's really helpful!

This is definitely all stuff that I'd like to support. I'll mull over this a bit. I assume that there are parts of this that can be done near-term (i.e. don't require expansive infrastructure to be built). I'll probably open more specific issues for those. Some other aspects might need to go into the wishlist, to be picked up later.

Also, cc @hendrikmaus, who expressed interest in furniture building (in case you want to weigh in).

Distributing assembly parts over multiple files sounds painful.

Yes. To be clear, I only mention this as a workaround that's currently possible. I don't mean to imply that it is even close to a desirable solution.

hannobraun commented 2 years ago

I've had some time to think about this. Here's what I've come up with so far:

Multiple types of artifacts (artifact system)

Models need the ability to produce multiple types of artifacts, like sketches, components, assemblies, etc. Each of those could expose a different API in the CAD kernel, be displayed differently in the viewer, etc.

My favored approach for specifying this, is to add attributes to free functions. Something along those lines:

/// An assembly
///
/// The attribute would type-check the function (to make sure it returns
/// `fj::Assembly`, or whatever the types end up being), parse the arguments (so
/// they can be exposed in the UI or via the CLI for parametrization), and
/// register the function somehow (so the Fornjot application can load it).
/// 
/// Otherwise, this should not transform the function, so it can be called by
/// other functions in the same model, or even other models.
#[fj::assembly]
pub fn assembly(number_of_thingies: u64) -> fj::Assembly {
    // Create the assembly; call other functions defined below
}

/// A component
/// 
/// Pretty much the same as the assembly above, except it returns
/// `fj::Component` (or whatever the type ends up being), which can provide a
/// different API to the kernel and can be displayed differently in the app.
#[fj::component]
pub fn component(width: fj::Scalar, height: fj::Scalar) -> fj::Component {
    // Create the component
}

/// A sketch
/// 
/// Same, only this time it's a sketch.
#[fj::sketch]
pub fn sketch() -> fj::Sketch {
    // Create the sketch
}

I like this approach, because it can provide the structure that the kernel and viewer will likely require, while still allowing regular model code to call these functions. From the perspective of the kernel/viewer, those functions could just as well return some fj::Model mega-struct with Vec<Assembly>/Vec<Component>/... fields. But then we'd lose the ability to use those functions in a type-safe manner from model code.

This work is related to #13 and #72, and could potentially be done as part of either of those, which is why I'm not going to open a separate issue for this. While this work is not strictly blocked by anything, it probably makes sense to wait for #71 before working on this, as that might change the implementation quite a bit.

Plans for each part

I think this basically comes down to being able to export technical drawings, possibly with some additional metadata, like assembly instructions.

Technical drawings are on the feature wishlist. As for assembly instructions, I'm not sure about which approach to take. I want to make it really easy to embed Fornjot models into websites (#73), and maybe a system could be built around that. Maybe it also makes sense to add assembly instructions (and the like) as a feature to Fornjot itself, but I don't know. I expect to revisit this once other steps have been taken in that direction.

Cut lists

This would require teaching Fornjot about materials, and how to get parts out of them. This would also play into the artifact system I've described above.

I'm inclined to consider this out-of-scope for now, as it's somewhat orthogonal to the current development priorities (which are centered around getting a stable base working) and can probably be prototyped in "user space" (i.e. model code).

Exploded view

Once the artifact system above exists, this "just" becomes a matter of improving the renderer and making it use the available data. I don't think it makes much sense to think about this right now, until things like the artifact system and #13 are in place.


Those are my thoughts, so far. I'm going to link this comment in #13 and #72, which are more actionable in the near-term.

I'm pretty sure I'm going to close this issue soon in favor of adding an entry to the feature wishlist (to be re-evaluated once these other issues have been addressed), but I haven't decided yet. I'm leaving it open for now.

hannobraun commented 2 years ago

As I alluded to in my previous comment, I've decided to close this issue as not actionable.

I've added Assemblies (including exploded view), Assembly instructions, and Cut lists to the feature wishlist. We can open more specific issues based on them later.

As I've mentioned before, #13 and #72 are existing issues that are relevant.

Closing this issue doesn't mean that further discussion here is not welcome! It just means that this feature request is now tracked on the feature wishlist, to keep the number of open issues to a manageable level. Feel free to post here, if assemblies are something you'd like to see, or if you have anything to add to the discussion.

Thank you, @therealprof for opening this issue!

gabsi26 commented 2 years ago

I don't know how much time you already spent on planning a structure for the features of a model and thus its representation, but I have suggestion which I would like to turn into a proof of concept if you agree on the general direction. @hannobraun (and everyone of course)

So let's start with my idea:

Currently Shapes in the kernel-module contain Stores which I think of as registries of features which are related or make up the shape. This connection of features and shapes is basically what I'd suggest for the relation of a model, which might be an assembly or a part (this distinction is not needed but could be useful), with the features that define it. This is also the same as sweeps which know about their defining Shape2D and path. However in both those already used examples the building blocks belong to one specific "thing" (be it the Sweep or the Shape), instead I would suggest to have some model global containers where we would have one for 2D-features and another for 3D ones, probably one for axes, planes and the like would also be needed. If one would then like to use such an already stored component they can be refered to by their handle in those containers. This allows to reuse them without recreating them (hopefully avoiding cloning).

By annotating free functions with the corresponding attribute the component would be inserted in its respective container automatically and can then be refered to by other components. Of course one has to think of the order in which the these components are created/defined to be able to use them, since you can't use stuff that doesn't exist yet.

With this idea the assembly approach could initially be limited to only affect model creation, since the model function could still simply return the final shape, but later on (if it is a viable approach) could be used in the backend as well, working towards #13.

I'm not sure if this explanation of my idea is comprehensible, but either way I'm looking forward to a response to either clear up any confusion or just giving me the green light to play with a proof of concept implementation. If a visual representation of the idea would help to clarify it let me know.

hannobraun commented 2 years ago

I don't know how much time you already spent on planning a structure for the features of a model and thus its representation, but I have suggestion which I would like to turn into a proof of concept if you agree on the general direction. @hannobraun (and everyone of course)

The current structure is a reflection of the current requirements, nothing more. I am 100% sure that things are going to change drastically going forward. I do have some reservations regarding your idea, but those are based on specific problems I see, based on the current use cases. Any help with figuring out where things needs to go is certainly welcome!

I've read your comment, but only have a few minutes right now, which isn't enough to fully understand it and reply to it. I'll get back to you as soon as I have some time!

hannobraun commented 2 years ago

Sorry that it's taking me a bit of time to get to this, @gabsi26! The timing was a bit unfortunate for me, with the weekend + the public holiday on Monday.

I will most likely reply tomorrow.

hannobraun commented 2 years ago

Okay, here we go, @gabsi26. Thank you for your patience!

Currently Shapes in the kernel-module contain Stores which I think of as registries of features which are related or make up the shape. This connection of features and shapes is basically what I'd suggest for the relation of a model, which might be an assembly or a part (this distinction is not needed but could be useful), with the features that define it. This is also the same as sweeps which know about their defining Shape2D and path.

What you call "features" here is called "objects" in current Fornjot kernel parlance. In the context of CAD, I understand the word "feature" to refer to a collection of such objects, that make up a region of the part.

I'm not saying my own use of these words is the definite one, and I'm open to change nomenclature where it makes sense. I'm just mentioning it here, to avoid confusion.

However in both those already used examples the building blocks belong to one specific "thing" (be it the Sweep or the Shape), instead I would suggest to have some model global containers where we would have one for 2D-features and another for 3D ones, probably one for axes, planes and the like would also be needed. If one would then like to use such an already stored component they can be refered to by their handle in those containers. This allows to reuse them without recreating them (hopefully avoiding cloning).

I see a problem here: Objects within Shape are mutable. Meaning they can be changed, and the same Handles will from then on refer to the changed object. I don't love this. It would be nicer in many ways, if objects were immutable. If an object you reference with Handle couldn't just change from under you without you noticing. But it's the best way I have found to make the current use cases work.

If there were global object stores, then we'd have to find some way to keep the current use cases working, preferably without making things harder to do than they already are. I think the sweep algorithm might be a good testbed for this, since what it does is really simple from a conceptual level (1. copy the original sketch and invert its orientation, creating the bottom face; 2. copy the original sketch and translate it along the sweep path, creating the top face; 3. connect the vertices and edges of the bottom and top face, creating the side faces), but implementing it, especially step 3, has turned out way too gnarly.

Basically, if we can still implement the sweep algorithm using global object stores, without making it even more complicated, then that's a good sign that things can work out. And maybe there are even opportunities to simplify things using your proposal. A lot of the complexity in the sweep algorithm stems from Shapes being cloned, and the relationships between the Handles of different Shapes having to be managed.

I have to think more about this. Maybe we're onto something here.

By annotating free functions with the corresponding attribute the component would be inserted in its respective container automatically and can then be refered to by other components. Of course one has to think of the order in which the these components are created/defined to be able to use them, since you can't use stuff that doesn't exist yet.

With this idea the assembly approach could initially be limited to only affect model creation, since the model function could still simply return the final shape, but later on (if it is a viable approach) could be used in the backend as well, working towards #13.

I think you've lost me there. Are you saying that we have freestanding functions that are annotated as defining 2D or 3D components, for example, which are then inserted into the 2D or 3D containers? If they're inserted automatically, where would another component get the handle from to refer to them?

Maybe some example syntax would help here.

I'm not sure if this explanation of my idea is comprehensible, but either way I'm looking forward to a response to either clear up any confusion or just giving me the green light to play with a proof of concept implementation. If a visual representation of the idea would help to clarify it let me know.

I think the underlying concept of having global object stores is quite clear to me (although I might have totally misunderstood that part :smile:), but I don't understand how the concept relates to defining models.

What's also not clear to me (and I think that follows from my previously expressed lack of understanding), is what this concept is trying to achieve. You mention that it would hopefully no longer necessary to clone things, and while that sounds good, all that cloning might[^1] not be a problem right now. And I'm suspicious of trying to solve to solve performance issues before they are an actual problem. That just means you're making changes without being able to properly assess them, which is especially relevant if they have negative impacts too (for example, by making things more complicated).

[^1]: As I said above, cloning might contribute to the complexity of the sweep algorithm, and this needs to be explored. But it's not a performance problem right now.

gabsi26 commented 2 years ago

Okay, here we go, @gabsi26. Thank you for your patience!

No problem

What you call "features" here is called "objects" in current Fornjot kernel parlance. In the context of CAD, I understand the word "feature" to refer to a collection of such objects, that make up a region of the part.

I'm not saying my own use of these words is the definite one, and I'm open to change nomenclature where it makes sense. I'm just mentioning it here, to avoid confusion.

Of course you are right sorry for the mixed terminology

I see a problem here: Objects within Shape are mutable. Meaning they can be changed, and the same Handles will from then on refer to the changed object. I don't love this. It would be nicer in many ways, if objects were immutable. If an object you reference with Handle couldn't just change from under you without you noticing. But it's the best way I have found to make the current use cases work.

I think we should be able to make them immutable once inserted, which would of course make those object stores different from the one use in Shape, but as I see it that is really not an issue at all. Basically the stores contain everything that was created already and can thus be referenced by another component/object. Like a Sketch can be referenced by a Sweep by simply holding the Handle to this Sketch. If there were global object stores, then we'd have to find some way to keep the current use cases working, preferably without making things harder to do than they already are. I think the sweep algorithm might be a good testbed for this, since what it does is really simple from a conceptual level (1. copy the original sketch and invert its orientation, creating the bottom face; 2. copy the original sketch and translate it along the sweep path, creating the top face; 3. connect the vertices and edges of the bottom and top face, creating the side faces), but implementing it, especially step 3, has turned out way too gnarly.

Basically, if we can still implement the sweep algorithm using global object stores, without making it even more complicated, then that's a good sign that things can work out. And maybe there are even opportunities to simplify things using your proposal. A lot of the complexity in the sweep algorithm stems from Shapes being cloned, and the relationships between the Handles of different Shapes having to be managed.

I have to think more about this. Maybe we're onto something here.

Great to hear that if nothing else I have at least inspired you in some way.

I think you've lost me there. Are you saying that we have freestanding functions that are annotated as defining 2D or 3D components, for example, which are then inserted into the 2D or 3D containers? If they're inserted automatically, where would another component get the handle from to refer to them?

Maybe some example syntax would help here.

I imagine it something like this (where the expanded version of the two example functions is written below the attribute macro one) :


#[fj::model]
pub fn model() -> Shape {
cross_section();
sweep_cross_section();
}

[fj::sketch]

pub fn cross_section() { //.... something to create the sketch fj::Sketch::from_points(points) }

pub fn cross_section(global_container_2d: &mut ContainerType2d) { //.... something to create the sketch global_container_2d.insert(fj::Sketch::from_points(points)); }

[fj::sweep]

pub fn sweep_cross_section() { fj::Sweep::from_path(cross_section, [0., 0., h]) }

pub fn sweep_cross_section(global_container_2d: &ContainerType2d, global_container_3d: &mut ContainerType3d) { global_container_3d.insert(fj::Sweep::from_path(cross_section, [0., 0., h])); }


The `ContainerType2d`/`ContainerType3d` structs could require to either supply a label or assign an index otherwise, which would allow to reference stored `Object` by the respective mean. So I'm basically thinking of them as `HashMap`s.
(The way the global containers enter the scope of the functions could of course be different, passing them as references like this just seemed the simplest.)
> I think the underlying concept of having global object stores is quite clear to me (although I might have totally misunderstood that part 😄), but I don't understand how the concept relates to defining models.
> 
You understood the global stores part correctly ;). It helps in the creation of a model structure and thus the ability to display it once the GUI part of has been settled on.
> What's also not clear to me (and I think that follows from my previously expressed lack of understanding), is what this concept is trying to achieve. You mention that it would hopefully no longer necessary to clone things, and while that sounds good, all that cloning might not be a problem right now. And I'm suspicious of trying to solve to solve performance issues before they are an actual problem. That just means you're making changes without being able to properly assess them, which is especially relevant if they have negative impacts too (for example, by making things more complicated).

I was hoping the suggestion would lead to more structured models and a clearer way of how to make assemblies happen. Avoiding cloning was meant more as way to tidy things up and not have as many semi-duplicates around. I did not think of this as performance imporvement to be honest, I simply believe it to be more intuitive to retrieve an `Object` you want to use from a store knowing exactly what `Object` this is, than having a cloned version of an `Object` where it might be mutable and thus no longer be the same `Object` but a modified one.

 I have but some thought into it of course but I'm nowhere near a good solution to achieve any of it. I'll try to implement parts and maybe that process reveals the way to go or makes more issues apparent to me. Anyways I hope I've made my ideas a bit more obvious and gave you the chance to better judge if it even makes sense or has a chance to work.
hannobraun commented 2 years ago

I think we should be able to make them immutable once inserted, which would of course make those object stores different from the one use in Shape, but as I see it that is really not an issue at all. Basically the stores contain everything that was created already and can thus be referenced by another component/object. Like a Sketch can be referenced by a Sweep by simply holding the Handle to this Sketch.

You say that it is no issue, but I'm not so sure. I don't doubt that it can be made to work somehow, but the somehow is where all the details live, and those are what causes all the trouble.

When sweeping a sketch, how to we create the top face? Do we just copy the original sketch, and translate it? Then we need to remember which edges and vertices of the original sketch correspond to which edges and vertices of the top face, otherwise we can't create the side edge/faces later.

And how do we know which objects to translate in the first place? We can't just translate the whole Shape, like we can now, because a store that only contains the objects that belong to the sketch no longer exists. So I guess we have to translate only those objects, that the sketch refers to. (And what's a sketch anyway? So far that concept doesn't exist in fj-kernel.)

So I guess we're either writing a lot of tedious and manual code, or we write some generic infrastructure that can translate an object, and all objects it references, returning the new object, as well as some kind of mapping between the handles of the original objects and those of the translated objects. (Remember, we need those, or we won't know which vertices/edges to connect with side edges/faces later.)

But maybe creating the top face by creating translated copies isn't the right way to do it. Isn't that just the kind of copying and redundancy we were going to avoid in the first place? So maybe we can just have a new "transform" object that references the original objects that it transforms. Maybe the original sketch is a Vec<Face>, and the top face can then be a Vec<Transform<Face>>.

Sounds good, but of course those new Transform objects will need to be referenced by other objects, so they too need to live in their own global store. Do we want separate stores for Transform<Face>/Transform<Edge>/Transform<Vertex>/...? Probably not. Plus, this wouldn't be general enough for real use cases anyway, because if we have a Transform for the top face, we need an Invert for the bottom face, and we certainly can't have separate stores for Invert<Transform<...>> and Transform<Invert<...>>, and Transform<Invert<Transform<...>>>...

Okay, so I guess we need dynamically typed handles now, and use those in Transform/Invert. But how do we integrate that without throwing away all static typing? Do we need to implement upcasting and downcasting for handles now?


I'll stop here. What I'm trying to say is, it's easy to imagine how things should work, but using those things in real code will cause real problems, and those will need to be solved. The current Shape infrastructure is one solution, the result of months of refinement, and it's still not good.

I'm excited about the promise that those global stores have, but I absolutely know there are a million problems to be solved, before any of that promise can be realized.

I imagine it something like this (where the expanded version of the two example functions is written below the attribute macro one) : ...

You may already know this, since you have looked at the code, but in the interest of clarity, I should note that Shape in fj-kernel and fj::Shape aren't really related from an implementation standpoint. They are conceptually related, as every fj::Shape will be translated into an fj_kernel::shape::Shape under the hood, via fj-operations. But, as of right now, this kernel Shape is not made available through the fj library. So there's no way for a model to refer to them, or anything in them.

So what you are proposing here, is essentially a new thing, as far as the fj library is concerned, as stores/containers don't exist there yet. And how to add them is a bit of an open question to me. I doubt that fj-kernel can just become a dependency of fj without wrecking the compile time. Plus, fj and fj-app (which contains everything interesting, including the kernel) need to communicate over an FFI boundary, which brings its own set of issues.


So yeah, I don't know how that would look when implemented. Plus, your example is still a bit hand-wavy. Wouldn't cross_section need something like #[fj::sketch("name of sketch")], so sweep_cross_section can refer to it?

I'm also not sure this is the best approach to do it, as sorting stuff into different containers based on annotated functions might be a bit too magical. Maybe it would be better to achieve the same thing using a more straight-forward API that is passed into the model function. Then you could do stuff like let handle = model.create_sketch(points);, and it would be much easier understandable to anyone who knows Rust.

I have but some thought into it of course but I'm nowhere near a good solution to achieve any of it. I'll try to implement parts and maybe that process reveals the way to go or makes more issues apparent to me. Anyways I hope I've made my ideas a bit more obvious and gave you the chance to better judge if it even makes sense or has a chance to work.

I think we're talking about at least 3 different problems here:

  1. A better way to store objects, without redundancy and mutability.
  2. If/how/why to expose those improved stores to models.
  3. How to structure models in response to all that.

I'll be thinking more about point 1, as I see a lot of promise there (but as I said, with a million problems still to be solved). Points 2 and 3 seem more nebulous to me. No idea, how good of a chance any work in those areas has to work out. Maybe prototyping is the best way to answer that, or maybe not. I don't know.

hannobraun commented 2 years ago

I've spent some more hours thinking about the "global store for all objects" idea, only in relation to how it could affect data structures in the kernel, not how to possibly expose that through the fj library. I even started writing up a proposal, to organize my thoughts.

My premise for this was, that all objects are part of a single graph, and that shapes are basically just references into that graph. This graph is not redundant (i.e. common objects are shared between shapes), the graph is append-only, and objects within the graph are immutable.

While this is a promising concept that I remain interested in, I wasn't able to figure out a good way to implement it. The core problem is that without mutability, you need nodes in the graph that modify objects they reference (like a Transform<T> modifier), and that any code that can accept a Face, for example, needs to be able to accept a Transform<Face> too, as well as all possible other iterations.

I couldn't find a way to make this kind of polymorphism work without lots of traits and Boxes everywhere. Given that this kind of heavyweight infrastructure brings with it a lot of complexity, I wonder whether the end result will be any better than what we currently have. Maybe it will be, but I don't see a compelling enough case to invest more of my time right now.

I will keep thinking about this in the background. Maybe I can come up with a simpler idea.

hannobraun commented 2 years ago

Follow-up to my previous comment here: While I still don't have a solution for the polymorphism problem, some other aspects I've been thinking about in relation to the "global object store" idea have solidified into a concrete plan: #691

Thank you for triggering that line of thought, @gabsi26! I'm not sure if the overall idea will lead anywhere, but it looks like there are some concrete benefits coming out of it.