CadQuery / cadquery

A python parametric CAD scripting framework based on OCCT
https://cadquery.readthedocs.io
Other
3.25k stars 294 forks source link

Text placement on a 3D surface #109

Open adam-urbanczyk opened 5 years ago

adam-urbanczyk commented 5 years ago

Support text placement on a 3D surface:

afbeelding

Continuation of #48

gumyr commented 2 years ago

I'm working on wrapping text and have had some success: image Currently I've mapped to a cylinder but I believe mapping to an arbitrary surface is just an extension of the same algorithm. The hard part is handling letter interiors as the direction of the wire is critical while simple subtraction of the interior solid has problems. More work to do...

adam-urbanczyk commented 2 years ago

What is exactly "the algorithm" you are referring to? I don't understand the remark about handling letter interiors. What I had in mind here was to extend #108 with using projection (see #562) and offset #642 #643

gumyr commented 2 years ago

Sorry, I should have included a more complete explanation.

My goal was to create text wrapped around a cylinder as shown above so I came up with the following algorithm:

As the input is Wires it can be generated to map any wires to a cylinder like this: image

The complexity with text is that the insides of the letters need to be removed which should be done during Face creation. For this to work the Wires need to be in the correct direction - something I'm still working on. For the "Beingφθ⌀" picture I just cut the inner Solids from the outer Solids which is slow and problematic.

BRepProj_Projection looks good but from what I can see it only projects to a cylinder or a cone. The above algorithm can be generalized to map in the input wires on to an arbitrary surface by sampling the appropriate location (in u,v space) and using this to build the mapped Edges. There should probably be a text on cylinder/cone method (which uses BRepProj_Projection) and a text on surface method that is more general.

With respect to 2D text, it seems as though AIS_Dimension could be used to generate the text as wires which should be faster than how I extracted wires from the text Solid.

gumyr commented 2 years ago

Adding link: AIS_Dimension:drawText() ... https://dev.opencascade.org/doc/occt-7.3.0/refman/html/class_a_i_s___dimension.html#a24b48debc577aa7f1f914b021f339c81

adam-urbanczyk commented 2 years ago

Thanks for the clarification! I don't think you are right regarding BRepProj_Projection - you are confusing projection type with the surface being projected onto. TBH I don't think the proposed approach is robust.

gumyr commented 2 years ago

I'll build a prototype with BRepProj_Projection and see what it can do...

gumyr commented 2 years ago

Looking good: image You were right, there are two projections - one with a direction and the other with a point. It will be fun to see what can be done with this.

gumyr commented 2 years ago

BRepProj_Projection is great but it doesn't solve one of the central problems with Text on a surface - so I'm looking for some advice here. The problem is that Text can have multiple outer and inner wires and I can't find a way to create a non-planar face with a hole in it. Consider a "D" - with the projected inner and outer wires non-planar faces can be created from both but the inner face needs to be subtracted from the outer face. Planar faces support the cut() method but this doesn't work with non-planar faces from what I can see. The non-optimal solution that I can think of is to create Solids first then do the cutting but this is slow and may have tolerance problems with the surfaces. Any suggestions are welcome.

dcowden commented 2 years ago

@gumyr When i wrote the same feature for Onshape (pictured in the original issue here), I ran into these same issues.

It turns out that doing text on a surface really well is quite hard. You need to consider not only text alignment, but also kerning. I ultimately just made a planar surface under each individual character, and projected each character to the desired surface.

At first it seems inelegant. but

  1. part of the inelegant feel is that you have to loop over the characters. But this is unavoidable when you support kerning.
  2. of course for certain poorly behaving surfaces, the plane-for-each-character approach doesnt work. but usually that means the text wont look good anyway.
  3. Eventually users want to choose ability to have each letter be its own solid, or have them grouped in a compound. the single-character at a time approach makes it easy to support both

In practice most surfaces people are applying text to are quite tame, and projecting the letters individually works ok most of the time.

[EDIT] : I looked back at my code, and it appears that The algorithm i ended up with is slightly more complex than just use a flat plane. What I did was this psuedocode:

for each character 
     * compute position ( kerning, alignment, etc)
     * create text character, and transform it to the right place
     * offset the underlying surface the desired letter height. This becomes the upper text surface
     * create a plane tangent to the target surface, and project the letter onto that surface
     * extrude the letter from the tangent plane UP to the offset surface, and downward onto the target surface

This is an approximation, but ends up being very very close to the generalized approach you're looking for.

dcowden commented 2 years ago

@gumyr also if you are interested in developing text code you're willing to contribute back to CQ, PM me and I'll be happy to share the complete source of the Onshape feature I wrote. Its a weird javascript Onshape language thing, but it might help you see exactly what i ended up doing

adam-urbanczyk commented 2 years ago

