DXF import of simple drawing makes a dog's breakfast

Post here for help on using FreeCAD's graphical user interface (GUI).
Forum rules
and Helpful information
IMPORTANT: Please click here and read this first, before asking for help

Also, be nice to others! Read the FreeCAD code of conduct!
kpmartin
Posts: 10
Joined: Tue Feb 07, 2023 9:50 pm
Contact:

DXF import of simple drawing makes a dog's breakfast

Post by kpmartin »

I'm just starting with FreeCAD so maybe I'm doing something wrong or have the wrong expectations, but here's what is happening.

I have a very simple Autocad drawing saved from R13 as DXF. The file contains one layer, a few lines, ellipses, text, and angular dimensions. FWIW I was figuring out the geometry involved in milling a sharp inside corner in the bottom of a pocket using a V cutting endmill with the spindle angled.

I'm using FreeCAD 0.20.2.

When I imported this drawing into FreeCAD in Draft workbench using the C++ importer, I get the following issues:
1 - The lines in the original drawing turn into, well, I'm not sure what they are because FreeCAD doesn't seem to have the ability to tell me what an object actually *is*, but they have names like "Shape092" in the model tree. It seems to me that, being lines in the original drawing, these should import as lines, not shapes (and ellipses as ellipses).
2 - The text ends up microscopic, with a font size of 0.20mm regardless of the original size
3 - Angled text loses its angle and is drawn parallel to the X axis
4 - Angular dimensions come in as linear dimensions between apparently random points (though I expect the points are actually somehow significant to the original objects)
5 - The objects are at the top level in the model, but in the original drawing they are in a layer. In this case it is the default layer "0" so perhaps it was decided that things in layer "0" are only in a layer because AutoCad doesn't let you have objects not in a layer at all, so "0" is the catch-all for such objects. Unfortunately this loses the ability to globally change object properties which in AutoCad would have been "BYLAYER". Either this is a bug, or if it is a "feature" it should be optional.

When I imported this using the legacy python importer it failed in four ways, two of which I worked around so far:
1 - The text for some of the dimensions contains the degree symbol ° (0xB0) and in the DXF file is is actually the byte 0xB0, but the importer is reading the file using UTF-8 encoding, for which this is not a valid byte and the encoder generates an error. Per the DXF documentation from AutoDesk, DXF from version 2004 and previous use "plain ASCII" (which actually appears to be windows-1252 or maybe iso-8859-1) for its strings, augmented further with the in-band \U+0x1234 style encoding. Only version 2007 and after use UTF-8 encoding. The reader should read the start of the file with no encoding until it finds the $ACADVER variable, and then if this is >= "AC1021" it should re-open the file with UTF-8 encoding. This would also give the code the opportunity to give a sensible "This is not a DXF file" message if the contents are completely wacko.
2 - When I alter the Python code in dxfReader.py to open the file with encoding=None it completes reading the data into its own private structure, but then the importDXF module trips up placing the dimension objects into their layer because the layer object it finds by looking up the layer name is some sort of proxy object to the actual FreeCAD model layer object. Other object-placement code calls the module-level addObject function to encapsulate the presence of this proxy, but the dimension code just does proxyObject.addObject(dimObject) which fails because the proxy has no addObject method.

When I fix the above assign-to-layer code in importDXF.py most of the drawing imports properly except:
3 - the angular dimensions have turned into linear dimensions (though different from the ones generated by the C++ importer).
4 - Line styles are not imported. Perhaps FreeCAD does not support these, but then how is one supposed to show a center line or a hidden line in a drawing? Lines seem to have a "Draw Style" but options are very limited.

It seems that the status of the legacy importer is that it is no longer supported because it is being replaced by the C++ importer, but at least from my viewpoint, the new importer is far from ready, and until it does at least as good a job as the legacy one, the legacy one should continue to get fixes.

So, I'm new here in the FreeCAD world, and I don't want to come charging in like a bull in a china shop, so I'm looking for advice on how to proceed here. I'm willing to supply (and if possible, apply) fixes for the legacy importer but I don't want to go through the trouble only to be told that it is unsupported and no fixes will be accepted.
-Kevin Martin
the Papertrail Handmade Paper & Book Arts
User avatar
thomas-neemann
Veteran
Posts: 11800
Joined: Wed Jan 22, 2020 6:03 pm
Location: Osnabrück DE 🇩🇪
Contact:

