| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | import math |
| | from datetime import datetime |
| |
|
| | import FreeCAD |
| | import DraftGeomUtils |
| | import DraftVecUtils |
| | import Part |
| | import WorkingPlane |
| |
|
| | |
| | |
| | |
| |
|
| | TOLERANCE = 0.0001 |
| | DISCRETIZE = 4 |
| | ROTATIONS = [0, 90, 180, 270] |
| |
|
| |
|
| | class Nester: |
| |
|
| | def __init__(self, container=None, shapes=None): |
| | """Nester([container,shapes]): Creates a nester object with a container |
| | shape and a list of other shapes to nest into it. Container and |
| | shapes must be Part.Faces. |
| | |
| | Typical workflow: |
| | |
| | n = Nester() # creates the nester |
| | n.addContainer(object) # adds a doc object as the container |
| | n.addObjects(objects) # adds a list of doc objects as shapes |
| | n.run() # runs the nesting |
| | n.show() # creates a preview (compound) of the results |
| | n.apply() # applies transformations to the original objects |
| | |
| | Defaults (can be changed): |
| | |
| | Nester.TOLERANCE = 0.0001 |
| | Nester.DISCRETIZE = 4 |
| | Nester.ROTATIONS = [0,90,180,270] |
| | """ |
| |
|
| | self.objects = None |
| | self.container = container |
| | self.shapes = shapes |
| | self.results = [] |
| | self.indexedFaces = None |
| | self.running = True |
| | self.progress = 0 |
| | self.setCounter = None |
| |
|
| | def addObjects(self, objects): |
| | """addObjects(objects): adds FreeCAD DocumentObjects to the nester""" |
| |
|
| | if not isinstance(objects, list): |
| | objects = [objects] |
| | if not self.objects: |
| | self.objects = {} |
| | if not self.shapes: |
| | self.shapes = [] |
| | for obj in objects: |
| | if hasattr(obj, "Shape"): |
| | h = obj.Shape.hashCode() |
| | if not h in self.objects: |
| | self.objects[h] = obj |
| | self.shapes.append(obj.Shape) |
| |
|
| | def addContainer(self, container): |
| | """addContainer(object): adds a FreeCAD DocumentObject as the container""" |
| |
|
| | if hasattr(container, "Shape"): |
| | self.container = container.Shape |
| |
|
| | def clear(self): |
| | """clear(): Removes all objects and shape from the nester""" |
| |
|
| | self.objects = None |
| | self.shapes = None |
| |
|
| | def stop(self): |
| | """stop((): stops the computation""" |
| |
|
| | self.running = False |
| |
|
| | def update(self): |
| | """update(): internal function to verify if computation can |
| | go on""" |
| |
|
| | if self.setCounter: |
| | self.setCounter(self.progress) |
| | if FreeCAD.GuiUp: |
| | from PySide import QtGui |
| |
|
| | QtGui.QApplication.processEvents() |
| | if not self.running: |
| | return False |
| | return True |
| |
|
| | def run(self): |
| | """run(): Runs a nesting operation. Returns a list of lists of |
| | shapes, each primary list being one filled container, or None |
| | if the operation failed.""" |
| |
|
| | |
| |
|
| | self.running = True |
| | self.progress = 0 |
| | starttime = datetime.now() |
| |
|
| | |
| |
|
| | print("Executing conformity tests… ", end="") |
| | if not self.container: |
| | print("Empty container. Aborting.") |
| | return |
| | if not self.shapes: |
| | print("Empty shapes. Aborting.") |
| | return |
| | if not isinstance(self.container, Part.Face): |
| | print("Container is not a face. Aborting.") |
| | return |
| | normal = self.container.normalAt(0, 0) |
| | for s in self.shapes: |
| | if not self.update(): |
| | return |
| | if len(s.Faces) != 1: |
| | print("One of the shapes does not contain exactly one face. Aborting.") |
| | return |
| | |
| | if s.Faces[0].normalAt(0, 0).getAngle(normal) > TOLERANCE: |
| | |
| | if s.Faces[0].normalAt(0, 0).getAngle(normal) - math.pi > TOLERANCE: |
| | print( |
| | "One of the face does not have the same orientation as the container. Aborting." |
| | ) |
| | return |
| |
|
| | |
| | |
| | |
| | |
| |
|
| | |
| | |
| |
|
| | |
| | step = 100.0 / (len(self.shapes) * len(ROTATIONS)) |
| |
|
| | |
| | |
| | self.indexedfaces = [[shape.hashCode(), shape] for shape in self.shapes] |
| |
|
| | |
| | faces = list(self.indexedfaces) |
| |
|
| | |
| | faces = [[f[0], f[1].Faces[0]] for f in faces] |
| |
|
| | |
| | faces = sorted(faces, key=lambda face: face[1].Area) |
| |
|
| | |
| | nfaces = [] |
| | for face in faces: |
| | if not self.update(): |
| | return |
| | nedges = [] |
| | allLines = True |
| | for edge in face[1].OuterWire.OrderedEdges: |
| | if isinstance(edge.Curve, (Part.LineSegment, Part.Line)): |
| | nedges.append(edge) |
| | else: |
| | allLines = False |
| | last = edge.Vertexes[0].Point |
| | for i in range(DISCRETIZE): |
| | s = float(i + 1) / DISCRETIZE |
| | par = edge.FirstParameter + (edge.LastParameter - edge.FirstParameter) * s |
| | new = edge.valueAt(par) |
| | nedges.append(Part.LineSegment(last, new).toShape()) |
| | last = new |
| | f = Part.Face(Part.Wire(nedges)) |
| | if not f.isValid(): |
| | if allLines: |
| | print("Invalid face found in set. Aborting.") |
| | else: |
| | print("Face distretizing failed. Aborting.") |
| | return |
| | nfaces.append([face[0], f]) |
| | faces = nfaces |
| |
|
| | |
| | sheets = [[]] |
| |
|
| | print("Everything OK (", datetime.now() - starttime, ")") |
| |
|
| | |
| |
|
| | facenumber = 1 |
| | facesnumber = len(faces) |
| |
|
| | |
| |
|
| | while faces: |
| |
|
| | print( |
| | "Placing piece", |
| | facenumber, |
| | "/", |
| | facesnumber, |
| | "Area:", |
| | FreeCAD.Units.Quantity(faces[-1][1].Area, FreeCAD.Units.Area).getUserPreferred()[0], |
| | ": ", |
| | end="", |
| | ) |
| |
|
| | face = faces.pop() |
| | boc = self.container.BoundBox |
| |
|
| | |
| | |
| | |
| | |
| | available = [] |
| |
|
| | |
| | |
| | initials = [] |
| |
|
| | |
| | unfit = True |
| |
|
| | for rotation in ROTATIONS: |
| |
|
| | if not self.update(): |
| | return |
| |
|
| | self.progress += step |
| |
|
| | print(rotation, ", ", end="") |
| | hashcode = face[0] |
| | rotface = face[1].copy() |
| | if rotation: |
| | rotface.rotate(rotface.CenterOfMass, normal, rotation) |
| | bof = rotface.BoundBox |
| | rotverts = self.order(rotface) |
| | |
| | |
| | basepoint = rotverts[0] |
| | basecorner = boc.getPoint(0) |
| |
|
| | |
| | if (bof.XLength < boc.XLength) and (bof.YLength < boc.YLength): |
| | unfit = False |
| |
|
| | |
| | |
| | |
| |
|
| | v1 = basecorner.add(basepoint.sub(bof.getPoint(0))) |
| | v2 = v1.add(FreeCAD.Vector(0, boc.YLength - bof.YLength, 0)) |
| | v3 = v2.add(FreeCAD.Vector(boc.XLength - bof.XLength, 0, 0)) |
| | v4 = v3.add(FreeCAD.Vector(0, -(boc.YLength - bof.YLength), 0)) |
| | binpol = Part.Face(Part.makePolygon([v1, v2, v3, v4, v1])) |
| | initials.append([binpol, [hashcode, rotface], basepoint]) |
| |
|
| | |
| |
|
| | for sheetnumber, sheet in enumerate(sheets): |
| | |
| | |
| | |
| | |
| | |
| |
|
| | if not self.update(): |
| | return |
| |
|
| | nofitpol = [] |
| | for placed in sheet: |
| | pts = [] |
| | for placedvert in self.order(placed[1], right=True): |
| | fpts = [] |
| | for i, rotvert in enumerate(rotverts): |
| | if not self.update(): |
| | return |
| |
|
| | facecopy = rotface.copy() |
| | facecopy.translate(placedvert.sub(rotvert)) |
| |
|
| | |
| | |
| |
|
| | outside = True |
| | faceverts = self.order(facecopy) |
| | for vert in faceverts: |
| | if (vert.sub(placedvert)).Length > TOLERANCE: |
| | if placed[1].isInside(vert, TOLERANCE, True): |
| | outside = False |
| | break |
| |
|
| | |
| | |
| | |
| |
|
| | if outside: |
| | for e1 in facecopy.OuterWire.Edges: |
| | for e2 in placed[1].OuterWire.Edges: |
| | if not self.update(): |
| | return |
| |
|
| | if True: |
| | |
| | p = DraftGeomUtils.findIntersection(e1, e2) |
| | if p: |
| | p = p[0] |
| | p1 = e1.Vertexes[0].Point |
| | p2 = e1.Vertexes[1].Point |
| | p3 = e2.Vertexes[0].Point |
| | p4 = e2.Vertexes[1].Point |
| | if ( |
| | (p.sub(p1).Length > TOLERANCE) |
| | and (p.sub(p2).Length > TOLERANCE) |
| | and (p.sub(p3).Length > TOLERANCE) |
| | and (p.sub(p4).Length > TOLERANCE) |
| | ): |
| | outside = False |
| | break |
| | else: |
| | |
| | p = e1.distToShape(e2) |
| | if p: |
| | if p[0] < TOLERANCE: |
| | |
| | if (p[2][0][0] != "Vertex") or ( |
| | p[2][0][3] != "Vertex" |
| | ): |
| | outside = False |
| | break |
| |
|
| | if outside: |
| | fpts.append([faceverts[0], i]) |
| | |
| |
|
| | |
| | |
| |
|
| | idxs = [p[1] for p in fpts] |
| | if (0 in idxs) and (len(faceverts) - 1 in idxs): |
| | slicepoint = len(fpts) |
| | last = len(faceverts) |
| | for p in reversed(fpts): |
| | if p[1] == last - 1: |
| | slicepoint -= 1 |
| | last -= 1 |
| | else: |
| | break |
| | fpts = fpts[slicepoint:] + fpts[:slicepoint] |
| | |
| | pts.extend(fpts) |
| |
|
| | |
| |
|
| | if len(pts) < 3: |
| | print("Error calculating a no-fit polygon. Aborting.") |
| | return |
| | pts = [p[0] for p in pts] |
| | pol = Part.Face(Part.makePolygon(pts + [pts[0]])) |
| |
|
| | if not pol.isValid(): |
| |
|
| | |
| |
|
| | overlap = True |
| | while overlap: |
| | overlap = False |
| | for i in range(len(pol.OuterWire.Edges) - 1): |
| | if not self.update(): |
| | return |
| |
|
| | v1 = DraftGeomUtils.vec(pol.OuterWire.OrderedEdges[i]) |
| | v2 = DraftGeomUtils.vec(pol.OuterWire.OrderedEdges[i + 1]) |
| | if abs(v1.getAngle(v2) - math.pi) <= TOLERANCE: |
| | overlap = True |
| | ne = Part.LineSegment( |
| | pol.OuterWire.OrderedEdges[i].Vertexes[0].Point, |
| | pol.OuterWire.OrderedEdges[i + 1].Vertexes[-1].Point, |
| | ).toShape() |
| | pol = Part.Face( |
| | Part.Wire( |
| | pol.OuterWire.OrderedEdges[:i] |
| | + [ne] |
| | + pol.OuterWire.OrderedEdges[i + 2 :] |
| | ) |
| | ) |
| | break |
| |
|
| | if not pol.isValid(): |
| |
|
| | |
| |
|
| | pol.fix(0, 0, 0) |
| | if pol.isValid(): |
| | if pol.ShapeType == "Face": |
| | pol = Part.Face(pol.OuterWire) |
| | elif pol.Faces: |
| | |
| | a = 0 |
| | ff = None |
| | for f in pol.Faces: |
| | if f.Area > a: |
| | a = f.Area |
| | ff = f |
| | if ff: |
| | pol = ff |
| | else: |
| | print("Unable to fix invalid no-fit polygon. Aborting.") |
| | Part.show(pol) |
| | return |
| |
|
| | if not pol.isValid(): |
| |
|
| | |
| |
|
| | print("Invalid no-fit polygon. Aborting.") |
| | Part.show(pol.OuterWire) |
| | for p in sheet: |
| | Part.show(p[1]) |
| | Part.show(facecopy) |
| | |
| | |
| | return |
| |
|
| | if pol.isValid(): |
| | nofitpol.append(pol) |
| | |
| |
|
| | |
| |
|
| | if len(nofitpol) == 1: |
| | nofitpol = nofitpol[0] |
| | elif len(nofitpol) > 1: |
| | b = nofitpol.pop() |
| | for n in nofitpol: |
| | if not self.update(): |
| | return |
| | b = b.fuse(n) |
| | nofitpol = b |
| |
|
| | |
| |
|
| | lut = {} |
| | for f in fitpol.Faces: |
| | for e in f.Edges: |
| | h = e.hashCode() |
| | if h in lut: |
| | lut[h].append(e) |
| | else: |
| | lut[h] = [e] |
| | edges = [e[0] for e in lut.values() if len(e) == 1] |
| | try: |
| | pol = Part.Face(Part.Wire(edges)) |
| | except Exception: |
| | |
| | w = DraftGeomUtils.findWires(edges) |
| | if len(w) == 1: |
| | if w[0].isClosed(): |
| | try: |
| | pol = Part.Face(w[0]) |
| | except Exception: |
| | print("Error merging polygons. Aborting.") |
| | try: |
| | Part.show(Part.Wire(edges)) |
| | except Exception: |
| | for e in edges: |
| | Part.show(e) |
| | return |
| |
|
| | |
| | |
| |
|
| | if nofitpol: |
| | fitpol = binpol.cut(nofitpol) |
| | else: |
| | fitpol = binpol.copy() |
| |
|
| | |
| |
|
| | if (fitpol.Area > 0) and fitpol.Vertexes: |
| |
|
| | |
| | |
| | |
| | fitverts = sorted([v.Point for v in fitpol.Vertexes], key=lambda v: v.x) |
| | for p in fitverts: |
| | if not self.update(): |
| | return |
| |
|
| | trface = rotface.copy() |
| | trface.translate(p.sub(basepoint)) |
| | ok = True |
| | for placed in sheet: |
| | if ok: |
| | for vert in trface.Vertexes: |
| | if placed[1].isInside(vert.Point, TOLERANCE, False): |
| | ok = False |
| | break |
| | if ok: |
| | for e1 in trface.OuterWire.Edges: |
| | for e2 in placed[1].OuterWire.Edges: |
| | p = DraftGeomUtils.findIntersection(e1, e2) |
| | if p: |
| | p = p[0] |
| | p1 = e1.Vertexes[0].Point |
| | p2 = e1.Vertexes[1].Point |
| | p3 = e2.Vertexes[0].Point |
| | p4 = e2.Vertexes[1].Point |
| | if ( |
| | (p.sub(p1).Length > TOLERANCE) |
| | and (p.sub(p2).Length > TOLERANCE) |
| | and (p.sub(p3).Length > TOLERANCE) |
| | and (p.sub(p4).Length > TOLERANCE) |
| | ): |
| | ok = False |
| | break |
| | if not ok: |
| | break |
| | if ok: |
| | rotface = trface |
| | break |
| | else: |
| | print("Could not determine location on sheet. Aborting.") |
| | return |
| |
|
| | |
| |
|
| | bb = rotface.BoundBox |
| | for placed in sheet: |
| | bb.add(placed[1].BoundBox) |
| | available.append([sheetnumber, [hashcode, rotface], bb.XMax, fitpol]) |
| |
|
| | if unfit: |
| | print("One face does not fit in the container. Aborting.") |
| | return |
| |
|
| | if available: |
| |
|
| | |
| | available = sorted(available, key=lambda sol: sol[2]) |
| | print("Adding piece to sheet", available[0][0] + 1) |
| | sheets[available[0][0]].append(available[0][1]) |
| | |
| |
|
| | else: |
| |
|
| | |
| |
|
| | sheet = [] |
| | print("Creating new sheet, adding piece to sheet", len(sheets)) |
| | |
| | initials = sorted(initials, key=lambda sol: sol[1][1].BoundBox.XLength) |
| | hashcode = initials[0][1][0] |
| | face = initials[0][1][1] |
| | |
| | verts = sorted([v.Point for v in initials[0][0].Vertexes], key=lambda v: v.x) |
| | face.translate(verts[0].sub(initials[0][2])) |
| | sheet.append([hashcode, face]) |
| | sheets.append(sheet) |
| |
|
| | facenumber += 1 |
| |
|
| | print("Run time:", datetime.now() - starttime) |
| | self.results.append(sheets) |
| | return sheets |
| |
|
| | def order(self, face, right=False): |
| | """order(face,[right]): returns a list of vertices |
| | ordered clockwise. The first vertex will be the |
| | lefmost one, unless right is True, in which case the |
| | first vertex will be the rightmost one""" |
| |
|
| | verts = [v.Point for v in face.OuterWire.OrderedVertexes] |
| |
|
| | |
| |
|
| | wp = WorkingPlane.PlaneBase() |
| | wp.align_to_point_and_axis(face.CenterOfMass, face.normalAt(0, 0)) |
| | pverts = [] |
| | for v in verts: |
| | vx = DraftVecUtils.project(v, wp.u) |
| | lx = vx.Length |
| | if vx.getAngle(wp.u) > 1: |
| | lx = -lx |
| | vy = DraftVecUtils.project(v, wp.v) |
| | ly = vy.Length |
| | if vy.getAngle(wp.v) > 1: |
| | ly = -ly |
| | pverts.append(FreeCAD.Vector(lx, ly, 0)) |
| | pverts.append(pverts[0]) |
| |
|
| | |
| |
|
| | s = 0 |
| | for i in range(len(pverts) - 1): |
| | s += (pverts[i + 1].x - pverts[i].x) * (pverts[i + 1].y + pverts[i].y) |
| | if s < 0: |
| | verts.reverse() |
| | elif s == 0: |
| | print("error computing winding direction") |
| | return |
| |
|
| | return verts |
| |
|
| | def show(self, result=None): |
| | """show([result]): creates shapes in the document, showing |
| | the given result (list of sheets) or the last result if |
| | none is provided""" |
| |
|
| | if not result: |
| | result = [] |
| | if self.results: |
| | result = self.results[-1] |
| | offset = FreeCAD.Vector(0, 0, 0) |
| | feats = [] |
| | for sheet in result: |
| | shapes = [self.container.OuterWire] |
| | shapes.extend([face[1] for face in sheet]) |
| | comp = Part.makeCompound(shapes) |
| | comp.translate(offset) |
| | o = FreeCAD.ActiveDocument.addObject("Part::Feature", "Nest") |
| | o.Shape = comp |
| | feats.append(o) |
| | offset = offset.add(FreeCAD.Vector(1.1 * self.container.BoundBox.XLength, 0, 0)) |
| | FreeCAD.ActiveDocument.recompute() |
| | return feats |
| |
|
| | def getPlacements(self, result=None): |
| | """getPlacements([result]): returns a dictionary of hashCode:Placement |
| | pairs from the given result or the last computed result if none |
| | is given. The Placement contains a translation vector and a rotation |
| | to be given to the final object.""" |
| |
|
| | if not self.indexedfaces: |
| | print("error: shapes were not indexed. Use run() first") |
| | return |
| | if not result: |
| | result = [] |
| | if self.results: |
| | result = self.results[-1] |
| | d = {} |
| | offset = FreeCAD.Vector(0, 0, 0) |
| | for sheet in result: |
| | for face in sheet: |
| | orig = None |
| | for pair in self.indexedfaces: |
| | if pair[0] == face[0]: |
| | orig = pair[1] |
| | if not orig: |
| | print("error: hashCode mismatch between original and transformed face") |
| | return |
| | shape = face[1] |
| | if offset.Length: |
| | shape.translate(offset) |
| | deltav = shape.Faces[0].CenterOfMass.sub(orig.Faces[0].CenterOfMass) |
| | rot = FreeCAD.Rotation( |
| | orig.Vertexes[0].Point.sub(orig.Faces[0].CenterOfMass), |
| | shape.Vertexes[0].Point.sub(shape.Faces[0].CenterOfMass), |
| | ) |
| | pla = FreeCAD.Placement(deltav, rot) |
| | d[face[0]] = pla |
| | offset = offset.add(FreeCAD.Vector(1.1 * self.container.BoundBox.XLength, 0, 0)) |
| | return d |
| |
|
| | def apply(self, result=None): |
| | """apply([result]): Applies the computed placements of the given |
| | result, or the last computed result if none is given, to the |
| | document objects given to the nester via addObjects() before |
| | running.""" |
| |
|
| | if not self.objects: |
| | print("objects list is empty") |
| | return |
| | p = self.getPlacements(result) |
| | if p: |
| | for key, pla in p.items(): |
| | if key in self.objects: |
| | sh = self.objects[key].Shape.copy() |
| | sh.translate(pla.Base) |
| | sh.rotate( |
| | sh.Faces[0].CenterOfMass, |
| | pla.Rotation.Axis, |
| | math.degrees(pla.Rotation.Angle), |
| | ) |
| | self.objects[key].Placement = sh.Placement |
| | else: |
| | print("error: hashCode mismatch with original object") |
| |
|
| |
|
| | def test(): |
| | "runs a test with selected shapes, container selected last" |
| |
|
| | import FreeCADGui |
| |
|
| | sel = FreeCADGui.Selection.getSelection() |
| | if sel: |
| | container = sel.pop().Shape |
| | shapes = [o.Shape for o in sel] |
| | n = Nester(container, shapes) |
| | result = n.run() |
| | if result: |
| | n.show() |
| |
|