commit 111eab6cf029b1e55e3ee105d6cadbf07cbdf2f9 Author: Ruben van de Ven Date: Mon Nov 22 20:54:04 2021 +0100 Rough draft for drawing diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2009c7d --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.2 diff --git a/files/.gitignore b/files/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/files/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/log/.gitignore b/log/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/log/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d364da2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "svganim" +version = "0.1.0" +description = "Draw an animated vector image" +authors = ["Ruben van de Ven "] + +[tool.poetry.dependencies] +python = "^3.9" +tornado = "^6.1" +coloredlogs = "^15.0.1" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/webserver.py b/webserver.py new file mode 100644 index 0000000..8a43ea5 --- /dev/null +++ b/webserver.py @@ -0,0 +1,264 @@ +import json +import logging +import os +import tornado.ioloop +import tornado.web +import tornado.websocket +from urllib.parse import urlparse +import uuid +import datetime +import html +import argparse +import coloredlogs +import glob +import csv + + + + +class DateTimeEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, datetime.datetime): + return o.isoformat(timespec='milliseconds') + + return super().default(self, o) + +class StaticFileWithHeaderHandler(tornado.web.StaticFileHandler): + def set_extra_headers(self, path): + """For subclass to add extra headers to the response""" + if path[-5:] == '.html': + self.set_header("Access-Control-Allow-Origin", "*") + if path[-4:] == '.svg': + self.set_header("Content-Type", "image/svg+xml") + + +class WebSocketHandler(tornado.websocket.WebSocketHandler): + """ + Websocket from the workers + """ + CORS_ORIGINS = ['localhost'] + connections = set() + + def initialize(self, config): + self.config = config + self.strokes = [] + self.hasWritten = False + self.dimensions = [None, None] + + def check_origin(self, origin): + parsed_origin = urlparse(origin) + # parsed_origin.netloc.lower() gives localhost:3333 + valid = any([parsed_origin.hostname.endswith(origin) for origin in self.CORS_ORIGINS]) + return valid + + # the client connected + def open(self, p = None): + self.__class__.connections.add(self) + self.strokes = [] + self.prefix = datetime.datetime.now().strftime('%Y-%m-%d-') + self.filename = self.prefix + str(self.check_filenr()) + '-' + uuid.uuid4().hex[:6] + print(self.filename) + self.write_message(json.dumps({ + "filename": self.filename + })) + + def check_filenr(self): + files = glob.glob(os.path.join(self.config.storage, self.prefix +'*')) + return len(files) + 1 + + + # the client sent the message + def on_message(self, message): + logger.info(f"recieve: {message}") + + try: + msg = json.loads(message) + if msg['action'] == 'stroke': + print('stroke!') + self.strokes.append([msg['color'], msg['points']]) + + with open(os.path.join(self.config.storage,self.filename +'.csv'), 'a') as fp: + writer = csv.writer(fp, delimiter=';') + if not self.hasWritten: + #metadata to first row, but only on demand + writer.writerow([datetime.datetime.now().strftime("%Y-%m-%d %T"), self.dimensions[0], self.dimensions[1]]) + self.hasWritten = True + + # first column is color, rest is points + writer.writerow([msg['color']] +[coordinate for point in msg['points'] for coordinate in point[:4]]) + + + elif msg['action'] == 'dimensions': + self.dimensions = [int(msg['width']), int(msg['height'])] + logger.info(f"{self.dimensions=}") + + + else: + # self.send({'alert': 'Unknown request: {}'.format(message)}) + logger.warn('Unknown request: {}'.format(message)) + + except Exception as e: + # self.send({'alert': 'Invalid request: {}'.format(e)}) + logger.exception(e) + + # client disconnected + def on_close(self): + self.__class__.rmConnection(self) + + logger.info(f"Client disconnected: {self.request.remote_ip}") + + + @classmethod + def rmConnection(cls, client): + if client not in cls.connections: + return + cls.connections.remove(client) + + @classmethod + def hasConnection(cls, client): + return client in cls.connections + + +class AnimationHandler(tornado.web.RequestHandler): + def initialize(self, config): + self.config = config + + def get(self, filename): + self.set_header("Content-Type", "application/json") + # filename = self.get_argument("file", None) + if filename == '': + names = [f"/files/{name[:-4]}" for name in os.listdir(self.config.storage) if name not in ['.gitignore']] + self.write(json.dumps(names)) + else: + path = os.path.join(self.config.storage,os.path.basename(filename)+".csv") + drawing = { + "shape": [] + } + with open(path, 'r') as fp: + strokes = csv.reader(fp,delimiter=';') + for i, stroke in enumerate(strokes): + if i == 0: + # metadata on first line + drawing['time'] = stroke[0] + drawing['dimensions'] = [stroke[1], stroke[2]] + continue + color = stroke.pop(0) + points = [] + for i in range(int(len(stroke) / 4)): + p = stroke[i*4:i*4+4] + points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])]) + drawing['shape'].append({ + 'color': color, + 'points': points + }) + self.write(json.dumps(drawing)) + +def strokes2D(strokes): + # strokes to a d attribute for a path + d = ""; + last_stroke = None; + cmd = ""; + for stroke in strokes: + if not last_stroke: + d += f"M{stroke[0]},{stroke[1]} " + cmd = 'M' + else: + if last_stroke[2] == 1: + d += " m" + cmd = 'm' + elif cmd != 'l': + d+=' l ' + cmd = 'l' + + rel_stroke = [stroke[0] - last_stroke[0], stroke[1] - last_stroke[1]]; + d += f"{rel_stroke[0]},{rel_stroke[1]} " + last_stroke = stroke; + return d; + +class Server: + """ + Server for HIT -> plotter events + As well as for the Status interface + """ + loop = None + + def __init__(self, config, logger): + self.config = config + self.logger = logger + + #self.config['server']['port'] + self.web_root = os.path.join('www') + + + def start(self): + application = tornado.web.Application([ + (r"/ws(.*)", WebSocketHandler, { + 'config': self.config, + }), + + (r"/files/(.*)", AnimationHandler, + {'config': self.config}), + (r"/(.*)", StaticFileWithHeaderHandler, + {"path": self.web_root}), + ], debug=True, autoreload=True) + application.listen(self.config.port) + tornado.ioloop.IOLoop.current().start() + + +if __name__ == "__main__": + argParser = argparse.ArgumentParser( + description='Start up the vector animation server') + # argParser.add_argument( + # '--config', + # '-c', + # required=True, + # type=str, + # help='The yaml config file to load' + # ) + argParser.add_argument( + '--port', + type=int, + default=7890, + help='Port' + ) + argParser.add_argument( + '--storage', + type=str, + default='files', + help='directory name for output files' + ) + argParser.add_argument( + '--verbose', + '-v', + action='count', default=0 + ) + + args = argParser.parse_args() + + loglevel = logging.NOTSET if args.verbose > 1 else logging.DEBUG if args.verbose > 0 else logging.INFO + + coloredlogs.install( + level=loglevel, +# default: "%(asctime)s %(hostname)s %(name)s[%(process)d] %(levelname)s %(message)s" + fmt="%(asctime)s %(hostname)s %(name)s[%(process)d,%(threadName)s] %(levelname)s %(message)s" + ) + + # File logging + formatter = logging.Formatter(fmt='%(asctime)s %(module)s:%(lineno)d %(levelname)8s | %(message)s', + datefmt='%Y/%m/%d %H:%M:%S') # %I:%M:%S %p AM|PM format + logFileHandler = logging.handlers.RotatingFileHandler( + 'log/draw_log.log', + maxBytes=1024*512, + backupCount=5 + ) + logFileHandler.setFormatter(formatter) + + logger = logging.getLogger("sorteerhoed") + logger.addHandler( + logFileHandler + ) + logger.info("Start server") + + server = Server(args, logger) + server.start() + diff --git a/www/cursor.png b/www/cursor.png new file mode 100644 index 0000000..ad600de Binary files /dev/null and b/www/cursor.png differ diff --git a/www/draw.html b/www/draw.html new file mode 100644 index 0000000..c16773b --- /dev/null +++ b/www/draw.html @@ -0,0 +1,164 @@ + + + + + Draw a line animation + + + +
+
+ + + + + diff --git a/www/draw.js b/www/draw.js new file mode 100644 index 0000000..28b7a74 --- /dev/null +++ b/www/draw.js @@ -0,0 +1,244 @@ +class Canvas { + constructor(wrapperEl) { + this.allowDrawing = false; + this.url = window.location.origin.replace('http', 'ws') + '/ws?' + window.location.search.substring(1); + this.wrapperEl = wrapperEl; + this.wrapperEl.classList.add('closed'); + + this.svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.wrapperEl.appendChild(this.svgEl); + + this.toolboxEl = document.createElement('div'); + this.toolboxEl.classList.add('toolbox') + this.wrapperEl.appendChild(this.toolboxEl); + + this.filenameEl = document.createElement('div'); + this.filenameEl.classList.add('filename') + this.wrapperEl.appendChild(this.filenameEl); + + + this.colors = ["red", "blue", "green"]; + + this.resize(); + + window.addEventListener('resize', this.requestResize); + + + this.paths = []; + this.isDrawing = false; + this.hasMouseDown = false; + this.currentStrokeEl = null; + + this.startTime = null; + + document.body.addEventListener('mousemove', this.draw.bind(this)); + document.body.addEventListener('mouseup', this.penup.bind(this)); + this.svgEl.addEventListener('mousedown', this.startStroke.bind(this)); + + this.createToolbox(); + + this.setColor(this.colors[0]); + + + this.socket = new WebSocket(this.url); + this.socket.addEventListener('open', (e) => { + + this.socket.send(JSON.stringify({ + 'action': 'dimensions', + 'width': this.width, + 'height': this.height + })); + }) + this.socket.addEventListener('message', (e) => { + let msg = JSON.parse(e.data); + console.log('receive', msg); + if (msg.hasOwnProperty('filename')) { + console.log('filename', msg.filename); + this.setFilename(msg.filename); + this.openTheFloor() + } + }); + } + + openTheFloor() { + this.wrapperEl.classList.remove('closed'); + } + + setFilename(filename) { + this.filename = filename; + this.filenameEl.innerText = filename; + } + + createToolbox() { + const colorsEl = document.createElement('ul'); + colorsEl.classList.add('colors'); + for (let color of this.colors) { + const colorEl = document.createElement('li'); + colorEl.style.background = color; + colorEl.addEventListener('click', (e) => { + console.log('set color', color) + this.setColor(color); + + }) + + colorsEl.appendChild(colorEl); + } + this.toolboxEl.appendChild(colorsEl); + } + + setColor(color) { + this.currentColor = color; + const colorEls = this.toolboxEl.querySelectorAll('.colors li'); + for (let colorEl of colorEls) { + if (colorEl.style.backgroundColor == color) { + colorEl.classList.add('selected'); + } + else { + colorEl.classList.remove('selected'); + } + } + } + + resize() { + this.width = window.innerWidth; + this.height = window.innerHeight; + const viewBox = `0 0 ${this.width} ${this.height}`; + this.svgEl.setAttribute('viewBox', viewBox); + this.svgEl.setAttribute('width', this.width + 'mm'); + this.svgEl.setAttribute('height', this.height + 'mm'); + } + + requestResize() { + alert('Resize not implemented yet. Please reloade the page'); + } + + + getCoordinates(e) { + // convert event coordinates into relative positions on x & y axis + let box = this.svgEl.getBoundingClientRect(); + let x = (e.x - box['left']) / box['width']; + let y = (e.y - box['top']) / box['height']; + return { 'x': x, 'y': y }; + } + + isInsideBounds(pos) { + return !(pos['x'] < 0 || pos['y'] < 0 || pos['x'] > 1 || pos['y'] > 1); + } + + // isInsideDrawingBounds (pos) { + // if(pos['x'] > xPadding && pos['x'] < (xPadding+drawWidthFactor) && pos['y'] > yPadding && pos['y'] < yPadding+drawHeightFactor) { + // return true; + // } + // return false; + // } + + draw(e) { + let pos = this.getCoordinates(e); + if (this.hasMouseDown) + console.log(pos, e); + + // if(!isInsideBounds(pos)) { + // // outside of bounds + // return; + // } + + // if(!e.buttons || (this.isDrawing && !this.isInsideBounds(pos))){ + // this.endStroke(pos); + // } + if (!this.isDrawing && this.hasMouseDown && this.isInsideBounds(pos)) { + this.isDrawing = true; + } + + if (this.isDrawing) { + this.paths[this.paths.length - 1].points.push([pos['x'], pos['y'], 0, window.performance.now() - this.startTime]); + let d = this.strokes2D(this.paths[this.paths.length - 1].points); + this.currentStrokeEl.setAttribute('d', d); + } + + //console.log([pos['x'], pos['y']], isDrawing); + // socket.send(JSON.stringify({ + // 'action': 'move', + // 'direction': [pos['x'], pos['y']], + // 'mouse': isDrawing, + // })); + } + + startStroke(e) { + this.hasMouseDown = true; + console.log('start'); + + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.style.stroke = this.currentColor; + this.svgEl.appendChild(strokeEl); + this.currentStrokeEl = strokeEl; + this.paths.push({ + 'el': strokeEl, + 'color': this.currentColor, + 'points': [] + }); + + if (this.startTime === null) { + // initiate timer on first stroke + this.startTime = window.performance.now(); + } + } + + endStroke(pos) { + console.log(this.isDrawing) + if (!this.isDrawing) { + return; + } + console.log("send!") + this.isDrawing = false; + //document.body.removeEventListener('mousemove', draw); + + + if (this.paths[this.paths.length - 1].points.length > 0) { + // mark point as last of stroke + this.paths[this.paths.length - 1].points[this.paths[this.paths.length - 1].points.length - 1][2] = 1; + } + const stroke = this.paths[this.paths.length - 1]; + this.socket.send(JSON.stringify({ + 'action': 'stroke', + 'color': stroke.color, + 'points': stroke.points + })); + } + + penup(e) { + if (!this.hasMouseDown) { + return; + } + this.hasMouseDown = false; + + let pos = this.getCoordinates(e); + this.endStroke(pos); + } + + strokes2D(strokes) { + // strokes to a d attribute for a path + let d = ""; + let last_stroke = undefined; + let cmd = ""; + for (let stroke of strokes) { + if (!last_stroke) { + d += `M${stroke[0] * this.width},${stroke[1] * this.height} `; + cmd = 'M'; + } else { + if (last_stroke[2] == 1) { + d += " m"; + cmd = 'm'; + } else if (cmd != 'l') { + d += ' l '; + cmd = 'l'; + } + let rel_stroke = [stroke[0] - last_stroke[0], stroke[1] - last_stroke[1]]; + d += `${rel_stroke[0] * this.width},${rel_stroke[1] * this.height} `; + } + last_stroke = stroke; + + } + return d; + } + +} diff --git a/www/play.html b/www/play.html new file mode 100644 index 0000000..1ea23c6 --- /dev/null +++ b/www/play.html @@ -0,0 +1,74 @@ + + + + + Play a line animation + + + +
+
+ + + + diff --git a/www/play.js b/www/play.js new file mode 100644 index 0000000..acfd029 --- /dev/null +++ b/www/play.js @@ -0,0 +1,147 @@ + + +class Player { + constructor(wrapperEl) { + this.wrapperEl = wrapperEl; + this.svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.wrapperEl.appendChild(this.svgEl); + + this.resize(); + } + + playlist(url) { + const request = new Request(url, { + method: 'GET', + }); + + + fetch(request) + .then(response => response.json()) + .then(data => { + let playlist = this.wrapperEl.querySelector('.playlist'); + if(!playlist) { + playlist = document.createElement('nav'); + playlist.classList.add('playlist'); + this.wrapperEl.appendChild(playlist) + } + else{ + playlist.innerHTML = ""; + } + + const listEl = document.createElement("ul"); + for(let fileUrl of data) { + const liEl = document.createElement("li"); + liEl.innerText = fileUrl + liEl.addEventListener('click', (e) => { + this.play(fileUrl); + + }); + listEl.appendChild(liEl); + } + playlist.appendChild(listEl); + // do something with the data sent in the request + }); + } + + play(file) { + const request = new Request(file, { + method: 'GET', + }); + + + fetch(request) + .then(response => response.json()) + .then(data => { + this.playStrokes(data) + // do something with the data sent in the request + }); + + } + + playStrokes(drawing){ + this.strokes = drawing.shape; + this.currentPath = null; + this.dimensions = drawing.dimensions; + this.svgEl.setAttributeNS('http://www.w3.org/2000/svg', 'viewBox', `0 0 ${this.dimensions.width} ${this.dimensions.height}`) + this.startTime = window.performance.now() - this.strokes[0].points[0][3]; + this.playStroke(0,1); + } + + playStroke(path_i, point_i){ + const path = this.strokes[path_i]; + // console.log(path); + let pathEl = this.svgEl.querySelector(`.path${path_i}`); + if(!pathEl){ + pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + pathEl.style.stroke = path.color; + pathEl.classList.add('path'+path_i) + this.svgEl.appendChild(pathEl) + } + + const d = this.strokes2D(path.points.slice(0, point_i)); + pathEl.setAttribute('d', d); + + let next_path, next_point,t; + if(path.points.length > point_i + 1){ + next_path = path_i; + next_point = point_i + 1; + t = path.points[next_point][3];// - path.points[point_i][3]; + // setTimeout(() => this.playStroke(next_path, next_point), dt); + } else if(this.strokes.length > path_i + 1) { + next_path = path_i + 1; + next_point = 1; + t = this.strokes[next_path].points[next_point][3];// - path.points[point_i][3]; + // use starttime instead of diff, to prevent floating + } else { + console.log('done'); + return + } + + const dt = t - (window.performance.now() - this.startTime); + setTimeout(() => this.playStroke(next_path, next_point), dt); + } + + playUntil(path_i){ + // for scrubber + } + + resize() { + this.width = window.innerWidth; + this.height = window.innerHeight; + const viewBox = `0 0 ${this.width} ${this.height}`; + this.svgEl.setAttribute('viewBox', viewBox); + this.svgEl.setAttribute('width', this.width + 'mm'); + this.svgEl.setAttribute('height', this.height + 'mm'); + } + + requestResize() { + alert('Resize not implemented yet. Please reloade the page'); + } + + + strokes2D(strokes) { + // strokes to a d attribute for a path + let d = ""; + let last_stroke = undefined; + let cmd = ""; + for (let stroke of strokes) { + if (!last_stroke) { + d += `M${stroke[0] * this.width},${stroke[1] * this.height} `; + cmd = 'M'; + } else { + if (last_stroke[2] == 1) { + d += " m"; + cmd = 'm'; + } else if (cmd != 'l') { + d += ' l '; + cmd = 'l'; + } + let rel_stroke = [stroke[0] - last_stroke[0], stroke[1] - last_stroke[1]]; + d += `${rel_stroke[0] * this.width},${rel_stroke[1] * this.height} `; + } + last_stroke = stroke; + + } + return d; + } +} \ No newline at end of file