Re: DXF import of simple drawing makes a dog's breakfast

Post by thomas-neemann »

kpmartin wrote: Wed Feb 08, 2023 2:14 pm ...
can you upload the file somewhere?
Gruß Dipl.-Ing. (FH) Thomas Neemann

https://www.youtube.com/@thomasneemann5 ... ry=freecad
Syres
Veteran
Posts: 2893
Joined: Thu Aug 09, 2018 11:14 am

Re: DXF import of simple drawing makes a dog's breakfast

Post by Syres »

If I'm struggling with a DXF file sent by a customer then I always fall back to a macro that uses the ezdxf library https://pypi.org/project/ezdxf/, the version of ezdxf depends on the Python version being used, this must be installed before running the macro below (the installation in the code has never worked on any Windows boxes I've tried). So far this hasn't let me down but no doubt you're about to prove this case is different. :D

Code: Select all

# -*- coding: utf-8 -*-

__Name__ = 'DXF to Sketch Layers'
__Comment__ = 'DXF loader to a sketch for each layer or color'
__Author__ = 'Julian Todd'
__Version__ = '1.0.2'
__Date__ = '2019-04-30'
__License__ = 'LGPL-2.0-or-later'
__Web__ = 'https://github.com/goatchurchprime/transition-CAM/dxfimporting/Macro_DXF_to_Sketch_layers.FCMacro'
__Wiki__ = ''
__Icon__ = ''
__Help__ = ''
__Status__ = 'stable'
__Requires__ = 'FreeCAD >= 0.17'
__Communication__ = ''
__Files__ = ''

