# Tools for handling tool-paths. # Last edited on 2021-10-03 05:59:27 by stolfi import path_IMP; from path_IMP import Path_IMP import move import move_parms import pyx class Path(Path_IMP): # An object of the {Path} class represents a /tool-path/, or /path/ # for short: the traveling of the nozzle along a polygonal path -- # sometimes down, extruding material, sometimes raised, not extruding. # It is a sequence of zero or more oriented moves (traces or jumps), # such that the final point of each one is the initial point of the # next one. # # Every {Path} object {ph} has an /initial point/ and a /final point/ # the initial and final position of the nozzle as it executes the # path. These points are defined even if for an /empty/ path with zero # moves, in which case they are the same point. If the path is not # empty, they are the initial point of the first move and the last # point of the last move. # # The path may begin and/or end with a jump. It makes no sense for a # path to have two consecutive jumps, since those can be condensed # into a single jump with significant time saving. It also does not # make sense to have jumps of zero length. On the other hand, a trace # of zero length makes sense, provided that it is not followed or # preceded by another trace: it deposits a dot of material. # # The /execution time/ of path is the time needed to execute # all its moves, plus one /transition pernalty time/ # for each internal transition from a trace to a jump or # vice-versa. This penalty is assumed to be part of the # execution time of the jump. # # A {Path} object also has a mutable /name/ attribute, that is used # for debugging and documentation. It must be either a string or {None}. # # An /oriented path/ is a pair {(ph,dr)} where {ph} is a {path} object # and {dr} is 0 or 1 to indicate a direction of motion. If {dr} is zero # (the /native/ orientation), moves are assumed to be executed in the # order and orientations specified by the {Path} object. If {dr} is 1, # the moves are assumed to be executed in the reversed order, each in # the opposite direction. Note that the initial and final points of the # path are swapped in this case. A {Path} object {ph} by itself is # generally treated as the oriented path {(ph,0)}. pass # ATTRIBUTES def nelems(oph): # Returns the number of moves in the oriented path {oph}. return path_IMP.nelems(oph) def elem(oph, imv): # Returns the oriented move with index {imv} in the oriented path {oph=(ph,dr)}, # counting in the order implied by the orientation. The index {imv} # must be in {0..nmv-1} where {nmv = nelems(oph)}. return path_IMP.elem(oph, imv) def find_move(oph, omv): # Given an oriented path {oph} and an oriented or unoriented move {omv}, # returns the index {imv} such that {elem(oph, imv)} is {omv} or its # reverse. If the move does not occur in {oph}, returns {None}. return path_IMP.find_move(oph, omv) # GEOMETRY def pini(oph): # Returns the initial point of the oriented path {oph}, taking the # orientation bit into account. return path_IMP.pini(oph) def pfin(oph): # Returns the final point of the oriented path {oph}, taking the # orientation bit into account. return path_IMP.pfin(oph) def endpoints(oph): # Returns a tuple {(pini(oph),pfin(oph))}. return path_IMP.endpoints(oph) def points(oph): # Returns a list with all endpoints of all moves in the path {oph}. return path_IMP.points(oph) def find_nearest_point(oph, pt): # Returns the index {ipt} of the move endpoint of the path {oph} that is nearest to the point {pt} # and the distance between the two. # # If the path is empty, returns 0 and the distance from {pt} to # {pini(oph)==pfin(oph)}. Otherwise, if the path is closed, the index {ipt} will # be in {0..nmv-1} where {nmv=nelems(oph)}, and the nearest point will # be {move.pini(elem(oph, ipt)}. If {oph} is not closed, the index # {ipt} may be equal to {nmv}, and then the nearest point will be # {pfin(oph)}. return path_IMP.find_nearest_point(oph, pt) def bbox(OPHS): # The parameter {OPHS} must be a list or tuple of oriented paths. # Returns a bounding box of all moves of all those paths, # as a pair of points {(plo, phi)} that its lower left and upper right # corners. If {OPHS} is empty, returns {None} # # The bounding box is the smallest axis-aligned rectangle that # contains all the endpoints of all moves and jumps in all those # paths, as well as the starting/ending point of any empty paths. Note # that the nominal "sausages" of traces, as well as decoration such as # dots and arrowheads, may extend outside the box. return path_IMP.bbox(OPHS) def mean_projections(oph, xdir, ydir): # Obtains the pair of mean coordinates of the endpoints of {oph} # along the directions of the unit vectors {xdir} and {ydir}. # If either direction is {None}, the coordinate is {None}. return path_IMP.mean_projections(oph, xdir, ydir) # CREATION def make_empty(p): # Creates and returns an (unoriented) empty {Path} object, with zero # moves, that begins and ends at the point {p}. return path_IMP.make_empty(p) def from_move(p, q, mp): # Creates and returns an (unoriented) {Path} object, with exactly # one move from {p} to {q}. The width and dynamics will be as defined # by the {Move_Parms} object {mp} return path_IMP.from_move(p, q, mp) def from_moves(omvs): # The argument {omvs} must be a list of one or more moves, # such that each move ends where the next one begins. # Creates and returns a path consisting of those moves. return path_IMP.from_moves(omvs) def from_points(pts, mp_trace, mp_jump): # The argument {pts} must be a list whose elements are points or lists of points. # # If {n} consecutive elements of {pts} are points, the procedure creates # a sub-path of {n-1} traces with parameters {mp_trace}, connecting those points # # An element of {pts} may be itself a sub-list of points. In that # case, those points are connected by traces, as above, and that # sub-path is connected by jumps to the previous and following path # elements. # # The argument {mp_jump} is used only if the resulting path has jumps, as # per above. Otherwise it may be {None}. return path_IMP.from_points(pts, mp_trace, mp_jump) def concat(ophs, use_links, mp_jump): # The argument {ophs} must be a list of one or more oriented paths. # Returns a new {Path} object {ph} that is the concatenation of all # those paths. # # If the final point of a path {oph1} in the list is not not the # initial point of the next path {oph2}, a /connector/ is inserted # between them. If {use_links} is true, the procedure looks for a path # {olk} in {get_links(oph2)} that starts at {pfin(oph1)}. If there is # such a path, the procedure inserts {olk} between {oph1} and {oph2}. # If there is no such path, or {use_links} is false, the connector is # a jump with parameters {mp_jump}, # # The resulting path {ph} will share all the {move.Move} objects of # the given paths and any added links. Note that, depending on the # inputs, it may have multiple consecutive jumps; they are not # automatically condensed. # # The link paths attached to the start of the first path of {ophs} and # to the end of the last path of {ophs} are attached to the resulting path # too. # # This operation does not modify the given paths. It takes computing time # proportional to the number of paths plus the total number of moves # in those paths. return path_IMP.concat(ophs, use_links, mp_jump) def displace(oph, ang, v): # Returns a copy of the oriented path {oph} rotated by {ang} radians # around the origin and translated by the vector {v}, # in that order. # # All moves will be new {move.Move} objects, even if {ang}and {v} are # zero. The parameter object {Move_Parms} of each move will be # preserved, however all the timing data will be recomputed (even if the # length of moves is not supposed to change). return path_IMP.displace(oph, ang, v) def split_at_jumps(oph): # Breaks the path {oph} into one or more trace-only paths at the jumps (if any). # Returns the list {OPHS} of the trace-only paths, and a list {OJMS} of the jumps. return path_IMP.split_at_jumps(oph) # ORIENTATION def rev(oph): # Returns the reversal of the oriented path {oph}. return path_IMP.rev(oph) def spin(oph, dr): # Applies {rev} to the oriented path {oph}, {dr} times. # Thus the result is {oph} if {dr} is even, and {rev(oph)} # if {dr} is odd. # # If the {oph} argument is an unoriented {Path} object {ph}, the # result is {(ph,dr)}. return path_IMP.spin(oph, dr) def unpack(oph): # Checks whether {oph} is an oriented path. Returns the underlying # {Path} object and the direction bit as two separate results. # If {oph} is already an (undirected) {Path} object, returns {oph,0}. return path_IMP.unpack(oph) # TIMING def fabtime(oph): # Returns the total time to execute the path {oph}, including all jumps # in it. The orientation of {oph} is irrelevant. The time # includes the penalty times for all internal jump/trace transitions, # as specified in the {Move_Parms} records of the jumps. return path_IMP.fabtime(oph) def tini(oph, imv): # Returns the total nozzle travel time from the initial point of the oriented path {oph} # to the INITIAL point of the oriented move {elem(oph,imv)}, including all acceleration # and deceleration times. # # This time includes all the trace/jump transition penalties that # occur in moves {0..imv-1}, which are counted as adding to the # execution time of the jumps. Thus, the result includes a penalty for # the transition from move {imv-1} to {imv} if the former is a jump and # the latter is a trace -- but not vice-versa. # # For convenience, if {imv == nelems(oph)}, returns {fabtime(oph)}. return path_IMP.tini(oph, imv) def tfin(oph, imv): # Returns the total nozzle travel time from the initial point of the # oriented path {oph} to the FINAL point of the oriented move # {elem(oph,imv)}, including all acceleration and deceleration times. # # This time includes all the trace/jump transition penalties that # occur in moves {0..imv}, which are counted as adding to the execution # time of the jumps. Thus, the result includes a penalty for the # transition from move {imv} to {imv+1} if the former is a jump and the # latter is a trace -- not vice-versa. # # For convenience, if {imv == -1}, returns zero. return path_IMP.tfin(oph, imv) # INCREMENTAL PATH BUILDING def move_to(MVS, p, q, mp, tag): # If the point {p} is not {None}, # appends to {MVS} a trace from {p} to {q} with parms {mp}. # The moves are named "M{tag}{k}" where {k} is sequentially incremented. # In any case, returns the point {q}. return path_IMP.move_to(MVS, p, q, mp, tag) def finish(PHS, MVS, tag): # Makes the moves in {MVS} into a path, appends it to {PHS}, # and resets {MVS} to empty # The path name is set to "P{tag}". return path_IMP.finish(PHS, MVS, tag) # LINK PATHS # Each path {oph} can have a set of precomputed link paths that can be # used to connect it to other paths. These links are associated to the # endpoints of the underlying {Path} object of {oph} by {clear_links} # and {add_link} below. The orientation of the path {oph} is used only # to determine which endpoint the links are supposed to connect to. # # The links of {oph} are paths that end at {pini(oph)}. # # The links of {rev(oph)} are paths than end at {pfin(oph)}, # thus their reverses are paths that start at {pfin(oph)}. def clear_links(oph): # Sets the link lists of the path {oph} and {rev(oph)} to empty. path_IMP.clear_links(oph) def add_link(oph, olk): # Appends the oriented link path {olk} to the list of # links associated with {oph} object. The link {olk} must end at {pini(oph)}. path_IMP.add_link(oph, olk) def set_links(oph, OLKS): # Saves in the {Path} record of {oph} a copy of the list {OLKS}, as the list of all links # that connect to {oph}. They all must end at {pini(oph)}. return path_IMP.set_links(oph, OLKS) def get_links(oph): # Returns a list of all links stored in the {Path} record of {oph} # that end at {pini(oph)}. return path_IMP.get_links(oph) def get_connecting_link(oph0, oph1): # Returns the link path that connects the end of oriented # path {oph0} to the start of the oriented path {oph1}, # if such a link has been stored in the respective {Path} # objects; or {None} if there is no such link. # # There must be at most one link path satisfyng those conditions. # That path must have been associated with both {oph0} and # {oph1} through {add_link}. return path_IMP.get_connecting_link(oph0, oph1) def connection_time(oph0, oph1, use_links, mp_jump): # Estimates the extra fabrication time of {concat([oph0, oph1,], use_links, mp_jump)} # compared to the fabtimes of paths {oph0} and {oph1}. # # If {pfin(oph0)} is the same as {pini(oph1)}, the result is zero. # Otherwise, {use_links} is true and {get_connecting_link(oph0, oph1)} is not {None}, # returns the fabtime of that link. Otherwise returns the time of a jump with parameters # {mp_jump}. Includes the penalties of move-jump and jump-move transitions where applicable. return path_IMP.connection_time(oph0, oph1, use_links, mp_jump) # NESTING OF CONTOUR PATHS # A /contour path/ is a closed oriented path that is part of the slice's # boundary. "Closed" means that its initial and final points coincide. # Distinct contour paths must not cross or touch each other. # # A contour path may be the outer boundary of an "island", in which case # it must be oriented counterclockwise; or the boundary of a "hole", in # which case it must be oriented clockwise. There may be holes in # islands, and islands inside holes, nested to arbitrary depth. # # The nesting of a set of contour paths can be precomputed by assigning # to each contour path {ocr} in the set the list of other contour paths # of the set that are contained in it, and the list of contour paths # that contain it. These lists are built by {compute_contour_nesting} # below. The contour paths must be properly oriented (CCW or CW) for # this operation, but the lists are associated to the underlying {Path} # object of each contour path {ocr}, ignoring its orientation. Therefore, # these lists are shared with {path.rev(ocr)}, but not with any new # {Path} object created by operations such as {path.shift_contour} or # {path.displace}. def compute_contour_nesting(OCRS): # Given a list {OCRS} of the contour paths of a slice, determines their nesting # arrangement and saves in each {Path} object the lists used by {inner_contours} # and {outer_contours}. The contour paths must be properly oriented: CCW for # island contours, CW for hole contours. path_IMP.compute_contour_nesting(OCRS) def inner_contours(ocr): # Given a contour path {ocr}, returns the list (possibly empty) of the contour paths # that are immediately contained in it, as determined by the last call to # {compute_contour_nesting}. The orientaton of {ocr} is ignored, # and the returned paths are always properly oriented. If # {compute_contour_nesting} was never applied to {ocr}, returns {None} return path_IMP.inner_contours(ocr) def outer_contour(ocr): # Given a contour path {ocr}, returns the innermost contour path # that encloses it, as determined by {compute_contour_nesting}; # or {None} if there is no such contour. The # orientaton of {ocr} is ignored, and the returned path is always # properly oriented. If {compute_contour_nesting} was never applied to # {ocr}, returns {None} return path_IMP.outer_contour(ocr) def contour_nesting(ocr0, ocr1): # Given two contour paths {ocr0,ocr1}, returns {+1} if {ocr0} contains # {ocr1}, {-1} if {ocr0} is contained in {ocr1} and 0 otherwise # (including if {ocr1} and {ocr2} are the same {Path} object). Uses # nesting information attached to the paths by # {compute_contour_nesting}. The orientatons of {ocr0} and {ocr1} are # ignored. return path_IMP.contour_nesting(ocr0, ocr1) def shift_contour(ocr, k): # Given a contour path {ocr}, returns a contour path {ocrk} that has the same moves # in the same sequence, except that it starts with the move {elem(ocr,k)}. # # More precisely, one will have {elem(ocrk,i) = elem(ocr,(i+k)%n)} for # all {i} in {0...nmv-1}, where {n=nelems(ocr)}. Note that the shift # amount {k} is taken modulo {n}. In particular, if {k=0} (or {k=n}) # the result {ocrk} will be a copy of {ocr}. In any case, the result # will be a new {Path} object that shares the same {Move} objects with # {ocr}. return path_IMP.shift_contour(ocr, k) # PLOTTING def plot_to_files(fname, OPHS, CLRS, rwd, wd_axes, grid, deco): # The argument {OPHS} must be a list or tuple of oriented paths (or # plain {Path} objects). The {CLRS} argument must be a list or tuple # of {pyx.color.rgb} objects. # # Plots each path in {OPHS} using using some default sytle and the # correspondng color of {CLRS} for the trace sausages, over a # millimeter grid. Trace axes, dots, and arrowheads will be plotted # iff {deco} is true. Trace axes and jump lines will be drawn with line # width {wd_axes}. The fat sausage of a trace {mv} will have width # {rwd*move.width(mv)}. # # Plots a background grid iff {grid} is true. # # Then writes the plot to files "{fname}.{ext}" where {ext} is "eps", # "png", and "jpg". # # If {CLRS} has a single element, uses that color for all trace # sausages. If {CLRS} is {None}, uses some default color, Otherwise {CLRS} # must have the same length as {OPHS}. path_IMP.plot_to_files(fname, OPHS, CLRS, rwd, wd_axes, grid, deco) def plot_standard(c, OPHS, dp, layer, CLRS, rwd, wd_axes, axes, dots, arrows, matter): # The argument {OPHS} must be a list or tuple of oriented paths (or # plain {Path} objects). The {CLRS} argument must be a list or tuple # of {pyx.color.rgb} objects. # # Plots each path in {OPHS}, displaced by the vector {dp}, using the # correspondng color of {CLRS} for the trace sausages. # # If {axes} is true, draws the axes of traces, not just of jumps. # # If {dots} is true, prints dots at the ends of traces, not just of jumps. # # If {arrows} is true, draws arrowheads on traces, not just on jumps. # # If {matter} is {true}, shows the estimated area covered by the # material extruded during traces. # # The fat sausage of a trace will have width # {rwd*wd} where {wd} is its natural width. The axes of moves (traces or jumps) will be # drawn with width {wd_axes}: solid for traces, dashed for jumps. The # dots at the endpoints and arrows will be drawn with size # proportional to {wd_axes}. The color of these lines and decorations # will black for jumps, and darkened version of the sausage color for # trace axes. # # If {CLRS} has a single element, uses that color for all trace # sausages. If {CLRS} is {None}, uses some default color, Otherwise {CLRS} # must have the same length as {OPHS}. If {dp} is {None}, # assumes {(0,0)} (no displacement). # # The plot is done in 4 passes or /layers/: (0) the material overflow # sausages of all traces, if reequested; (1) the main sausage of all # traces; (2) the axes, dots, and arrows of all traces, as requested; # and (3) the axes, dots, and arrows of all jumps. # # If {layer} is not {None}, it must be an integer in {0..3}, in which # case only that layer is plotted. path_IMP.plot_standard(c, OPHS, dp, layer, CLRS, rwd, wd_axes, axes, dots, arrows, matter) def plot_layer(c, oph, dp, jmp, clr, rwd, wd, dashed, wd_dots, sz_arrows): # Plots selected elements of the oriented path {oph} on the {pyx} # context {c}. # # Plots only the jumps if {jmp} is true, and only the traces if {jmp} # is false. # # If {rwd} and/or {wd} are positive, the axis of each # selected move {omv} is drawn as a fat line segment: a rectangle with # round caps at the endpoints. The line will be dashed if {dashed} is # true. The width of that line will be {rwd*width(omv) + wd}. # So, if the move is a jump, it will be just {wd}. # # If {wd_dots} is true, dots of that diameter will be plotted at both # ends of each selected move, even if the axis itself is not drawn. # # If {sz_arrows} is nonzero, an arrowhead of that size will be drawn # halfway along the axis of each selected move that is long enough, # even if the axis itself is not drawn. # # These items are drawn with color {clr}. If {clr} is {None}, the # procedure does nothing. # # The plot will be displaced by the vector {dp} (a 2-tuple # of floats). If {dp} is {None}, assumes {(0,0)} (no # displacement). # # If {None} is given as {wd_axis}, {wd_dots}, or {sz_arrow}, the value 0 is # assumed. path_IMP.plot_layer(c, oph, dp, jmp, clr, rwd, wd, dashed, wd_dots, sz_arrows) def plot_single(c, oph, dp, split, clr): # Plots the path {oph}, displaced by {dp}, on the Pyx canvas {c} with a default style. # # If the boolean {split} is false, plots the traces of the whole path with the same color {clr}, # or with a default color if {clr} is {None}. # # If the boolean {split} is true, then {clr} must be {None}; the procedure # breaks the path into sub-paths at its jumps, and plots each path # with a different color, from a list of colors generated internally. return path_IMP.plot_single(c, oph, dp, split, clr) # DEBUGGING def validate(oph): # Runs some consistency checks on the oriented path {oph}. # Aborts with assert failure if it detects any errors. path_IMP.validate(oph) def get_name(oph): # Given an oriented path {oph}, returns the name attribute of the # underlying {Path} object {ph}, if not {None}. If the name of {ph} is # {None}, returns instead "P?". In any case, that name is prefixed "~" if {oph} is # {rev(ph)}. return path_IMP.get_name(oph) def set_name(oph, name, moves): # Given an oriented path {oph}, saves the string {name} as the name # attrbute of the underlying {Path} object {ph}. The orientation of # {oph} is ignored. # # If {moves} is true, also sets the name of every move {mv} in {ph} # to "{name}.{KK}" where {KK} is the position of the move in the path. # The move name will be the same no matter whether the path uses {mv} # or {rev(mv)}. path_IMP.set_name(oph, name, moves) def tag_names(OPHS, tag): # Prepends the string {tag} (if not {None}) to the names of all paths # in {OPHS}. The {Path} objects had better be all distinct. path_IMP.tag_names(OPHS, tag) def show(wr, oph, moves, ind, wna, wnm): # Writes to file {wr} a readable description of the path {oph}. # # The description has: # # * the direction bit of the path, " " or "~" # * the name of the underlying {Path} object, as returned by {get_name} # * the number {nmv} of moves in the path # * the initial and final points # # If {moves} is true, the description also includes the list of the # names of the moves that comprise the path {oph}; namely, the # sequence {move.get_name(path.elem(oph,imv))} for {imv} in # {range(path.nelems(oph))}. # # The name is padded to {wna} columns and left-aligned. The number of # moves is padded to {wnm} columns. The whole description is printed # in a single line, is indented by {2*ind} blanks, and is NOT ended # with a newline. path_IMP.show(wr, oph, moves, ind, wna, wnm) def show_list(wr, OPHS, moves, ind): # Writes to file {wr} a readable description of the oriented paths in # the list {OPHS}. The output consists of a header and then the # description of each path, one per line, as produced with {show} with # the given parameter {moves}; prefixed by {2*ind} spaces and # its index in {OPHS}. path_IMP.show_list(wr, OPHS, moves, ind)