BRepProj_Projection is great but it doesn't solve one of the central problems with Text on a surface - so I'm looking for some advice here. The problem is that Text can have multiple outer and inner wires and I can't find a way to create a non-planar face with a hole in it. Consider a "D" - with the projected inner and outer wires non-planar faces can be created from both but the inner face needs to be subtracted from the outer face. Planar faces support the cut() method but this doesn't work with non-planar faces from what I can see. The non-optimal solution that I can think of is to create Solids first then do the cutting but this is slow and may have tolerance problems with the surfaces. Any suggestions are welcome.

You know the topology of every letter (inner/outer wires) and have the underlying surface so it should be trivial to construct a Face using BRepBuilderAPI_MakeFace. What is the problem with such an approach?

If you want to work on this topic, I'd really strongly suggest to start with #108 and #562 and only then handle this issue.

gumyr commented 2 years ago

Thanks for the help @dcowden, turns out that I'm doing almost exactly what you describe in the Onshape feature - working character by character with kerning and generating a Compound in the end - but I haven't given up on non-planar faces yet. As you describe there is a fair bit of complexity here. And yes, I intend to do one (or more) PRs with all of this code.

@adam-urbanczyk, I have no problem generating both the outer or inner wires - here is an example of just the outer wires mapped on a sphere: image but when I try to combine the outer and inner wires with BRepBuilderAPI_MakeFace I get OCP.Standard.Standard_NoSuchObject: NCollection_BaseList::PRemove when using the Add() method on the inner wires.

I thought I read that holes can be created with BRepBuilderAPI_MakeFace - I haven't given up yet... although I'd welcome an example if you know of one.

P.S. I essentially already have #562 and could do #108 too which sounds relatively easy as this would be a planar faces with simple extrusions (just translating and rotating each character if I understand the intent of the feature).

gumyr commented 2 years ago

I wrote the following which generates a non-planar face with holes:


def makeNonPlanarFace(
    exterior: Union[cq.Wire, list[cq.Edge]],
    surfacePoints: list[cq.Vector] = None,
    interiorWires: list[cq.Wire] = None,
) -> cq.Face:
    """Create a potentially non-planar face bounded by exterior (wire or edges),
    optionally refined by surfacePoints with optional holes defined by interiorWires"""

    # First, create the non-planar surface
    surface = BRepOffsetAPI_MakeFilling(
        Degree=3,
        NbPtsOnCur=15,
        NbIter=2,
        Anisotropie=False,
        Tol2d=0.00001,
        Tol3d=0.0001,
        TolAng=0.01,
        TolCurv=0.1,
        MaxDeg=8,
        MaxSegments=9,
    )
    if isinstance(exterior, cq.Wire):
        outside_edges = exterior.Edges()
    else:
        outside_edges = [e.Edge() for e in exterior]
    for edge in outside_edges:
        surface.Add(edge.wrapped, GeomAbs_C0)
    if surfacePoints:
        for pt in surfacePoints:
            surface.Add(gp_Pnt(*pt.toTuple()))
    surface.Build()

    # Next, add wires that define interior holes - note these wires must be entirely interior
    surface_face = cq.Face(surface.Shape())
    if interiorWires:
        makeface_object = BRepBuilderAPI_MakeFace(surface_face.wrapped)
        for w in interiorWires:
            makeface_object.Add(w.wrapped)
        surface_face = cq.Face(makeface_object.Face()).fix()
    return surface_face

with which I was able to generate: image with:

(outer_e, inner_e) = makeTextWires("e", 10)[0]
e_face = makeNonPlanarFace(outer_e[0], interiorWires=inner_e)
e_solid = e_face.thicken(1)

Still lots to do:

I think the rest is just normal coding problems - I'm confident I'll be able to make this work. Thanks for the guidance.

adam-urbanczyk commented 2 years ago

Thanks! Note that it should not be needed to use BRepOffsetAPI_MakeFilling because you already have the underlying geometry. Here is an example:

import OCP

w1 = cq.Face.makePlane(2,2).Wires()[0]
w2 = cq.Face.makePlane(1,1).Wires()[0]

f0 = cq.Workplane("XZ", origin=(0,3,3)).ellipse(2,1).extrude(6).faces('not %PLANE').val()

#show_object(w1)
#show_object(w2)
#show_object(f0)

d = OCP.gp.gp_Dir(0,0,1)

def project(w):

    return OCP.BRepProj.BRepProj_Projection(w.wrapped, f0.wrapped, d).Current()

w1_p = project(w1)
w2_p = project(w2)

show_object(w1_p)
show_object(w2_p)

bldr = OCP.BRepBuilderAPI.BRepBuilderAPI_MakeFace(f0._geomAdaptor(), w1_p)
bldr.Add(OCP.TopoDS.TopoDS.Wire_s(w2_p.Reversed()))

show_object(bldr.Face())

Regarding your remarks:

gumyr commented 2 years ago

Making progress - here is what both the conical (at the back) and cylindrical projections look like: image I need more experience with using BRepProj_Projection to see if they'll actually do what I want - which was to wrap text around a cylinder to label some of my parts - as neither projection currently is doing a good job of that with my level of experience. As currently designed, alternative text projections (e.g. the cylindrical wrap described at the top of this thread) would be easy to create.