# This should be a general function available to all macros
# (Taken from https://stackoverflow.com/questions/12332975/installing-python-module-within-code )
from PySide import QtGui
def import_and_install(packagename):
    try:
        _encoding = QtGui.QApplication.UnicodeUTF8
        def tr(context, text):
            return QtGui.QApplication.translate(context, text, None, _encoding)
    except AttributeError:
        def tr(context, text):
            return QtGui.QApplication.translate(context, text, None)

    import importlib
    try:
        importlib.import_module(packagename)
    except ImportError:
        cmdlist = ["pip", "install", packagename, "--user"]
        answer = QtGui.QMessageBox.question(None,
                    tr(__Name__, 'Install %s?' % packagename),
        	    tr(__Name__, 'Install %s by executing "%s"?' % (packagename, " ".join(cmdlist))))
        if answer == QtGui.QMessageBox.StandardButton.Yes:
            import subprocess
            proc = subprocess.Popen(cmdlist, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            out, err = proc.communicate()
            print(out.decode())
            print(err.decode())
        else:
            raise
    globals()[packagename] = importlib.import_module(packagename)


#
# Imports and start of code
#
import_and_install("ezdxf")
from PySide import QtGui
import math
import Draft, Part
from FreeCAD import Vector
import FreeCAD
import FreeCADGui


#
# DXF unwrapping and colour converting.  (Would like this in a separate file.)
#
dxfcolors = ['#000000', '#FF0000', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#FF00FF', '#000000', '#808080', '#C0C0C0', '#FF0000', '#FF7F7F', '#CC0000', '#CC6666', '#990000', '#994C4C', '#7F0000', '#7F3F3F', '#4C0000', '#4C2626', '#FF3F00', '#FF9F7F', '#CC3300', '#CC7F66', '#992600', '#995F4C', '#7F1F00', '#7F4F3F', '#4C1300', '#4C2F26', '#FF7F00', '#FFBF7F', '#CC6600', '#CC9966', '#994C00', '#99724C', '#7F3F00', '#7F5F3F', '#4C2600', '#4C3926', '#FFBF00', '#FFDF7F', '#CC9900', '#CCB266', '#997200', '#99854C', '#7F5F00', '#7F6F3F', '#4C3900', '#4C4226', '#FFFF00', '#FFFF7F', '#CCCC00', '#CCCC66', '#999900', '#99994C', '#7F7F00', '#7F7F3F', '#4C4C00', '#4C4C26', '#BFFF00', '#DFFF7F', '#99CC00', '#B2CC66', '#729900', '#85994C', '#5F7F00', '#6F7F3F', '#394C00', '#424C26', '#7FFF00', '#BFFF7F', '#66CC00', '#99CC66', '#4C9900', '#72994C', '#3F7F00', '#5F7F3F', '#264C00', '#394C26', '#3FFF00', '#9FFF7F', '#33CC00', '#7FCC66', '#269900', '#5F994C', '#1F7F00', '#4F7F3F', '#134C00', '#2F4C26', '#00FF00', '#7FFF7F', '#00CC00', '#66CC66', '#009900', '#4C994C', '#007F00', '#3F7F3F', '#004C00', '#264C26', '#00FF3F', '#7FFF9F', '#00CC33', '#66CC7F', '#009926', '#4C995F', '#007F1F', '#3F7F4F', '#004C13', '#264C2F', '#00FF7F', '#7FFFBF', '#00CC66', '#66CC99', '#00994C', '#4C9972', '#007F3F', '#3F7F5F', '#004C26', '#264C39', '#00FFBF', '#7FFFDF', '#00CC99', '#66CCB2', '#009972', '#4C9985', '#007F5F', '#3F7F6F', '#004C39', '#264C42', '#00FFFF', '#7FFFFF', '#00CCCC', '#66CCCC', '#009999', '#4C9999', '#007F7F', '#3F7F7F', '#004C4C', '#264C4C', '#00BFFF', '#7FDFFF', '#0099CC', '#66B2CC', '#007299', '#4C8599', '#005F7F', '#3F6F7F', '#00394C', '#26424C', '#007FFF', '#7FBFFF', '#0066CC', '#6699CC', '#004C99', '#4C7299', '#003F7F', '#3F5F7F', '#00264C', '#26394C', '#0042FF', '#7F9FFF', '#0033CC', '#667FCC', '#002699', '#4C5F99', '#001F7F', '#3F4F7F', '#00134C', '#262F4C', '#0000FF', '#7F7FFF', '#0000CC', '#6666CC', '#000099', '#4C4C99', '#00007F', '#3F3F7F', '#00004C', '#26264C', '#3F00FF', '#9F7FFF', '#3200CC', '#7F66CC', '#260099', '#5F4C99', '#1F007F', '#4F3F7F', '#13004C', '#2F264C', '#7F00FF', '#BF7FFF', '#6600CC', '#9966CC', '#4C0099', '#724C99', '#3F007F', '#5F3F7F', '#26004C', '#39264C', '#BF00FF', '#DF7FFF', '#9900CC', '#B266CC', '#720099', '#854C99', '#5F007F', '#6F3F7F', '#39004C', '#42264C', '#FF00FF', '#FF7FFF', '#CC00CC', '#CC66CC', '#990099', '#994C99', '#7F007F', '#7F3F7F', '#4C004C', '#4C264C', '#FF00BF', '#FF7FDF', '#CC0099', '#CC66B2', '#990072', '#994C85', '#7F005F', '#7F3F0B', '#4C0039', '#4C2642', '#FF007F', '#FF7FBF', '#CC0066', '#CC6699', '#99004C', '#994C72', '#7F003F', '#7F3F5F', '#4C0026', '#4C2639', '#FF003F', '#FF7F9F', '#CC0033', '#CC667F', '#990026', '#994C5F', '#7F001F', '#7F3F4F', '#4C0013', '#4C262F', '#333333', '#5B5B5B', '#848484', '#ADADAD', '#D6D6D6', '#FFFFFF']
def getlayercol(e, dfile, blockcolnum):
    if e.dxf.color == 256:
        layer = dfile.layers.get(e.dxf.layer)
        colnum = layer.get_dxf_attrib("color", 0)
    elif e.dxf.color == 0:
        colnum = blockcolnum
    else:
        colnum = e.dxf.color
    return (e.dxf.layer, dxfcolors[colnum])

def makeentitygroupsrecurse(entitygroupdict, dfile, entities, blockcolnum):
    for e in entities:
        if e.dxftype() == 'INSERT':
            print("INSERT translate", e.dxf.insert, "rotate", e.dxf.rotation, "scale", e.dxf.xscale, e.dxf.yscale)
            lblockcolnum = dfile.layers.get(e.dxf.layer).get_color()
            makeentitygroupsrecurse(entitygroupdict, dfile, list(dfile.blocks[e.dxf.name]), lblockcolnum)
        elif e.dxftype() == 'MTEXT':
            pass
        elif e.dxftype() == 'TEXT':
            pass
        else:
            layercol = getlayercol(e, dfile, blockcolnum)
            entitygroupdict.setdefault(layercol, []).append(e)
    return entitygroupdict

def makeentitygroups(dfile):
    entitygroupdict = { }
    makeentitygroupsrecurse(entitygroupdict, dfile, dfile.entities, 1)
    entitygroups = list(entitygroupdict.items())
    layernameorder = dict((n, i)  for i, n in enumerate(l.dxf.name  for l in dfile.layers))
    entitygroups.sort(key=lambda X:layernameorder[X[0][0]])
    return entitygroups


# See also arbitrary axis algorithm in http://paulbourke.net/dataformats/dxf/dxf10.html
# Seems to flip rotation around Y-axis, so only invert the X
def arcextrusionfac(e):
    if max(abs(e.dxf.extrusion[0]), abs(e.dxf.extrusion[1]), abs(abs(e.dxf.extrusion[2]) - 1)) > 1e-5:
        print("Unknown arc extrusion", e.dxf.extrusion)
    return 1 if e.dxf.extrusion[2] >= 0 else -1


#
# Function to create sketches and inject dxf geometry directly into it 
#

def MakeSketches(addObjectFunc, entitygroups):
    cnorm = Vector(0, 0, 1)
    sketches = [ ]
    for (layer, col), entities in entitygroups:
        slayer = str(layer)
        if len(slayer) <= 1:
            slayer = "layer_"+slayer  # Sketcher name is '_' if it's a 1 character number
        sketch = addObjectFunc("Sketcher::SketchObject", slayer)
        if sketch.ViewObject is not None:
            sketch.ViewObject.Visibility = True
            sketch.ViewObject.LineColor = (int(col[1:3],16)/255.0, int(col[3:5],16)/255.0, int(col[5:7],16)/255.0)
        print("Making sketch", layer, col)
        sketches.append(sketch)
        for e in entities:
            try:
                if e.dxftype() == "LINE":
                    if e.dxf.start != e.dxf.end:
                        p0 = Vector(e.dxf.start[0], e.dxf.start[1])
                        p1 = Vector(e.dxf.end[0], e.dxf.end[1])
                        sketch.addGeometry(Part.LineSegment(p0, p1))
                
                elif e.dxftype() == "CIRCLE":
                    exfac = arcextrusionfac(e)
                    cen = Vector(e.dxf.center[0]*exfac, e.dxf.center[1])
                    sketch.addGeometry(Part.Circle(cen, cnorm, e.dxf.radius))
                    
                elif e.dxftype() == "ARC":
                    exfac = arcextrusionfac(e)
                    cen = Vector(e.dxf.center[0]*exfac, e.dxf.center[1])
                    circ = Part.Circle(cen, cnorm, e.dxf.radius)
                    a0, a1 = e.dxf.start_angle, e.dxf.end_angle
                    if exfac == -1:
                        a0, a1 = 180-a1, 180-a0
                    sketch.addGeometry(Part.ArcOfCircle(circ, math.radians(a0), math.radians(a1)))

                elif e.dxftype() == "SPLINE":
                    cps = [Vector(x[0], x[1])  for x in list(e.control_points)]
                    bspl = Part.BSplineCurve(cps,None,None,False,e.dxf.degree,None,e.closed)
                    sketch.addGeometry(bspl)
                    
                elif e.dxftype() == "LWPOLYLINE":
                    def lwpgeo(p0, p1, bulge):
                        if bulge != 0:
                            lv = p1 - p0
                            b = abs(bulge)
                            d = lv.Length/2
                            bd = b*d
                            r = (bd + d/b)/2
                            sb = (1 if bulge >= 0 else -1)
                            pf = (r - bd)/(d*2)
                            cnorm = Vector(0, 0, 1)
                            lvperp = lv.cross(cnorm)*sb
                            cen = p0 + lv*0.5 - lvperp*pf
                            circ = Part.Circle(cen, cnorm, r)
                            mang = math.atan2(lvperp.y, lvperp.x)
                            th2 = math.asin(d/r)
                            return(Part.ArcOfCircle(circ, mang-th2, mang+th2))
                        else:
                            return Part.LineSegment(p0, p1)
                            
                    p0 = Vector(e[0][0], e[0][1])
                    for i in range(1, len(e)):
                        p1 = Vector(e[i][0], e[i][1])
                        sketch.addGeometry(lwpgeo(p0, p1, e[i-1][4]))
                        p0 = p1
                    if e.closed:
                        sketch.addGeometry(lwpgeo(p0, Vector(e[0][0], e[0][1]), e[-1][4]))
                
                elif e.dxftype() == "POLYLINE": 
                    p0s = Vector(e[0].dxf.location[0], e[0].dxf.location[1])
                    p0 = p0s
                    for i in range(1, len(e)):
                        p1 = Vector(e[i].dxf.location[0], e[i].dxf.location[1])
                        if p0 != p1:
                            sketch.addGeometry(Part.LineSegment(p0, p1))
                        p0 = p1
                    if e.is_closed and p0s != p0:
                        sketch.addGeometry(Part.LineSegment(p0, p0s))
                        
                else:
                    print("unknown", e.dxftype())
                    
            except Part.OCCError as err:
                print(e, err)
    return sketches



#
# Main entry which finds or creates the document and body
#
def main():
    """Main entry which finds or creates the document and body"""
    fname, fnamefilter = QtGui.QFileDialog.getOpenFileName(parent=FreeCADGui.getMainWindow(), caption='Read a DXF file', filter='*.dxf')
    if fname:
        FreeCAD.Console.PrintMessage('Parsing file {}'.format(fname))
        dfile = ezdxf.readfile(fname)

        # unitfacmap = {4:1.0} # 4->mm
        # unitfac = unitfacmap[dfile.header['$INSUNITS']]  
        # fac = dfile.header['$DIMALTF']
        # print("Factor multiply requested (not implemented)", fac, unitfac)

        entitygroups = makeentitygroups(dfile)
        doc = FreeCAD.activeDocument()
        if doc is None:
            doc = FreeCAD.newDocument()
        # Get the active body, if any.
        obj = FreeCADGui.activeDocument().ActiveView.getActiveObject('pdbody')
        try:
            addObjectFunc = obj.newObject
        except AttributeError:
            addObjectFunc = doc.addObject

        sketches = MakeSketches(addObjectFunc, entitygroups)
        doc.recompute()

if __name__ == '__main__':
    main()

Code: Select all

OS: Linux Mint 20.3 (X-Cinnamon/cinnamon)
Word size of FreeCAD: 64-bit
Version: 0.21.0.31806 (Git)
Build type: Release
Branch: master
Hash: 30e3abde15de58f1f01fddacc9a6570fc3de08c4
Python 3.8.10, Qt 5.12.8, Coin 4.0.0, Vtk 7.1.1, OCC 7.3.0
Locale: English/United Kingdom (en_GB)
Installed mods: 
  * fasteners.backup1675873390.7615435 0.4.54
  * ose-workbench-core
  * SearchBar 1.0.1
  * OSE3dPrinter
  * PieMenu 1.2.4
  * fasteners 0.4.54
kpmartin
Posts: 10
Joined: Tue Feb 07, 2023 9:50 pm
Contact:

Re: DXF import of simple drawing makes a dog's breakfast

Post by kpmartin »

thomas-neemann wrote: Wed Feb 08, 2023 4:15 pm
kpmartin wrote: Wed Feb 08, 2023 2:14 pm ...
can you upload the file somewhere?
Can I attach files to posts in this forum or is there some other related place to post files? The sample file is 66kB, though I have now tried importing another dxf file and doubled my list of problems...
-Kevin Martin
the Papertrail Handmade Paper & Book Arts
kpmartin
Posts: 10
Joined: Tue Feb 07, 2023 9:50 pm
Contact:

Re: DXF import of simple drawing makes a dog's breakfast

Post by kpmartin »

Syres wrote: Wed Feb 08, 2023 4:31 pm If I'm struggling with a DXF file sent by a customer then I always fall back to a macro that uses the ezdxf library https://pypi.org/project/ezdxf/, the version of ezdxf depends on the Python version being used, this must be installed before running the macro below (the installation in the code has never worked on any Windows boxes I've tried). So far this hasn't let me down but no doubt you're about to prove this case is different. :D
...
It looks like this might create a Sketch, but I want a Draft. Also, I would prefer having the official importer working properly rather than replacing it entirely with a 3rd-party contribution.
-Kevin Martin
the Papertrail Handmade Paper & Book Arts
drmacro
Veteran
Posts: 8862
Joined: Sun Mar 02, 2014 4:35 pm

Re: DXF import of simple drawing makes a dog's breakfast

Post by drmacro »

kpmartin wrote: Thu Feb 09, 2023 3:14 pm
Syres wrote: Wed Feb 08, 2023 4:31 pm If I'm struggling with a DXF file sent by a customer then I always fall back to a macro that uses the ezdxf library https://pypi.org/project/ezdxf/, the version of ezdxf depends on the Python version being used, this must be installed before running the macro below (the installation in the code has never worked on any Windows boxes I've tried). So far this hasn't let me down but no doubt you're about to prove this case is different. :D
...
It looks like this might create a Sketch, but I want a Draft. Also, I would prefer having the official importer working properly rather than replacing it entirely with a 3rd-party contribution.
That's actually rather ironic, since most of FreeCAD is some sort of 3rd party contribution, or was at some point. :lol:
Star Trek II: The Wrath of Khan: Spock: "...His pattern indicates two-dimensional thinking."
Syres
Veteran
Posts: 2893
Joined: Thu Aug 09, 2018 11:14 am

