├── .gitignore ├── .gitmodules ├── README.md ├── gcode_parser.py ├── plotter └── plotter.ino ├── plotter_new └── plotter_new.ino ├── print.py ├── requirements.txt └── sender.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.nc 2 | *.txt 3 | .vscode/ 4 | __pycache__/ 5 | *.elf 6 | *.hex 7 | !requirements.txt -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "text_to_gcode"] 2 | path = text_to_gcode 3 | url = https://github.com/Stypox/text-to-gcode.git 4 | [submodule "image_to_gcode"] 5 | path = image_to_gcode 6 | url = https://github.com/Stypox/image-to-gcode.git 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plotter software 2 | 3 | This repository contains all of the code needed to run a **plotter** and have it print **G-code, text or images**. In particular: 4 | - the `plotter/` subdirectory contains the Arduino sketch 5 | - the `gcode_parser.py` script is able to read G-code, normalize it (so that the printed composition fits on a 2D rectangle of a specified size) and convert it to a shorter binary file 6 | - the `sender.py` script takes the binary file generated by `gcode_parser.py` and sends it to a plotter connected to the computer via a serial port 7 | - the `print.py` script wraps all of the things you may need into a single command 8 | - [text-to-gcode](https://github.com/Stypox/text-to-gcode/) is used to convert some ASCII text to G-code 9 | - [image-to-gcode](https://github.com/Stypox/image-to-gcode/) is used to convert an image to G-code, also with automatic edge detection 10 | 11 | # Usage 12 | You can run any script normally with [Python 3](https://www.python.org/downloads/). E.g. for `print.py`: 13 | ``` 14 | python3 print.py ARGUMENTS... 15 | ``` 16 | This is the help screen with all valid arguments for `print.py` (obtainable with `python3 print.py --help`): 17 | ``` 18 | usage: print.py [-h] [-o FILE] [-b FILE] [-l FILE] [--end-home] [-s XxY] [-d FACTOR] [--simulate] [--port PORT] [--baud RATE] {binary,gcode,text} ... 19 | 20 | Print something with the connected plotter 21 | 22 | optional arguments: 23 | -h, --help show this help message and exit 24 | 25 | subcommands: 26 | Format subcommands 27 | 28 | {binary,gcode,text} 29 | binary Send binary files directly to the plotter 30 | gcode Print gcode with the plotter 31 | text Print text with the plotter 32 | 33 | Output options: 34 | -o FILE, --output FILE 35 | File in which to save the generated gcode (will be ignored if using binary subcommand) 36 | -b FILE, --binary-output FILE 37 | File in which to save the binary data ready to be fed to the plotter 38 | -l FILE, --log FILE File in which to save logs, comments and warnings 39 | 40 | Gcode generation options: 41 | --end-home Add a trailing instruction to move to (0,0) instead of just taking the pen up 42 | -s XxY, --size XxY The size of the print area in millimeters (e.g. 192.7x210.3) 43 | -d FACTOR, --dilation FACTOR 44 | Dilation factor to apply (useful to convert mm to steps) 45 | 46 | Plotter connectivity options: 47 | --simulate Simulate sending data to the plotter without really opening a connection. Useful with logging enabled to debug the commands sent. 48 | --port PORT, --serial-port PORT 49 | The serial port the plotter is connected to (required unless there is --simulate) 50 | --baud RATE, --baud-rate RATE 51 | The baud rate to use for the connection with the plotter. It has to be equal to the plotter baud rate. (required unless there is --simulate) 52 | ``` -------------------------------------------------------------------------------- /gcode_parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | #pylint: disable=no-member 3 | 4 | from enum import Enum 5 | import argparse 6 | import sys 7 | import re 8 | import math 9 | 10 | writeByte = b"w" 11 | moveByte = b"m" 12 | 13 | 14 | def _log_nothing(*args, **kwargs): 15 | pass 16 | 17 | 18 | class AttrType(Enum): 19 | pen = 0, 20 | x = 1, 21 | y = 2 22 | 23 | class AttributeParser: 24 | def __init__(self, useG, feedVisibleBelow, speedVisibleBelow): 25 | self.useG = useG 26 | self.useFeed = feedVisibleBelow is not None 27 | self.feedVisibleBelow = feedVisibleBelow 28 | self.useSpeed = speedVisibleBelow is not None 29 | self.speedVisibleBelow = speedVisibleBelow 30 | 31 | if not self.useG and not self.useFeed and not self.useSpeed: 32 | raise ValueError("At least a method (G, feed or speed) has to be specified to parse gcode") 33 | 34 | def parseAttribute(self, word, lineNr, log=_log_nothing): 35 | try: 36 | try: # attribute value is an integer 37 | value = int(word[1:]) 38 | except ValueError: # attribute value is a floating point number 39 | value = float(word[1:]) 40 | 41 | key = word[0].upper() 42 | if key == "G" and self.useG: 43 | if value == 0 or value == 1: 44 | key = AttrType.pen 45 | else: 46 | raise ValueError() 47 | elif key == "F" and self.useFeed: 48 | key = AttrType.pen 49 | value = 1 if value < self.feedVisibleBelow else 0 50 | elif key == "S" and self.useSpeed: 51 | key = AttrType.pen 52 | value = 1 if value < self.speedVisibleBelow else 0 53 | elif key == "X": 54 | key = AttrType.x 55 | elif key == "Y": 56 | key = AttrType.y 57 | else: 58 | raise ValueError() 59 | 60 | return (key, value) 61 | except (ValueError, IndexError): 62 | if word != "": 63 | log(f"[WARNING {lineNr:>5}]: ignoring unknown attribute \"{word}\"") 64 | return None 65 | 66 | 67 | class ParsedLine: 68 | @classmethod 69 | def fromRawCoordinates(cls, pen, x, y, lineNr=None): 70 | return cls({AttrType.pen: pen, AttrType.x: x, AttrType.y: y}, lineNr) 71 | 72 | @classmethod 73 | def fromGcodeLine(cls, attributeParser, line, lineNr, lastAttributes, log=_log_nothing): 74 | def removeComments(code): 75 | begin = code.find("(") 76 | if begin == -1: 77 | return code 78 | else: 79 | end = code.find(")") 80 | if end == -1: 81 | log(f"[WARNING {lineNr:>5}]: missing closing parenthesis on comment starting in position {begin+1}") 82 | return code[:begin] 83 | else: 84 | log(f"[comment {lineNr:>5}]: {code[begin+1:end]}") 85 | return code[:begin] + " " + removeComments(code[end+1:]) 86 | 87 | attributes = {k: v for k, v in lastAttributes.items()} 88 | words = removeComments(line).split(" ") 89 | 90 | for word in words: 91 | attribute = attributeParser.parseAttribute(word, lineNr, log=log) 92 | if attribute is not None: 93 | attributes[attribute[0]] = attribute[1] 94 | 95 | return cls(attributes, lineNr) 96 | 97 | 98 | def __init__(self, attributes, lineNr): 99 | self.attributes = attributes 100 | self.lineNr = lineNr 101 | 102 | def __repr__(self): 103 | lineRepr = "EOF" if self.lineNr is None else self.lineNr 104 | return f"[{lineRepr:>5} data]: pen={self[AttrType.pen]} x={self[AttrType.x]:>12.5f} y={self[AttrType.y]:>12.5f}" 105 | 106 | def __getitem__(self, key): 107 | return self.attributes[key] 108 | 109 | def __setitem__(self, key, value): 110 | self.attributes[key] = value 111 | 112 | 113 | def shouldOverwrite(self, lastAttributes): 114 | return ((self[AttrType.x] == lastAttributes[AttrType.x] 115 | and self[AttrType.y] == lastAttributes[AttrType.y] 116 | and self[AttrType.pen] == lastAttributes[AttrType.pen]) 117 | or (self[AttrType.pen] == 0 118 | and lastAttributes[AttrType.pen] == 0)) 119 | 120 | def gcode(self): 121 | return f"G{self[AttrType.pen]} X{self[AttrType.x]:.3f} Y{self[AttrType.y]:.3f}" 122 | 123 | 124 | def detectParsingMode(data, log=_log_nothing): 125 | logLabel = "[info] parsing mode detection:" 126 | gRegex = r"(?:\s|\A)[Gg]([01])(?:\s|\Z)" 127 | feedRegex = r"(?:\s|\A)[Ff]([-+]?(?:[0-9]*\.[0-9]+|[0-9]+))(?:\s|\Z)" 128 | speedRegex = r"(?:\s|\A)[Ss]([-+]?(?:[0-9]*\.[0-9]+|[0-9]+))(?:\s|\Z)" 129 | 130 | gInvisibleCount, gVisibleCount = 0, 0 131 | for gMatch in re.finditer(gRegex, data): 132 | if gMatch.group(0) == "0": gInvisibleCount += 1 133 | else: gVisibleCount += 1 134 | log(logLabel, f"found {gInvisibleCount} invisible G attributes and {gVisibleCount} visible ones") 135 | 136 | def getVisibilityFeedOrSpeed(regex): 137 | foundValues, allCount = {}, 0 138 | for match in re.finditer(regex, data): 139 | allCount += 1 140 | if match.group(1) in foundValues: 141 | foundValues[match.group(1)] += 1 142 | else: 143 | foundValues[match.group(1)] = 1 144 | 145 | average = 0 146 | for value, count in foundValues.items(): 147 | average += float(value) * count 148 | average /= allCount 149 | 150 | invisibleCount, visibleCount = 0, 0 151 | for value, count in foundValues.items(): 152 | if float(value) > average: 153 | invisibleCount += count 154 | else: 155 | visibleCount += count 156 | 157 | return invisibleCount, visibleCount, average 158 | 159 | feedInvisibleCount, feedVisibleCount, feedThreshold = getVisibilityFeedOrSpeed(feedRegex) 160 | log(logLabel, f"found {feedInvisibleCount} invisible feed attributes and " + 161 | f"{feedVisibleCount} visible ones, with a feed threshold of {feedThreshold}") 162 | 163 | speedInvisibleCount, speedVisibleCount, speedThreshold = getVisibilityFeedOrSpeed(speedRegex) 164 | log(logLabel, f"found {speedInvisibleCount} invisible speed attributes and " + 165 | f"{speedVisibleCount} visible ones, with a feed threshold of {speedThreshold}") 166 | 167 | 168 | def score(invisible, visible): # higher is better 169 | return ((1.0 - abs(invisible - visible) / (invisible + visible)) 170 | * (0.5 + 0.5 * visible / (invisible + visible)) 171 | * math.log10(invisible + visible)) 172 | 173 | gScore = score(gInvisibleCount, gVisibleCount) 174 | feedScore = score(feedInvisibleCount, feedVisibleCount) 175 | speedScore = score(speedInvisibleCount, speedVisibleCount) 176 | log(logLabel, f"gScore={gScore}, feedScore={feedScore}, speedScore={speedScore}") 177 | 178 | maxScore = max(gScore, feedScore, speedScore) 179 | if maxScore == gScore: 180 | log(logLabel, "chosen g mode") 181 | return True, None, None 182 | elif maxScore == feedScore: 183 | log(logLabel, f"chosen feed mode with feed visible below {feedThreshold}") 184 | return False, feedThreshold, None 185 | else: 186 | log(logLabel, f"chosen speed mode with speed visible below {speedThreshold}") 187 | return False, None, speedThreshold 188 | 189 | 190 | def translateToFirstQuarter(parsedGcode, log=_log_nothing): 191 | translationX = -min([line[AttrType.x] for line in parsedGcode]) 192 | translationY = -min([line[AttrType.y] for line in parsedGcode]) 193 | 194 | for line in parsedGcode: 195 | line[AttrType.x] += translationX 196 | line[AttrType.y] += translationY 197 | 198 | log(f"[info] Translation vector: ({translationX}, {translationY})") 199 | return parsedGcode 200 | 201 | def getDilationFactor(parsedGcode, xSize, ySize): 202 | xSizeReal = max([line[AttrType.x] for line in parsedGcode]) - min([line[AttrType.x] for line in parsedGcode]) 203 | ySizeReal = max([line[AttrType.y] for line in parsedGcode]) - min([line[AttrType.y] for line in parsedGcode]) 204 | 205 | return min([ 206 | xSize / xSizeReal, 207 | ySize / ySizeReal, 208 | ]) 209 | 210 | def dilate(parsedGcode, dilationFactor): 211 | for line in parsedGcode: 212 | line[AttrType.x] *= dilationFactor 213 | line[AttrType.y] *= dilationFactor 214 | return parsedGcode 215 | 216 | def addEnd(parsedGcode, endHome=False, log=_log_nothing): 217 | if endHome: 218 | parsedGcode.append(ParsedLine.fromRawCoordinates(0, 0, 0)) 219 | log("[info] The gcode path ends at (0, 0)") 220 | elif len(parsedGcode) > 0: 221 | parsedGcode.append(ParsedLine.fromRawCoordinates(0, 222 | parsedGcode[-1][AttrType.x], parsedGcode[-1][AttrType.y])) 223 | log(f"[info] Before dilating the gcode path ends at ({parsedGcode[-1][AttrType.x]}, {parsedGcode[-1][AttrType.y]})") 224 | return parsedGcode 225 | 226 | def resize(parsedGcode, xSize, ySize, dilation=1.0, log=_log_nothing): 227 | dilationFactor = dilation * getDilationFactor(parsedGcode, xSize, ySize) 228 | parsedGcode = dilate(parsedGcode, dilationFactor) 229 | 230 | log("[info] Dilation factor:", dilationFactor) 231 | return parsedGcode 232 | 233 | def toGcode(parsedGcode): 234 | return "\n".join([l.gcode() for l in parsedGcode], ) + "\n" 235 | 236 | def toBinaryData(parsedGcode): 237 | stepsX, stepsY = 0, 0 238 | data = b"" 239 | 240 | for line in parsedGcode: 241 | currentStepsX = int(round(line[AttrType.x]-stepsX)) 242 | currentStepsY = int(round(line[AttrType.y]-stepsY)) 243 | stepsX += currentStepsX 244 | stepsY += currentStepsY 245 | 246 | if line[AttrType.pen]: 247 | data += writeByte 248 | else: 249 | data += moveByte 250 | 251 | data += currentStepsX.to_bytes(2, byteorder="big", signed=True) 252 | data += currentStepsY.to_bytes(2, byteorder="big", signed=True) 253 | 254 | return data 255 | 256 | def parseGcode(data, useG=False, feedVisibleBelow=None, speedVisibleBelow=None, log=_log_nothing): 257 | attributeParser = AttributeParser(useG, feedVisibleBelow, speedVisibleBelow) 258 | lines = data.split("\n") 259 | # mostly safe: it should be overwritten by the first (move) command in data 260 | parsedGcode = [ParsedLine.fromRawCoordinates(0, 0, 0, 0)] 261 | 262 | for l in range(0, len(lines)): 263 | parsedLine = ParsedLine.fromGcodeLine(attributeParser, lines[l], l+1, parsedGcode[-1].attributes, log=log) 264 | if parsedLine.shouldOverwrite(parsedGcode[-1].attributes): 265 | parsedGcode[-1] = parsedLine 266 | else: 267 | parsedGcode.append(parsedLine) 268 | 269 | # remove trailing command that does not write anything 270 | if len(parsedGcode) > 0 and parsedGcode[-1][AttrType.pen] == 0: 271 | parsedGcode = parsedGcode[:-1] 272 | 273 | return parsedGcode 274 | 275 | 276 | def parseArgs(namespace): 277 | argParser = argparse.ArgumentParser(fromfile_prefix_chars="@", 278 | description="Parse the gcode provided on stdin and apply transformations") 279 | ioGroup = argParser.add_argument_group("Input/output options") 280 | ioGroup.add_argument("-i", "--input", type=argparse.FileType('r'), default="-", metavar="FILE", 281 | help="File from which to read the gcode to parse") 282 | ioGroup.add_argument("-o", "--output", type=argparse.FileType('w'), required=False, metavar="FILE", 283 | help="File in which to save the generated gcode") 284 | ioGroup.add_argument("-b", "--binary-output", type=argparse.FileType('wb'), required=False, metavar="FILE", 285 | help="File in which to save the binary data ready to be fed to the plotter") 286 | ioGroup.add_argument("-l", "--log", type=argparse.FileType('w'), required=False, metavar="FILE", 287 | help="File in which to save logs, comments and warnings") 288 | 289 | parseGroup = argParser.add_argument_group("Gcode parsing options (detected automatically if not provided)") 290 | parseGroup.add_argument("-g", "--use-g", action="store_true", 291 | help="Consider `G0` as pen up and `G1` as pen down") 292 | parseGroup.add_argument("--feed-visible-below", type=float, metavar="VALUE", 293 | help="Consider `F` (feed) commands with a value above the provided as pen down, otherwise as pen up") 294 | parseGroup.add_argument("--speed-visible-below", type=float, metavar="VALUE", 295 | help="Consider `S` (speed) commands with a value above the provided as pen down, otherwise as pen up") 296 | 297 | genGroup = argParser.add_argument_group("Gcode generation options") 298 | genGroup.add_argument("--end-home", action="store_true", 299 | help="Add a trailing instruction to move to (0,0) instead of just taking the pen up") 300 | genGroup.add_argument("-s", "--size", type=str, default="1.0x1.0", metavar="XxY", 301 | help="The size of the print area in millimeters (e.g. 192.7x210.3)") 302 | genGroup.add_argument("-d", "--dilation", type=float, default=1.0, metavar="FACTOR", 303 | help="Dilation factor to apply (useful to convert mm to steps)") 304 | 305 | argParser.parse_args(namespace=namespace) 306 | 307 | 308 | if namespace.output is None and namespace.binary_output is None: 309 | argParser.error("at least one of --output, --binary-output should be provided") 310 | 311 | try: 312 | size = namespace.size.split("x") 313 | namespace.xSize = float(size[0]) 314 | namespace.ySize = float(size[1]) 315 | except: 316 | argParser.error(f"invalid formatting for --size: {namespace.size}") 317 | 318 | namespace.auto = (namespace.use_g == False and 319 | namespace.feed_visible_below is None and 320 | namespace.speed_visible_below is None) 321 | 322 | def main(): 323 | class Args: pass 324 | parseArgs(Args) 325 | 326 | def log(*args, **kwargs): 327 | if Args.log is not None: 328 | print(*args, **kwargs, file=Args.log) 329 | 330 | data = Args.input.read() 331 | 332 | if Args.auto: 333 | Args.use_g, Args.feed_visible_below, Args.speed_visible_below = \ 334 | detectParsingMode(data, log=log) 335 | 336 | parsedGcode = parseGcode(data, log=log, 337 | useG=Args.use_g, 338 | feedVisibleBelow=Args.feed_visible_below, 339 | speedVisibleBelow=Args.speed_visible_below) 340 | 341 | parsedGcode = translateToFirstQuarter(parsedGcode, log=log) 342 | parsedGcode = addEnd(parsedGcode, Args.end_home, log=log) 343 | parsedGcode = resize(parsedGcode, Args.xSize, Args.ySize, Args.dilation, log=log) 344 | 345 | if Args.output is not None: 346 | Args.output.write(toGcode(parsedGcode)) 347 | if Args.binary_output is not None: 348 | Args.binary_output.write(toBinaryData(parsedGcode)) 349 | 350 | if __name__ == '__main__': 351 | main() -------------------------------------------------------------------------------- /plotter/plotter.ino: -------------------------------------------------------------------------------- 1 | #include 2 | constexpr int tempo = 4, tempoServo = 50, 3 | pennaSu = 60, pennaGiu = 43, pennaSuAlta = 150; 4 | 5 | constexpr unsigned char move = 'm', write = 'w', end = 'a'; 6 | 7 | struct Stepper { 8 | static constexpr bool avanti = false, indietro = true; 9 | 10 | int8_t en[4]; 11 | int8_t currentStep; 12 | bool direzione; 13 | 14 | Stepper(int8_t en1, int8_t en2, int8_t en3, int8_t en4) : 15 | en{en1, en2, en3, en4}, currentStep{0}, direzione{avanti} { 16 | pinMode(en[0], OUTPUT); 17 | pinMode(en[1], OUTPUT); 18 | pinMode(en[2], OUTPUT); 19 | pinMode(en[3], OUTPUT); 20 | } 21 | 22 | void step() { 23 | ++currentStep; 24 | if (currentStep == 4) { 25 | currentStep = 0; 26 | } 27 | 28 | if (direzione == avanti) { 29 | digitalWrite(en[0], currentStep == 0); 30 | digitalWrite(en[1], currentStep == 1); 31 | digitalWrite(en[2], currentStep == 2); 32 | digitalWrite(en[3], currentStep == 3); 33 | } else /* indietro */ { 34 | digitalWrite(en[0], currentStep == 3); 35 | digitalWrite(en[1], currentStep == 2); 36 | digitalWrite(en[2], currentStep == 1); 37 | digitalWrite(en[3], currentStep == 0); 38 | } 39 | } 40 | }; 41 | 42 | unsigned char readByte() { 43 | while (!Serial.available()) {} 44 | return Serial.read(); 45 | } 46 | 47 | void stepBoth(Stepper& a, Stepper& b, int16_t passi) { 48 | for(int16_t p = 0; p < passi; ++p) { 49 | a.step(); 50 | b.step(); 51 | delay(tempo); 52 | } 53 | } 54 | 55 | void setup() { 56 | Serial.begin(9600); 57 | Serial.println("Setup"); 58 | 59 | Servo servo; 60 | servo.attach(42); 61 | Stepper x1{31, 33, 35, 37}, x2{30, 32, 34, 36}, 62 | y1{47, 49, 51, 53}, y2{8, 9, 10, 11}; 63 | 64 | auto step = [&](int16_t passix, int16_t passiy) { 65 | if (passix < 0) { 66 | x1.direzione = x2.direzione = Stepper::indietro; 67 | passix = -passix; 68 | } else { 69 | x1.direzione = x2.direzione = Stepper::avanti; 70 | } 71 | 72 | if (passiy < 0) { 73 | y1.direzione = y2.direzione = Stepper::indietro; 74 | passiy = -passiy; 75 | } else { 76 | y1.direzione = y2.direzione = Stepper::avanti; 77 | } 78 | 79 | 80 | if (passix == 0) { 81 | stepBoth(y1, y2, passiy); 82 | 83 | } else if (passiy == 0) { 84 | stepBoth(x1, x2, passix); 85 | 86 | } else if (passix < passiy) { 87 | int16_t yFatti = 0; 88 | float yTot = 0.0f; 89 | float passiYOgniX = static_cast(passiy) / static_cast(passix); // TODO is this ok? 90 | 91 | for(int16_t px = 0; px < passix; ++px) { 92 | yTot += passiYOgniX; 93 | int16_t yDaFare = yTot - yFatti; 94 | yFatti += yDaFare; 95 | 96 | x1.step(); 97 | x2.step(); 98 | delay(tempo); 99 | stepBoth(y1, y2, yDaFare); 100 | } 101 | 102 | } else if (passix == passiy) { 103 | for(int16_t p = 0; p < passix; ++p) { 104 | x1.step(); 105 | x2.step(); 106 | y1.step(); 107 | y2.step(); 108 | delay(tempo); 109 | } 110 | 111 | } else /* passix > passiy */{ 112 | int16_t xFatti = 0; 113 | float xTot = 0.0f; 114 | float passiXOgniY = static_cast(passix) / static_cast(passiy); // TODO is this ok? 115 | 116 | for(int16_t py = 0; py < passiy; ++py) { 117 | xTot += passiXOgniY; 118 | int16_t xDaFare = xTot - xFatti; 119 | xFatti += xDaFare; 120 | 121 | y1.step(); 122 | y2.step(); 123 | delay(tempo); 124 | stepBoth(x1, x2, xDaFare); 125 | } 126 | } 127 | }; 128 | 129 | bool penna = false; 130 | servo.write(pennaSuAlta); 131 | while (!Serial.available()) {} 132 | servo.write(pennaSu); 133 | delay(100); 134 | while (1) { 135 | char mode = readByte(); 136 | if (mode == write || mode == move) { 137 | unsigned char a, b, c, d; 138 | a=readByte(); 139 | b=readByte(); 140 | c=readByte(); 141 | d=readByte(); 142 | int16_t x = (a << 8) | b; 143 | int16_t y = (c << 8) | d; 144 | 145 | if ((mode == write) != penna) { 146 | // the pen mode changed 147 | penna = !penna; 148 | servo.write(penna ? pennaGiu : pennaSu); 149 | delay(tempoServo); 150 | } 151 | 152 | Serial.print(mode == write ? "Write " : "Move "); 153 | Serial.print("X:"); 154 | Serial.print(x); 155 | Serial.print(" Y:"); 156 | Serial.println(y); 157 | 158 | step(-x, y); 159 | } else if (mode == end) { 160 | servo.write(pennaSuAlta); 161 | Serial.println("Completed!"); 162 | } 163 | } 164 | } 165 | 166 | void loop() {} 167 | -------------------------------------------------------------------------------- /plotter_new/plotter_new.ino: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | constexpr unsigned char MOVE = 'm', WRITE = 'w', END = 'a'; 6 | constexpr int STEPS = 200, SPEED = 50; 7 | constexpr int PEN_WRITING_DEG = 10, PEN_MOVING_DEG = 50, PEN_UP_DEG = 130; 8 | constexpr int PEN_DELAY_MS = 100; 9 | constexpr int LCD_LINE_COUNT = 2; 10 | constexpr int STEPS_DIAGONAL_AT_ONCE = 20; 11 | 12 | void swap(int16_t& a, int16_t& b) { 13 | int16_t tmp = a; 14 | a = b; 15 | b = tmp; 16 | } 17 | 18 | unsigned char readByte() { 19 | while (!Serial.available()) {} 20 | return Serial.read(); 21 | } 22 | 23 | int16_t sign(int16_t n) { 24 | return n >= 0 ? 1 : -1; 25 | } 26 | 27 | void setup() { 28 | Serial.begin(9600); 29 | Serial.println("Setup"); 30 | 31 | Stepper aStepper(STEPS, 2, 3); 32 | Stepper bStepper(STEPS, 4, 5); 33 | aStepper.setSpeed(SPEED); 34 | bStepper.setSpeed(SPEED); 35 | 36 | Servo penServo; 37 | penServo.attach(6); 38 | 39 | LiquidCrystal lcd(8, 9, 10, 11, 12, 13); 40 | lcd.begin(16, LCD_LINE_COUNT); 41 | int lcdLine = 0; 42 | 43 | 44 | auto stepOld = [&aStepper, &bStepper](int16_t x, int16_t y) { 45 | int16_t aSteps = abs(x + y), bSteps = abs(x - y); 46 | int16_t aSign = sign(x + y), bSign = sign(x - y); 47 | 48 | if (aSteps == 0) { 49 | bStepper.step(bSteps * bSign); 50 | 51 | } else if (bSteps == 0) { 52 | aStepper.step(aSteps * aSign); 53 | 54 | } else if (aSteps < bSteps) { 55 | int16_t bDone = 0; 56 | float bTot = 0.0f; 57 | float stepsBEveryA = static_cast(bSteps) / static_cast(aSteps); 58 | 59 | for(int16_t aStep = 0; aStep < aSteps; ++aStep) { 60 | bTot += stepsBEveryA; 61 | int16_t bToDo = bTot - bDone; 62 | bDone += bToDo; 63 | 64 | aStepper.step(aSign); 65 | bStepper.step(bToDo * bSign); 66 | } 67 | 68 | } else if (aSteps == bSteps) { 69 | for(int16_t s = 0; s < aSteps; ++s) { 70 | aStepper.step(aSign); 71 | bStepper.step(bSign); 72 | } 73 | 74 | } else /* aSteps > bSteps */ { 75 | int16_t aDone = 0; 76 | float aTot = 0.0f; 77 | float stepsAEveryB = static_cast(aSteps) / static_cast(bSteps); 78 | 79 | for(int16_t bStep = 0; bStep < bSteps; ++bStep) { 80 | aTot += stepsAEveryB; 81 | int16_t aToDo = aTot - aDone; 82 | aDone += aToDo; 83 | 84 | bStepper.step(bSign); 85 | aStepper.step(aToDo * aSign); 86 | } 87 | } 88 | }; 89 | 90 | auto stepSwap = [&stepOld](bool sw, int16_t x, int16_t y) { 91 | stepOld(sw ? y : x, sw ? x : y); 92 | }; 93 | 94 | auto step2 = [&stepSwap, &stepOld](int16_t x, int16_t y) { 95 | if (x == 0 || y == 0) { 96 | stepOld(x, y); 97 | return; 98 | } 99 | 100 | bool sw = false; 101 | if (abs(y) > abs(x)) { 102 | sw = true; 103 | swap(x, y); 104 | } 105 | 106 | int16_t xSign = sign(x), ySign = sign(y); 107 | int16_t xAbs = abs(x), yAbs = abs(y); 108 | 109 | int xDone = 0; 110 | int16_t yDone = 0; 111 | float yTot = 0.0f; 112 | float stepsYEveryX = ((float) yAbs) / (xAbs); 113 | 114 | while (xDone + STEPS_DIAGONAL_AT_ONCE < xAbs) { 115 | yTot += stepsYEveryX * STEPS_DIAGONAL_AT_ONCE; 116 | int16_t yToDo = yTot - yDone; 117 | 118 | yDone += yToDo; 119 | xDone += STEPS_DIAGONAL_AT_ONCE; 120 | 121 | stepSwap(sw, STEPS_DIAGONAL_AT_ONCE * xSign, 0); 122 | stepSwap(sw, 0, yToDo * ySign); 123 | } 124 | stepSwap(sw, (xAbs - xDone) * xSign, 0); 125 | stepSwap(sw, 0, (yAbs - yDone) * ySign); 126 | }; 127 | 128 | auto setPenDegrees = [&penServo](const int deg) { 129 | penServo.write(deg); 130 | delay(PEN_DELAY_MS); 131 | }; 132 | 133 | auto logMsg = [&lcd](const char* msg) { 134 | Serial.print(msg); 135 | lcd.print(msg); 136 | }; 137 | 138 | auto loglnMsg = [&lcd, &lcdLine](const char* msg) { 139 | Serial.println(msg); 140 | lcd.print(msg); 141 | 142 | ++lcdLine; 143 | lcdLine %= LCD_LINE_COUNT; 144 | lcd.setCursor(0, lcdLine); 145 | lcd.print(" "); 146 | lcd.setCursor(0, lcdLine); 147 | }; 148 | 149 | auto logNum = [&lcd](const int msg) { 150 | Serial.print(msg); 151 | lcd.print(msg); 152 | }; 153 | 154 | 155 | bool penIsWriting = false; 156 | setPenDegrees(PEN_UP_DEG); 157 | while (1) { 158 | char mode = readByte(); 159 | if (mode == WRITE || mode == MOVE) { 160 | unsigned char a, b, c, d; 161 | a = readByte(); 162 | b = readByte(); 163 | c = readByte(); 164 | d = readByte(); 165 | int16_t x = (a << 8) | b; 166 | int16_t y = (c << 8) | d; 167 | 168 | bool newPenIsWriting = (mode == WRITE); 169 | if (newPenIsWriting != penIsWriting) { 170 | // the pen mode changed, move it accordingly 171 | penIsWriting = newPenIsWriting; 172 | setPenDegrees(penIsWriting ? PEN_WRITING_DEG : PEN_MOVING_DEG); 173 | } 174 | 175 | logMsg(penIsWriting ? "w x=" : "m x="); 176 | logNum(x); 177 | logMsg(" y="); 178 | logNum(y); 179 | 180 | step2(-x, y); 181 | 182 | loglnMsg("*"); 183 | 184 | } else if (mode == END) { 185 | penIsWriting = false; 186 | setPenDegrees(PEN_UP_DEG); 187 | loglnMsg("Completed!"); 188 | } 189 | /* 190 | switch(readByte()) { 191 | case 'w': 192 | step2(100, 0); 193 | break; 194 | case 'a': 195 | step2(0, -100); 196 | break; 197 | case 's': 198 | step2(-100, 0); 199 | break; 200 | case 'd': 201 | step2(0, 100); 202 | break; 203 | case 'q': 204 | step2(100, -100); 205 | break; 206 | case 'e': 207 | step2(100, 100); 208 | break; 209 | case 'x': 210 | step2(-100, 100); 211 | break; 212 | case 'z': 213 | step2(-100, -100); 214 | break; 215 | } 216 | Serial.println("\n");*/ 217 | } 218 | } 219 | 220 | void loop() {} 221 | -------------------------------------------------------------------------------- /print.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | #pylint: disable=no-member 3 | 4 | import argparse 5 | from enum import Enum 6 | import text_to_gcode.text_to_gcode as text_to_gcode 7 | import gcode_parser 8 | import sender 9 | 10 | 11 | def textToGcode(text): 12 | letters = text_to_gcode.readLetters(Args.gcode_directory) 13 | return text_to_gcode.textToGcode(letters, text, Args.line_length, Args.line_spacing, Args.padding) 14 | 15 | def parseGcode(gcodeData): 16 | if Args.auto: 17 | Args.use_g, Args.feed_visible_below, Args.speed_visible_below = \ 18 | gcode_parser.detectParsingMode(gcodeData, log=log) 19 | 20 | parsedGcode = gcode_parser.parseGcode(gcodeData, log=log, 21 | useG=Args.use_g, 22 | feedVisibleBelow=Args.feed_visible_below, 23 | speedVisibleBelow=Args.speed_visible_below) 24 | 25 | parsedGcode = gcode_parser.translateToFirstQuarter(parsedGcode, log=log) 26 | parsedGcode = gcode_parser.addEnd(parsedGcode, Args.end_home, log=log) 27 | parsedGcode = gcode_parser.resize(parsedGcode, Args.xSize, Args.ySize, Args.dilation, log=log) 28 | 29 | return parsedGcode 30 | 31 | 32 | def parseArgs(namespace): 33 | argParser = argparse.ArgumentParser(fromfile_prefix_chars="@", 34 | description="Print something with the connected plotter") 35 | subparsers = argParser.add_subparsers(dest="subcommand", 36 | description="Format subcommands") 37 | 38 | ioGroup = argParser.add_argument_group("Output options") 39 | ioGroup.add_argument("-o", "--output", type=argparse.FileType('w'), required=False, metavar="FILE", 40 | help="File in which to save the generated gcode (will be ignored if using binary subcommand)") 41 | ioGroup.add_argument("-b", "--binary-output", type=argparse.FileType('wb'), required=False, metavar="FILE", 42 | help="File in which to save the binary data ready to be fed to the plotter") 43 | ioGroup.add_argument("-l", "--log", type=argparse.FileType('w'), required=False, metavar="FILE", 44 | help="File in which to save logs, comments and warnings") 45 | 46 | genGroup = argParser.add_argument_group("Gcode generation options") 47 | genGroup.add_argument("--end-home", action="store_true", 48 | help="Add a trailing instruction to move to (0,0) instead of just taking the pen up") 49 | genGroup.add_argument("-s", "--size", type=str, default="1.0x1.0", metavar="XxY", 50 | help="The size of the print area in millimeters (e.g. 192.7x210.3)") 51 | genGroup.add_argument("-d", "--dilation", type=float, default=1.0, metavar="FACTOR", 52 | help="Dilation factor to apply (useful to convert mm to steps)") 53 | 54 | connGroup = argParser.add_argument_group("Plotter connectivity options") 55 | connGroup.add_argument("--simulate", action="store_true", 56 | help="Simulate sending data to the plotter without really opening a connection. Useful with logging enabled to debug the commands sent.") 57 | connGroup.add_argument("--port", "--serial-port", type=str, metavar="PORT", dest="serial_port", 58 | help="The serial port the plotter is connected to (required unless there is --simulate)") 59 | connGroup.add_argument("--baud", "--baud-rate", type=int, metavar="RATE", dest="baud_rate", 60 | help="The baud rate to use for the connection with the plotter. It has to be equal to the plotter baud rate. (required unless there is --simulate)") 61 | 62 | 63 | binParser = subparsers.add_parser("binary", help="Send binary files directly to the plotter") 64 | bpDataGroup = binParser.add_argument_group("Data options") 65 | bpDataGroup.add_argument("-i", "--input", type=argparse.FileType('rb'), required=True, metavar="FILE", 66 | help="Binary file from which to read the raw data to send to the plotter") 67 | 68 | 69 | gcodeParser = subparsers.add_parser("gcode", help="Print gcode with the plotter") 70 | gpDataGroup = gcodeParser.add_argument_group("Data options") 71 | gpDataGroup.add_argument("-i", "--input", type=argparse.FileType('r'), default="-", metavar="FILE", 72 | help="File from which to read the gcode to print") 73 | 74 | gpParseGroup = gcodeParser.add_argument_group("Gcode parsing options (detected automatically if not provided)") 75 | gpParseGroup.add_argument("--use-g", action="store_true", 76 | help="Consider `G0` as pen up and `G1` as pen down") 77 | gpParseGroup.add_argument("--feed-visible-below", type=float, metavar="VALUE", 78 | help="Consider `F` (feed) commands with a value above the provided as pen down, otherwise as pen up") 79 | gpParseGroup.add_argument("--speed-visible-below", type=float, metavar="VALUE", 80 | help="Consider `S` (speed) commands with a value above the provided as pen down, otherwise as pen up") 81 | 82 | 83 | textParser = subparsers.add_parser("text", help="Print text with the plotter") 84 | tpDataGroup = textParser.add_argument_group("Data options") 85 | tpDataGroup.add_argument("-i", "--input", type=argparse.FileType('r'), default="-", metavar="FILE", 86 | help="File from which to read the characters to print") 87 | tpDataGroup.add_argument("--gcode-directory", type=str, default="./text_to_gcode/ascii_gcode/", metavar="DIR", 88 | help="Directory containing the gcode information for all used characters") 89 | 90 | tpTextGroup = textParser.add_argument_group("Text options") 91 | tpTextGroup.add_argument("--line-length", type=float, required=True, 92 | help="Maximum length of a line") 93 | tpTextGroup.add_argument("--line-spacing", type=float, default=8.0, 94 | help="Distance between two subsequent lines") 95 | tpTextGroup.add_argument("--padding", type=float, default=1.5, 96 | help="Empty space between characters") 97 | 98 | argParser.parse_args(namespace=namespace) 99 | 100 | 101 | try: 102 | size = namespace.size.split("x") 103 | namespace.xSize = float(size[0]) 104 | namespace.ySize = float(size[1]) 105 | except: 106 | argParser.error(f"invalid formatting for --size: {namespace.size}") 107 | 108 | if not namespace.simulate: 109 | if namespace.serial_port is None: 110 | argParser.error(f"--serial-port is required unless there is --simulate") 111 | if namespace.baud_rate is None: 112 | argParser.error(f"--baud-rate is required unless there is --simulate") 113 | 114 | # check that a subcommand was selected (required=True is buggy) 115 | if namespace.subcommand is None: 116 | argParser.error(f"exactly one subcommand from the following is required: binary, gcode, text") 117 | 118 | if Args.subcommand == "gcode": 119 | namespace.auto = (namespace.use_g == False and 120 | namespace.feed_visible_below is None and 121 | namespace.speed_visible_below is None) 122 | 123 | class Args: 124 | pass 125 | 126 | def log(*args, **kwargs): 127 | if Args.log is not None: 128 | kwargs["flush"] = True 129 | print(*args, **kwargs, file=Args.log) 130 | 131 | def main(): 132 | parseArgs(Args) 133 | 134 | binaryData = b"" 135 | if Args.subcommand == "binary": 136 | binaryData = Args.input.read() 137 | else: 138 | gcodeData = "" 139 | if Args.subcommand == "gcode": 140 | gcodeData = Args.input.read() 141 | elif Args.subcommand == "text": 142 | gcodeData = textToGcode(Args.input.read()) 143 | 144 | # settings for gcode parser 145 | Args.auto = False 146 | Args.use_g = True 147 | Args.feed_visible_below = None 148 | Args.speed_visible_below = None 149 | else: 150 | raise AssertionError() 151 | 152 | parsedGcode = parseGcode(gcodeData) 153 | binaryData = gcode_parser.toBinaryData(parsedGcode) 154 | if Args.output is not None: 155 | Args.output.write(gcode_parser.toGcode(parsedGcode)) 156 | 157 | if Args.binary_output is not None: 158 | Args.binary_output.write(binaryData) 159 | 160 | 161 | sender.sendData(binaryData, Args.serial_port, Args.baud_rate, Args.simulate, log=log) 162 | 163 | 164 | if __name__ == "__main__": 165 | main() 166 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyserial 2 | -------------------------------------------------------------------------------- /sender.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | #pylint: disable=no-member 3 | 4 | import argparse 5 | import serial 6 | 7 | endByte = b"a" 8 | serialLogLabel = "[info from serial]" 9 | 10 | 11 | def _log_nothing(*args, **kwargs): 12 | pass 13 | 14 | def sendData(data, serialPort, baudRate, simulate=False, log=_log_nothing): 15 | if simulate: 16 | log(serialLogLabel, "Setup") 17 | else: 18 | ser = serial.Serial(serialPort, baudRate) 19 | log(serialLogLabel, ser.readline()[:-2].decode("utf8")) 20 | 21 | try: 22 | i = 0 23 | while i < len(data): 24 | mode = data[i:i+1] 25 | x = int.from_bytes(data[i+1:i+3], byteorder="big", signed=True) 26 | y = int.from_bytes(data[i+3:i+5], byteorder="big", signed=True) 27 | log(f"[info] Sent: {repr(mode)[2:-1]:<2} x={x:>5} y={y:>5}") 28 | 29 | if not simulate: 30 | ser.write(data[i:i+5]) 31 | readData = ser.readline() 32 | log(serialLogLabel, readData[:-2].decode("utf8")) 33 | 34 | i+=5 35 | 36 | except KeyboardInterrupt: 37 | log("[info] Sending interrupted by user") 38 | finally: 39 | if simulate: 40 | log("[info] Completed!") 41 | else: 42 | ser.write(endByte) 43 | readData = ser.readline()[:-2].decode("utf8") 44 | log(serialLogLabel, readData) 45 | if (readData != "Completed!"): 46 | log(serialLogLabel, readData) 47 | readData = ser.readline()[:-2].decode("utf8") 48 | 49 | 50 | def parseArgs(namespace): 51 | argParser = argparse.ArgumentParser(fromfile_prefix_chars="@", 52 | description="Send binary data to a plotter using a serial connection") 53 | 54 | ioGroup = argParser.add_argument_group("Input/output options") 55 | ioGroup.add_argument("-i", "--input", type=argparse.FileType('rb'), required=True, metavar="FILE", 56 | help="Binary file from which to read the raw data to send to the plotter") 57 | ioGroup.add_argument("-l", "--log", type=argparse.FileType('w'), required=False, metavar="FILE", 58 | help="File in which to save logs, comments and warnings") 59 | 60 | connGroup = argParser.add_argument_group("Plotter connectivity options") 61 | connGroup.add_argument("--simulate", action="store_true", 62 | help="Simulate sending data to the plotter without really opening a connection. Useful with logging enabled to debug the commands sent.") 63 | connGroup.add_argument("--port", "--serial-port", type=str, metavar="PORT", dest="serial_port", 64 | help="The serial port the plotter is connected to (required unless there is --simulate)") 65 | connGroup.add_argument("--baud", "--baud-rate", type=int, metavar="RATE", dest="baud_rate", 66 | help="The baud rate to use for the connection with the plotter. It has to be equal to the plotter baud rate. (required unless there is --simulate)") 67 | 68 | argParser.parse_args(namespace=namespace) 69 | 70 | 71 | if not namespace.simulate: 72 | if namespace.serial_port is None: 73 | argParser.error(f"--serial-port is required unless there is --simulate") 74 | if namespace.baud_rate is None: 75 | argParser.error(f"--baud-rate is required unless there is --simulate") 76 | 77 | def main(): 78 | class Args: pass 79 | parseArgs(Args) 80 | 81 | def log(*args, **kwargs): 82 | if Args.log is not None: 83 | kwargs["flush"] = True 84 | print(*args, **kwargs, file=Args.log) 85 | 86 | data = Args.input.read() 87 | sendData(data, Args.serial_port, Args.baud_rate, Args.simulate, log=log) 88 | 89 | 90 | if __name__ == "__main__": 91 | main() --------------------------------------------------------------------------------