Click here if you'd like to see the code as is. I think there are many opportunities for improvements - each projection takes 6 to 7 seconds.

The code makes no assumptions about the shape of the letters - with the font shown above a "g" has two holes and the last "letter" in the above image is actually a diameter symbol "⌀". I wrote a createWireRelationships method that creates a list of dict where the key is an exterior wire and the value is a list of the interior wire(s) (which could be an empty list) for each letter in the text string. Another source for optimization.

I still need to work on face normal direction - the dot on the "i" in the cylindrical projection is backwards from the rest of the "i".

My projectWireOnSolid() method now sorts the wires into front and back lists if there is more than one projected wire - although I suspect more usage could reveal weaknesses to this sorting algorithm.

I haven't looked at removing BRepOffsetAPI_MakeFilling yet. As currently implemented, a new makeNonPlanarFace method (like makeNSidedSurface but will all native cadquery typed inputs and the ability to add a hole by providing an interior wire) creates all of the non-planar faces for the letters. Before I'm done I'll add at least a single point into face creation as a circle projected onto a sphere would incorrectly result in a planar face.

Text alignment (halign/valign) isn't working yet (although the code I copied from the workplane text method is there), a minor bit of coding still needed to implement this.

adam-urbanczyk commented 2 years ago

Thanks for sharing the code and progress! Note that what you are building is becoming quite distinct from the intent of this issue (i.e. extending #108 to working on 3D surfaces).

gumyr commented 2 years ago

I don't understand your comment "what you are building is becoming quite distinct from the intent of this issue". How is the result of my implementation: image "quite distinct" from the image you included in the description of this feature? image

gumyr commented 2 years ago

Okay, I don't know if you want to go in this direction but I've come to the conclusion that this feature should have nothing to do with text - it's a Face feature. With this code:

text_faces = (
    cq.Workplane("XZ")
    .text(
        "Beingφθ⌀",
        fontsize=10,
        distance=1,
        font="Serif",
        fontPath="/usr/share/fonts/truetype/freefont",
        halign="center",
    )
    .faces(">Y")
    .vals()
)
projected_text_faces = [
    f.projectToSolid(sphere_solid, cq.Vector(0, 1, 0))[FRONT] for f in text_faces
]
projected_text_solids = [f.thicken(5) for f in projected_text_faces]

square = cq.Workplane("YZ").rect(10, 10).extrude(1).faces(">X").val()
square_projected = square.projectToSolid(sphere_solid, cq.Vector(1, 0, 0))

I get: image

In using the previous text projection solution I found it difficult to get the result I wanted without being able to actually see the text. This Face based solution avoids this problem by allowing the user to create faces any way they want and setup the projection while looking at the Faces. The face normal problem still needs to be fixed but that shouldn't be too difficult.

Much of the code required for the previous solution is not required here and the solution is much more general. If you're not interested in going this way I'm okay with that - I'll just release this as part of cq_warehouse.

adam-urbanczyk commented 2 years ago

As I already mentioned above, the idea was to generalize #108 to 3D space. So you'd start with a text, face (e.g. cylinder) and a wire/edge (e.g. helix) on that face. Projection is indeed one of the needed ingredients.

gumyr commented 2 years ago

The proposed algo would be: (1) put every letter on the path so that normal is aligned with the local normal of the face (2) project every letter separately (using said normal) (3) construct new letter faces from the projected wires (4) thicken

Thanks - this is very helpful. How do you propose the user would describe the path on a 3D surface?

adam-urbanczyk commented 2 years ago

Using cq.Edge / cq.Wire.

adam-urbanczyk commented 2 years ago

We are having quite some back and forth on this. I don't know what your plans are here (i.e. are you working on a PR or just brainstorming), but if you want to contribute, I strongly suggest to start small in order to avoid difficult discussions/wasted effort afterwards. Starting with separate PRs related to #562 and #643 would be very welcome.

gumyr commented 2 years ago

My plans are to do some PRs with what I've been building. I'm still testing the projection #562 and thickening #643 methods as they are complex operations and can produce unexpected results. For example, here is a Canadian flag waving in the wind: image It looks good at first glance but if one looks closely at the maple leaf it differs in thickness from the white section around it. I've been trying to get more consistent projected faces by controlling the direction of wires (outer opposite to inner) to control face normals but clearly this isn't sufficient.

The code for the core functionality is here: https://github.com/gumyr/cq_warehouse/blob/dev/src/cq_warehouse/map_texture.py The code for the examples is here: https://github.com/gumyr/cq_warehouse/blob/dev/src/cq_warehouse/map_texture_examples.py

With respect to the 3D text algorithm - is there an existing method for determining the normal at a point on a Solid or Compound? This is a key requirement to extend #562 and #643 into 3D text with the algorithm described.