Re: DXF import of simple drawing makes a dog's breakfast

Post by Syres »

@kpmartin you can use https://wiki.freecad.org/Draft_Draft2Sketch from the Draft Wb to convert the Sketch to wire(s).
User avatar
thomas-neemann
Veteran
Posts: 11800
Joined: Wed Jan 22, 2020 6:03 pm
Location: Osnabrück DE 🇩🇪
Contact:

Re: DXF import of simple drawing makes a dog's breakfast

Post by thomas-neemann »

kpmartin wrote: Thu Feb 09, 2023 3:07 pm ...
up to 1mb can be uploaded here, if the extension is not accepted you can zip it
Gruß Dipl.-Ing. (FH) Thomas Neemann

https://www.youtube.com/@thomasneemann5 ... ry=freecad
kpmartin
Posts: 10
Joined: Tue Feb 07, 2023 9:50 pm
Contact:

Re: DXF import of simple drawing makes a dog's breakfast

Post by kpmartin »

drmacro wrote: Thu Feb 09, 2023 3:25 pm
kpmartin wrote: Thu Feb 09, 2023 3:14 pm
Syres wrote: Wed Feb 08, 2023 4:31 pm If I'm struggling with a DXF file sent by a customer then I always fall back to a macro that uses the ezdxf library https://pypi.org/project/ezdxf/, the version of ezdxf depends on the Python version being used, this must be installed before running the macro below (the installation in the code has never worked on any Windows boxes I've tried). So far this hasn't let me down but no doubt you're about to prove this case is different. :D
...
It looks like this might create a Sketch, but I want a Draft. Also, I would prefer having the official importer working properly rather than replacing it entirely with a 3rd-party contribution.
That's actually rather ironic, since most of FreeCAD is some sort of 3rd party contribution, or was at some point. :lol:
I meant a 3rd-party contribution that would need its own maintenance management as well as keeping FreeCAD itself up to date. If I wanted that route I would just stay quietly in my corner and do all the fixes myself.
-Kevin Martin
the Papertrail Handmade Paper & Book Arts
drmacro
Veteran
Posts: 8862
Joined: Sun Mar 02, 2014 4:35 pm

Re: DXF import of simple drawing makes a dog's breakfast

Post by drmacro »

kpmartin wrote: Fri Feb 10, 2023 12:25 pm ...
I meant a 3rd-party contribution that would need its own maintenance management as well as keeping FreeCAD itself up to date. If I wanted that route I would just stay quietly in my corner and do all the fixes myself.
But, FreeCAD is built on Coin, Qt, OCCT, etc. all 3rd party applications maintained and developed by none of the FreeCAD devs.

It is unfortunate that offerings in the Addon manager are offered "caveat emptor", but that is the case of all of FreeCAD.
Star Trek II: The Wrath of Khan: Spock: "...His pattern indicates two-dimensional thinking."
Post Reply