Browse Source

Rough draft for drawing

absolute
Ruben van de Ven 6 months ago
commit
111eab6cf0
  1. 1
      .python-version
  2. 2
      files/.gitignore
  3. 2
      log/.gitignore
  4. 16
      pyproject.toml
  5. 264
      webserver.py
  6. BIN
      www/cursor.png
  7. 164
      www/draw.html
  8. 244
      www/draw.js
  9. 74
      www/play.html
  10. 147
      www/play.js

1
.python-version

@ -0,0 +1 @@ @@ -0,0 +1 @@
3.9.2

2
files/.gitignore vendored

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
*
!.gitignore

2
log/.gitignore vendored

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
*
!.gitignore

16
pyproject.toml

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
[tool.poetry]
name = "svganim"
version = "0.1.0"
description = "Draw an animated vector image"
authors = ["Ruben van de Ven <git@rubenvandeven.com>"]
[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"

264
webserver.py

@ -0,0 +1,264 @@ @@ -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()

BIN
www/cursor.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

164
www/draw.html

@ -0,0 +1,164 @@ @@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Draw a line animation</title>
<style media="screen">
#sample, svg{
position:absolute;
top: 0;
left: 0;
bottom: 0;
right:0;
width:100%;
height:100%;
font-family: sans-serif;
z-index:2;
cursor: url('/cursor.png') 6 6, auto;
}
img{
position:absolute;
top:0;
bottom:0;
right:0;
left:0;
width:100%;
height:100%;
z-index:1;
}
path {
fill: none;
stroke: red;
stroke-width: 1mm;
stroke-linecap: round;
}
#wrapper {
position:absolute;
top:0;
right:0;
bottom:0;
left:0;
background:none;
}
.gray{
position:absolute;
background:rgba(255,255,255,0.7);
}
html, body{
height: 100%;
width: 100%;
margin:0;
font-family: sans-serif;
}
#interface{
height: 0;
overflow: hidden;
padding-top: calc({HEIGHT}/{WIDTH} * 100%);
position: relative;
margin: 0 auto;
background-size: 100% 100%;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#info{
position: absolute;
bottom: 15px;
width: 600px;
left: calc(50% - 250px);
z-index: 999;
}
.buttons{
text-align: center;
}
#submit{
background: lightblue;
border: solid 1px blue;
border-radius: 5px;
font-size: 110%;
padding: 5px 10px;
}
.toolbox{
position: absolute;
left: 0;
top: 50px;
z-index: 100;
background-color: white;
padding: 5px;
border-radius: 0 5px 5px 0;
background-color:#ccc;
}
.toolbox > ul{
padding: 0;
margin: 0;
}
.toolbox > ul > li{
cursor: pointer;
}
.toolbox .colors > li.selected {
border-width: 3px;
border-color: gray;
}
.toolbox .colors > li {
display: block;
border: solid 3px white;
border-radius: 5px;
margin: 5px;
width: 25px;
height: 25px;
}
.filename{
position: absolute;
bottom: 0;
left: 0;
color:gray;
z-index: -1;
font-size:8pt;
}
.closed{
background-color: lightgray;
}
.closed svg{
cursor:wait;
pointer-events: none;
}
</style>
</head>
<body>
<div id='interface'>
</div>
<!-- <div id='wrapper'>
<svg id="canvas" preserveAspectRatio="none">
<path d="" id="stroke" />
</svg>
</div>
<div id='info'>
<div class='buttons'>
<button id='submit'>Submit</button>
<form method='post' action='' id='finishedForm'>
</form>
</div>
</div>
<div class="toolbox">
<ul class="colors">
<li class="red"></li>
<li class="blue"></li>
<li class="green"></li>
</ul>
</div>
</div> -->
<script src="draw.js"></script>
<script type='text/javascript'>
const canvas = new Canvas( document.getElementById("interface"));
</script>
</body>
</html>

244
www/draw.js

@ -0,0 +1,244 @@ @@ -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;
}
}

74
www/play.html

@ -0,0 +1,74 @@ @@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Play a line animation</title>
<style media="screen">
#sample, svg{
position:absolute;
top: 0;
left: 0;
bottom: 0;
right:0;
width:100%;
height:100%;
font-family: sans-serif;
z-index:2;
cursor: url('/cursor.png') 6 6, auto;
}
img{
position:absolute;
top:0;
bottom:0;
right:0;
left:0;
width:100%;
height:100%;
z-index:1;
}
path {
fill: none;
stroke: gray;
stroke-width: 1mm;
stroke-linecap: round;
}
#wrapper {
position:absolute;
top:0;
right:0;
bottom:0;
left:0;
background:none;
}
.gray{
position:absolute;
background:rgba(255,255,255,0.7);
}
html, body{
height: 100%;
width: 100%;
margin:0;
font-family: sans-serif;
}
.playlist{
position: absolute;
left:0;
top:0;
z-index: 50;
}
</style>
</head>
<body>
<div id='interface'>
</div>
<script src="play.js"></script>
<script type='text/javascript'>
const player = new Player( document.getElementById("interface"));
player.playlist('/files/');
// player.play('/files/2021-11-22-3-75b0f0');
</script>
</body>
</html>

147
www/play.js

@ -0,0 +1,147 @@ @@ -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;
}
}
Loading…
Cancel
Save