From cd846577a4cea8f00e86142e90e3f15e34f1a5ce Mon Sep 17 00:00:00 2001 From: Lukas Cremer Date: Tue, 3 Feb 2026 22:41:29 +0100 Subject: [PATCH] add webui --- .gitignore | 6 + Plotter.py | 16 +- Program.py | 2 +- README.md | 5 + config.example.ini | 22 +++ config.py | 68 +++++++++ hpgl_svg.py | 127 +++++++++++++++ webui/README.md | 65 ++++++++ webui/app.py | 240 +++++++++++++++++++++++++++++ webui/requirements.txt | 2 + webui/static/app.js | 253 ++++++++++++++++++++++++++++++ webui/static/style.css | 305 +++++++++++++++++++++++++++++++++++++ webui/templates/index.html | 72 +++++++++ 13 files changed, 1180 insertions(+), 3 deletions(-) create mode 100644 config.example.ini create mode 100644 config.py create mode 100644 hpgl_svg.py create mode 100644 webui/README.md create mode 100644 webui/app.py create mode 100644 webui/requirements.txt create mode 100644 webui/static/app.js create mode 100644 webui/static/style.css create mode 100644 webui/templates/index.html diff --git a/.gitignore b/.gitignore index cfe56aa..ecd65c6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ env/ # OS .DS_Store Thumbs.db + +# Web UI uploads +webui/uploads/ + +# Config (use config.example.ini as template) +config.ini diff --git a/Plotter.py b/Plotter.py index 51fb53c..0b9ff1a 100755 --- a/Plotter.py +++ b/Plotter.py @@ -1,12 +1,20 @@ from __future__ import division import serial +try: + from config import plotter_port, plotter_baudrate, plotter_timeout, plotter_center_at_origin +except ImportError: + plotter_port = lambda: '/dev/ttyUSB0' + plotter_baudrate = lambda: 9600 + plotter_timeout = lambda: 15 + plotter_center_at_origin = lambda: False + class Plotter: def __init__(self, boundaries=None): self.boundaries = boundaries - self.p0incenter = False + self.p0incenter = plotter_center_at_origin() if not boundaries: s = self.getoutput(b'OW;') print(s) @@ -17,7 +25,11 @@ class Plotter: def getoutput(self, outstr): try: - self.ser = serial.Serial('/dev/ttyUSB0', timeout=15) + self.ser = serial.Serial( + plotter_port(), + baudrate=plotter_baudrate(), + timeout=plotter_timeout() + ) print('try to get Status') if not self.ser: print('Plotter not available') diff --git a/Program.py b/Program.py index cd8770a..4e6f1f7 100755 --- a/Program.py +++ b/Program.py @@ -1,6 +1,5 @@ import math from Command import * -import tkinter as tk class Program: @@ -52,6 +51,7 @@ class Program: return Program(commands) def show(self, w=None): + import tkinter as tk p = self.flip() p = p.rotate(270) p = p.fitin((0, 0, 1024, 600), (0, 0)) diff --git a/README.md b/README.md index 5215729..dd0cdc0 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,8 @@ mimaki/ - All HPGL and SVG live under **`output/hpgl/`** and **`output/svg/`**. - Run scripts from the project root so paths like `output/hpgl/...` resolve correctly. + +## Config + +- Copy **`config.example.ini`** to **`config.ini`** to set plotter port, baudrate, timeouts, and web UI host/port. `config.ini` is gitignored. +- Plotter and web UI read from `config.ini`; env `SECRET_KEY` overrides the web session secret. diff --git a/config.example.ini b/config.example.ini new file mode 100644 index 0000000..4ccaac9 --- /dev/null +++ b/config.example.ini @@ -0,0 +1,22 @@ +# HPGL Plotter – example config +# Copy to config.ini and adjust. config.ini is gitignored. + +[plotter] +# Serial port (e.g. /dev/ttyUSB0 on Linux, COM3 on Windows) +port = /dev/ttyUSB0 +# Baud rate (often 9600 for HPGL plotters) +baudrate = 9600 +# Timeout in seconds when reading from plotter +timeout = 15 +# Center program at origin before sending (True) or align to bottom-left (False) +center_at_origin = false + +[webui] +# Host to bind (0.0.0.0 = all interfaces, for access from other devices) +host = 0.0.0.0 +# Port for the web UI +port = 5000 +# Max upload size in bytes (default 16 MB) +max_upload_mb = 16 +# Secret key for Flask sessions; set a random string in production +secret_key = change-me-in-production diff --git a/config.py b/config.py new file mode 100644 index 0000000..36dcb79 --- /dev/null +++ b/config.py @@ -0,0 +1,68 @@ +""" +Load settings from config.ini (or config.example.ini if no config.ini). +Paths are relative to the project root (directory containing config.py). +""" +import os +from configparser import ConfigParser + +_ROOT = os.path.dirname(os.path.abspath(__file__)) +_CONFIG_DIR = _ROOT +_CONFIG_FILE = os.path.join(_CONFIG_DIR, 'config.ini') +_EXAMPLE_FILE = os.path.join(_CONFIG_DIR, 'config.example.ini') + + +def _get_parser(): + p = ConfigParser() + if os.path.isfile(_CONFIG_FILE): + p.read(_CONFIG_FILE, encoding='utf-8') + elif os.path.isfile(_EXAMPLE_FILE): + p.read(_EXAMPLE_FILE, encoding='utf-8') + return p + + +def get(section, key, fallback=None, type_=str): + p = _get_parser() + if not p.has_section(section) or not p.has_option(section, key): + return fallback + raw = p.get(section, key) + if type_ is int: + return int(raw) + if type_ is float: + return float(raw) + if type_ is bool: + return raw.strip().lower() in ('1', 'true', 'yes', 'on') + return raw + + +# Plotter +def plotter_port(): + return get('plotter', 'port', fallback='/dev/ttyUSB0') + + +def plotter_baudrate(): + return get('plotter', 'baudrate', fallback=9600, type_=int) + + +def plotter_timeout(): + return get('plotter', 'timeout', fallback=15, type_=int) + + +def plotter_center_at_origin(): + return get('plotter', 'center_at_origin', fallback=False, type_=bool) + + +# Web UI +def webui_host(): + return get('webui', 'host', fallback='0.0.0.0') + + +def webui_port(): + return get('webui', 'port', fallback=5000, type_=int) + + +def webui_max_upload_mb(): + return get('webui', 'max_upload_mb', fallback=16, type_=int) + + +def webui_secret_key(): + return os.environ.get('SECRET_KEY') or get('webui', 'secret_key', fallback='change-me-in-production') diff --git a/hpgl_svg.py b/hpgl_svg.py new file mode 100644 index 0000000..1771706 --- /dev/null +++ b/hpgl_svg.py @@ -0,0 +1,127 @@ +""" +Convert a Program to SVG for browser display. +No tkinter dependency; used by the web UI. +""" +from __future__ import division + + +# Plotter units to mm (HP-GL typical: 1 unit = 0.025 mm) +UNITS_PER_MM = 40.0 + +# Space for X and Y rulers (labels in plotter units) +RULER_LEFT = 52 +RULER_BOTTOM = 36 +MARGIN_TOP = 16 +MARGIN_RIGHT = 16 + + +def program_to_svg(program, width=800, height=600, margin=20, show_scale_bar=True): + """ + Render Program to SVG string. Fits content into width x height. + Always draws X and Y rulers showing bounds (xmin, xmax, ymin, ymax) in plotter units. + HPGL Y is flipped so it displays top-down in SVG. + """ + if not program.commands: + return _empty_svg(width, height) + + w, h = program.winsize + if w == 0 or h == 0: + return _empty_svg(width, height) + + xmin, ymin = program.xmin, program.ymin + xmax, ymax = program.xmax, program.ymax + vw = width - RULER_LEFT - MARGIN_RIGHT + vh = height - MARGIN_TOP - RULER_BOTTOM + scale = min(vw / w, vh / h) + cx, cy = program.center + ox = RULER_LEFT + vw / 2 - cx * scale + oy = MARGIN_TOP + vh / 2 + cy * scale # flip Y: HPGL y up -> SVG y down + + def tx(x): + return x * scale + ox + + def ty(y): + return -y * scale + oy + + path_segments = [] + circles = [] + x, y = None, None + + for cmd in program.commands: + if cmd.name == 'PU': + x, y = cmd.x, cmd.y + elif cmd.name == 'PD': + if x is not None and y is not None: + path_segments.append('M {:.2f} {:.2f} L {:.2f} {:.2f}'.format( + tx(x), ty(y), tx(cmd.x), ty(cmd.y))) + x, y = cmd.x, cmd.y + elif cmd.name == 'CI' and cmd.args: + r = abs(cmd.args[0]) * scale + cx_svg = tx(x) if x is not None else (RULER_LEFT + vw / 2) + cy_svg = ty(y) if y is not None else (MARGIN_TOP + vh / 2) + circles.append( + '' + .format(cx_svg, cy_svg, r)) + + parts = [] + if path_segments: + path_d = ' '.join(path_segments) + parts.append(''.format(path_d)) + parts.extend(circles) + + # X and Y rulers showing bounds (always) + parts.append(_rulers_svg(width, height, xmin, ymin, xmax, ymax, tx, ty)) + + return ( + '\n' + '\n' + '\n{body}\n\n' + ).format(w=width, h=height, body='\n'.join(parts)) + + +def _rulers_svg(width, height, xmin, ymin, xmax, ymax, tx, ty): + """Draw X and Y rulers with bounds labels (plotter units).""" + # X ruler: bottom, from xmin to xmax + y_xruler = height - RULER_BOTTOM + 8 + x0_x = tx(xmin) + x1_x = tx(xmax) + tick_y0 = y_xruler + tick_y1 = y_xruler + 6 + # Y ruler: left, from ymin (bottom in SVG) to ymax (top in SVG) + x_yruler = RULER_LEFT - 24 + y0_y = ty(ymin) # bottom of drawing in SVG + y1_y = ty(ymax) # top of drawing in SVG + tick_x0 = x_yruler + 18 + tick_x1 = x_yruler + 24 + font = 'font-size="11" font-family="system-ui,sans-serif" fill="#374151"' + return ( + '' + # X axis line and ticks + '' + '' + '' + '{xmin:.0f}' + '{xmax:.0f}' + # Y axis line and ticks + '' + '' + '' + '{ymin:.0f}' + '{ymax:.0f}' + '' + ).format( + x0_x=x0_x, x1_x=x1_x, y_xruler=y_xruler, tick_y0=tick_y0, tick_y1=tick_y1, + label_y=y_xruler + 20, xmin=xmin, xmax=xmax, + x_yruler=x_yruler, y0_y=y0_y, y1_y=y1_y, tick_x0=tick_x0, tick_x1=tick_x1, + y_label_x=x_yruler + 14, ymin=ymin, ymax=ymax, + font=font + ) + + +def _empty_svg(width, height): + return ( + '\n' + '\n' + 'No content\n' + ).format(w=width, h=height, x=width // 2 - 30, y=height // 2) diff --git a/webui/README.md b/webui/README.md new file mode 100644 index 0000000..5eba220 --- /dev/null +++ b/webui/README.md @@ -0,0 +1,65 @@ +# HPGL Plotter Web UI + +Flask web interface for uploading HPGL files, previewing and transforming them (flip, rotate, scale, center), and sending to the plotter. Intended to run on a Raspberry Pi connected to the plotter via USB. + +## Setup (Raspberry Pi) + +1. From the **project root** (mimaki/), install dependencies: + + If you get **externally-managed-environment** (Python 3.11+ / PEP 668) and don’t want a venv, use: + ```bash + pip install --break-system-packages -r webui/requirements.txt + ``` + Otherwise: + ```bash + pip install -r webui/requirements.txt + ``` + Optional – use a venv to avoid touching system Python: + ```bash + python3 -m venv venv + source venv/bin/activate + pip install -r webui/requirements.txt + ``` + +2. **Config:** Copy `config.example.ini` to `config.ini` and adjust (port, baudrate, web UI host/port, etc.). `config.ini` is gitignored. + ```bash + cp config.example.ini config.ini + ``` + Edit `config.ini` to set the plotter port (e.g. `/dev/ttyUSB0`) and optionally web UI port/host. + +## Run + +From the **project root** (mimaki/): + +```bash +python webui/app.py +``` + +Then open http://<raspi-ip>:5000 in a browser (e.g. http://192.168.1.10:5000). + +- **Host:** `0.0.0.0` so other devices on the network can connect. +- **Port:** 5000 (override with `PORT=8080 python webui/app.py` if needed). + +For production, run behind gunicorn and optionally a reverse proxy: + +```bash +pip install --break-system-packages gunicorn # or omit if not using system Python +gunicorn -w 1 -b 0.0.0.0:5000 "webui.app:app" +``` + +## Usage + +1. **Upload** – Choose an `.hpgl` or `.plt` file. It is validated and stored in `webui/uploads/`. +2. **Preview** – The drawing is converted to SVG and shown in the browser. +3. **Transform** – Flip, Rotate 90° / 180°, Scale + / −, Center. Each action updates the stored program and the preview. +4. **Print** – Sends the current (transformed) program to the plotter: scale to fit, center, then write HPGL to serial. + +## API + +- `POST /api/upload` – Upload HPGL file (form field `file`). +- `GET /api/svg` – Get current program as SVG (query: `width`, `height`). +- `POST /api/transform` – Body: `{"action": "flip"|"rotate"|"scale"|"centralize", "angle": 90, "factor": 1.25}`. +- `POST /api/print` – Send current program to plotter. +- `GET /api/status` – `has_file`, `filename`, `plotter_ready`. + +Session is used to keep the current file path; no database. diff --git a/webui/app.py b/webui/app.py new file mode 100644 index 0000000..8196976 --- /dev/null +++ b/webui/app.py @@ -0,0 +1,240 @@ +""" +Flask web UI for HPGL plotter. +Run from project root so Command, Program, Plotter, hpgl_svg, config can be imported. +""" +import os +import sys +import uuid +from flask import Flask, request, jsonify, session, send_from_directory, render_template + +# Run from project root (parent of webui) +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from Command import Command +from Program import Program +from Plotter import Plotter +from hpgl_svg import program_to_svg + +try: + from config import webui_secret_key, webui_max_upload_mb +except ImportError: + webui_secret_key = lambda: os.environ.get('SECRET_KEY', 'change-me-in-production') + webui_max_upload_mb = lambda: 16 + +app = Flask(__name__, static_folder='static', template_folder='templates') +app.secret_key = webui_secret_key() +app.config['MAX_CONTENT_LENGTH'] = webui_max_upload_mb() * 1024 * 1024 +UPLOAD_DIR = os.path.join(ROOT, 'webui', 'uploads') +os.makedirs(UPLOAD_DIR, exist_ok=True) + + +def _get_program(): + path = session.get('upload_path') + if not path or not os.path.isfile(path): + return None + try: + return Program.parsefile(path) + except Exception: + return None + + +def _save_program(program, path=None): + path = path or session.get('upload_path') + if not path: + return None + with open(path, 'w') as f: + f.write(str(program)) + return path + + +@app.route('/') +def index(): + return render_template('index.html') + + +@app.route('/api/upload', methods=['POST']) +def upload(): + if 'file' not in request.files: + return jsonify({'error': 'No file'}), 400 + f = request.files['file'] + if not f.filename or not (f.filename.lower().endswith('.hpgl') or f.filename.lower().endswith('.plt')): + return jsonify({'error': 'Need an .hpgl or .plt file'}), 400 + try: + content = f.read().decode('utf-8', errors='replace') + Program.parse(content) # validate + except Exception as e: + return jsonify({'error': 'Invalid HPGL: {}'.format(str(e))}), 400 + name = str(uuid.uuid4()) + '.hpgl' + path = os.path.join(UPLOAD_DIR, name) + with open(path, 'w') as out: + out.write(content) + session['upload_path'] = path + session['upload_filename'] = f.filename + return jsonify({ + 'ok': True, + 'filename': f.filename, + 'preview_url': '/api/svg' + }) + + +@app.route('/api/dimensions') +def dimensions(): + """Return current program size in plotter units (e.g. 0.025 mm per unit).""" + p = _get_program() + if p is None: + return jsonify({'error': 'No file loaded'}), 404 + w, h = p.winsize + return jsonify({ + 'width': round(w, 1), + 'height': round(h, 1), + 'width_mm': round(w * 0.025, 2), + 'height_mm': round(h * 0.025, 2), + }) + + +@app.route('/api/svg') +def get_svg(): + p = _get_program() + if p is None: + return jsonify({'error': 'No file loaded'}), 404 + width = request.args.get('width', 800, type=int) + height = request.args.get('height', 600, type=int) + show_scale = request.args.get('scale', '1').lower() in ('1', 'true', 'yes') + svg = program_to_svg(p, width=width, height=height, show_scale_bar=show_scale) + return svg, 200, {'Content-Type': 'image/svg+xml; charset=utf-8'} + + +@app.route('/api/transform', methods=['POST']) +def transform(): + p = _get_program() + if p is None: + return jsonify({'error': 'No file loaded'}), 404 + data = request.get_json() or {} + action = data.get('action') + if action == 'flip': + p = p.flip() + elif action == 'rotate': + angle = data.get('angle', 90) + p = p.rotate(angle) + elif action == 'scale': + factor = data.get('factor', 1.5) + p = p * factor + elif action == 'centralize': + p = p - p.center + elif action == 'scale_to_bounds': + xmin = data.get('xmin') + ymin = data.get('ymin') + xmax = data.get('xmax') + ymax = data.get('ymax') + if xmin is not None and ymin is not None and xmax is not None and ymax is not None: + if xmax <= xmin or ymax <= ymin: + return jsonify({'error': 'Invalid bounds: xmax > xmin and ymax > ymin required.'}), 400 + p = p.fitin((xmin, ymin, xmax, ymax), (0.5, 0.5)) + else: + try: + plt = Plotter() + if getattr(plt, 'boundaries', None) is None: + return jsonify({'error': 'Plotter not connected. Connect plotter or send bounds (xmin, ymin, xmax, ymax).'}), 400 + p = plt.full(p) + except (OSError, AttributeError, TypeError): + return jsonify({'error': 'Plotter not connected. Connect plotter or send bounds (xmin, ymin, xmax, ymax).'}), 400 + else: + return jsonify({'error': 'Unknown action'}), 400 + _save_program(p) + return jsonify({'ok': True, 'preview_url': '/api/svg'}) + + +@app.route('/api/print', methods=['POST']) +def print_to_plotter(): + p = _get_program() + if p is None: + return jsonify({'error': 'No file loaded'}), 404 + try: + plt = Plotter() + if getattr(plt, 'ser', None) is None: + return jsonify({'error': 'Plotter not connected. Connect plotter and try again.'}), 503 + p = plt.full(p) + p = plt.centralize(p) + plt.write(p) + return jsonify({'ok': True}) + except OSError as e: + return jsonify({'error': 'Plotter not connected: {}'.format(str(e))}), 503 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/status') +def status(): + has_file = bool(session.get('upload_path')) and os.path.isfile(session.get('upload_path')) + plotter_ready = False + try: + plt = Plotter() + if getattr(plt, 'ser', None) is not None and getattr(plt, 'boundaries', None) is not None: + plotter_ready = plt.ready + except (OSError, AttributeError, TypeError): + pass + return jsonify({ + 'has_file': has_file, + 'filename': session.get('upload_filename'), + 'plotter_ready': plotter_ready + }) + + +@app.route('/api/plotter_info') +def plotter_info(): + """Return plotter connection and media dimensions for display under the print button.""" + try: + from config import plotter_port, plotter_baudrate + except ImportError: + plotter_port = lambda: '/dev/ttyUSB0' + plotter_baudrate = lambda: 9600 + port = plotter_port() + baudrate = plotter_baudrate() + connected = False + ready = False + boundaries = None + winsize_units = None + winsize_mm = None + try: + plt = Plotter() + if getattr(plt, 'ser', None) is not None and getattr(plt, 'boundaries', None) is not None: + connected = True + ready = plt.ready + try: + boundaries = { + 'xmin': plt.xmin, + 'ymin': plt.ymin, + 'xmax': plt.xmax, + 'ymax': plt.ymax, + } + w, h = plt.winsize + winsize_units = {'width': round(w, 1), 'height': round(h, 1)} + winsize_mm = {'width': round(w * 0.025, 1), 'height': round(h * 0.025, 1)} + except (AttributeError, TypeError): + pass + except (OSError, AttributeError, TypeError): + pass + return jsonify({ + 'port': port, + 'baudrate': baudrate, + 'connected': connected, + 'ready': ready, + 'boundaries': boundaries, + 'winsize_units': winsize_units, + 'winsize_mm': winsize_mm, + }) + + +if __name__ == '__main__': + try: + from config import webui_host, webui_port + except ImportError: + webui_host = lambda: '0.0.0.0' + webui_port = lambda: 5000 + app.run( + host=webui_host(), + port=webui_port(), + debug=os.environ.get('FLASK_DEBUG', '0') == '1' + ) diff --git a/webui/requirements.txt b/webui/requirements.txt new file mode 100644 index 0000000..ae919ce --- /dev/null +++ b/webui/requirements.txt @@ -0,0 +1,2 @@ +Flask>=2.0 +pyserial>=3.5 diff --git a/webui/static/app.js b/webui/static/app.js new file mode 100644 index 0000000..9a3e681 --- /dev/null +++ b/webui/static/app.js @@ -0,0 +1,253 @@ +(function () { + const fileInput = document.getElementById('fileInput'); + const preview = document.getElementById('preview'); + const previewPlaceholder = document.getElementById('previewPlaceholder'); + const statusFile = document.getElementById('statusFile'); + const statusPlotter = document.getElementById('statusPlotter'); + const printMessage = document.getElementById('printMessage'); + const scaleDims = document.getElementById('scaleDims'); + const plotterInfo = { + port: document.getElementById('plotterPort'), + baud: document.getElementById('plotterBaud'), + winsizeUnits: document.getElementById('plotterWinsizeUnits'), + winsizeMm: document.getElementById('plotterWinsizeMm'), + bounds: document.getElementById('plotterBounds') + }; + const buttons = { + flip: document.getElementById('btnFlip'), + rotate90: document.getElementById('btnRotate90'), + rotate180: document.getElementById('btnRotate180'), + scaleUp: document.getElementById('btnScaleUp'), + scaleDown: document.getElementById('btnScaleDown'), + centralize: document.getElementById('btnCentralize'), + scaleToBounds: document.getElementById('btnScaleToBounds'), + print: document.getElementById('btnPrint'), + checkPlotter: document.getElementById('btnCheckPlotter') + }; + + function setMessage(el, text, type) { + el.textContent = text || ''; + el.className = 'message' + (type ? ' ' + type : ''); + } + + function updatePreview() { + const w = Math.min(800, window.innerWidth - 80); + const h = Math.min(600, window.innerHeight - 120); + preview.src = '/api/svg?width=' + w + '&height=' + h + '&scale=1&_=' + Date.now(); + } + + function updateDimensions() { + if (!scaleDims) return; + fetch('/api/dimensions') + .then(function (r) { + if (!r.ok) throw new Error(); + return r.json(); + }) + .then(function (d) { + scaleDims.innerHTML = d.width + ' × ' + d.height + ' (≈ ' + d.width_mm + ' × ' + d.height_mm + ' mm)'; + }) + .catch(function () { + scaleDims.innerHTML = '— × — (≈ — × — mm)'; + }); + } + + function updatePlotterInfo() { + if (!plotterInfo.port) return; + fetch('/api/plotter_info') + .then(function (r) { return r.ok ? r.json() : Promise.reject(); }) + .then(function (d) { + plotterInfo.port.textContent = d.port || '—'; + plotterInfo.baud.textContent = d.baudrate ? d.baudrate + ' baud' : '—'; + if (d.winsize_units) { + plotterInfo.winsizeUnits.textContent = d.winsize_units.width + ' × ' + d.winsize_units.height; + } else { + plotterInfo.winsizeUnits.textContent = '— × —'; + } + if (d.winsize_mm) { + plotterInfo.winsizeMm.textContent = d.winsize_mm.width + ' × ' + d.winsize_mm.height + ' mm'; + } else { + plotterInfo.winsizeMm.textContent = '— × —'; + } + if (d.boundaries) { + var b = d.boundaries; + plotterInfo.bounds.textContent = b.xmin + ',' + b.ymin + ' … ' + b.xmax + ',' + b.ymax; + } else { + plotterInfo.bounds.textContent = '—'; + } + }) + .catch(function () { + plotterInfo.port.textContent = '—'; + plotterInfo.baud.textContent = '—'; + plotterInfo.winsizeUnits.textContent = '— × —'; + plotterInfo.winsizeMm.textContent = '— × —'; + plotterInfo.bounds.textContent = '—'; + }); + } + + function setLoaded(loaded) { + if (loaded) { + preview.classList.add('loaded'); + previewPlaceholder.classList.add('hidden'); + Object.keys(buttons).forEach(function (k) { + var b = buttons[k]; + if (b) b.disabled = false; + }); + } else { + preview.classList.remove('loaded'); + previewPlaceholder.classList.remove('hidden'); + preview.src = ''; + Object.keys(buttons).forEach(function (k) { + var b = buttons[k]; + if (b) b.disabled = true; + }); + } + } + + function refreshStatus() { + fetch('/api/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + statusFile.textContent = data.has_file ? (data.filename || 'File loaded') : 'No file'; + statusPlotter.textContent = 'Plotter: ' + (data.plotter_ready ? 'Ready' : 'Not connected'); + statusPlotter.classList.toggle('ready', data.plotter_ready); + statusPlotter.classList.toggle('not-ready', !data.plotter_ready); + updatePlotterInfo(); + if (data.has_file) { + setLoaded(true); + updatePreview(); + updateDimensions(); + } else { + setLoaded(false); + if (scaleDims) scaleDims.innerHTML = '— × — (≈ — × — mm)'; + } + }) + .catch(function () { + statusFile.textContent = 'No file'; + statusPlotter.textContent = 'Plotter: —'; + setLoaded(false); + if (scaleDims) scaleDims.innerHTML = '— × — (≈ — × — mm)'; + }); + } + + fileInput.addEventListener('change', function () { + const file = this.files && this.files[0]; + if (!file) return; + preview.src = ''; + preview.classList.remove('loaded'); + previewPlaceholder.classList.remove('hidden'); + previewPlaceholder.textContent = 'Loading…'; + setMessage(printMessage, ''); + const form = new FormData(); + form.append('file', file); + fetch('/api/upload', { + method: 'POST', + body: form + }) + .then(function (r) { + if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Upload failed'); }); + return r.json(); + }) + .then(function () { + previewPlaceholder.textContent = 'Upload an HPGL file to preview'; + refreshStatus(); + }) + .catch(function (e) { + previewPlaceholder.textContent = 'Upload an HPGL file to preview'; + setMessage(printMessage, e.message || 'Upload failed', 'error'); + refreshStatus(); + }); + }); + + function transform(action, payload) { + setMessage(printMessage, ''); + var body = payload || { action: action }; + fetch('/api/transform', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then(function (r) { + if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Transform failed'); }); + return r.json(); + }) + .then(function () { + updatePreview(); + updateDimensions(); + }) + .catch(function (e) { + setMessage(printMessage, e.message || 'Transform failed', 'error'); + }); + } + + buttons.flip.addEventListener('click', function () { transform('flip'); }); + buttons.rotate90.addEventListener('click', function () { transform('rotate', { action: 'rotate', angle: 90 }); }); + buttons.rotate180.addEventListener('click', function () { transform('rotate', { action: 'rotate', angle: 180 }); }); + buttons.scaleUp.addEventListener('click', function () { transform('scale', { action: 'scale', factor: 1.25 }); }); + buttons.scaleDown.addEventListener('click', function () { transform('scale', { action: 'scale', factor: 0.8 }); }); + buttons.centralize.addEventListener('click', function () { transform('centralize'); }); + if (buttons.scaleToBounds) { + buttons.scaleToBounds.addEventListener('click', function () { + setMessage(printMessage, ''); + fetch('/api/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.plotter_ready) { + setMessage(printMessage, 'Plotter not connected.', 'error'); + return; + } + transform('scale_to_bounds', { action: 'scale_to_bounds' }); + }) + .catch(function () { + setMessage(printMessage, 'Plotter not connected.', 'error'); + }); + }); + } + + buttons.print.addEventListener('click', function () { + setMessage(printMessage, 'Sending to plotter…'); + fetch('/api/print', { method: 'POST' }) + .then(function (r) { + return r.json().then(function (data) { + if (!r.ok) throw new Error(data.error || 'Print failed'); + return data; + }); + }) + .then(function () { + setMessage(printMessage, 'Sent to plotter.', 'success'); + }) + .catch(function (e) { + setMessage(printMessage, e.message || 'Print failed', 'error'); + }); + }); + + if (buttons.checkPlotter) { + buttons.checkPlotter.addEventListener('click', function () { + setMessage(printMessage, 'Checking plotter…'); + fetch('/api/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + statusFile.textContent = data.has_file ? (data.filename || 'File loaded') : 'No file'; + statusPlotter.textContent = 'Plotter: ' + (data.plotter_ready ? 'Ready' : 'Not connected'); + statusPlotter.classList.toggle('ready', data.plotter_ready); + statusPlotter.classList.toggle('not-ready', !data.plotter_ready); + updatePlotterInfo(); + setMessage(printMessage, data.plotter_ready ? 'Plotter: Ready' : 'Plotter: Not connected', data.plotter_ready ? 'success' : 'error'); + }) + .catch(function () { + statusPlotter.textContent = 'Plotter: —'; + statusPlotter.classList.remove('ready'); + statusPlotter.classList.add('not-ready'); + updatePlotterInfo(); + setMessage(printMessage, 'Plotter: Not connected', 'error'); + }); + }); + } + + preview.addEventListener('load', function () { + previewPlaceholder.classList.add('hidden'); + }); + + refreshStatus(); + updatePlotterInfo(); + setInterval(updatePlotterInfo, 10000); +})(); diff --git a/webui/static/style.css b/webui/static/style.css new file mode 100644 index 0000000..a34a847 --- /dev/null +++ b/webui/static/style.css @@ -0,0 +1,305 @@ +:root { + --bg: #ffffff; + --surface: #f5f5f6; + --border: #d1d5db; + --text: #1f2937; + --muted: #6b7280; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --danger: #dc2626; + --success: #16a34a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.app { + max-width: 1200px; + margin: 0 auto; + padding: 1rem; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.header h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.status { + display: flex; + gap: 1rem; + font-size: 0.875rem; + color: var(--muted); +} + +.status-plotter.ready { + color: var(--success); +} + +.status-plotter.not-ready { + color: var(--danger); +} + +.main { + display: grid; + grid-template-columns: 260px 1fr; + gap: 1.5rem; + flex: 1; +} + +@media (max-width: 700px) { + .main { + grid-template-columns: 1fr; + } +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.upload-section { + padding: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} + +.upload-label { + display: block; + font-weight: 500; + margin-bottom: 0.5rem; +} + +.file-input { + display: block; + width: 100%; + padding: 0.5rem; + font-size: 0.875rem; + background: #fff; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + cursor: pointer; +} + +.file-input::file-selector-button { + margin-right: 0.5rem; + padding: 0.25rem 0.5rem; + background: var(--accent); + border: none; + border-radius: 4px; + color: #fff; + cursor: pointer; +} + +.hint { + margin: 0.5rem 0 0; + font-size: 0.75rem; + color: var(--muted); +} + +.transform-section h2, +.print-section h2, +.scale-section h2 { + margin: 0 0 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--muted); +} + +.scale-section { + padding: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 0.875rem; +} + +.scale-section .dims { + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.scale-section .unit { + color: var(--muted); + font-size: 0.8em; +} + +.transform-section { + padding: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} + +.btn-group { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.btn { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.btn:hover:not(:disabled) { + background: var(--border); + border-color: var(--accent); + color: var(--text); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-print { + width: 100%; + padding: 0.75rem 1rem; + font-size: 1rem; + font-weight: 600; + background: var(--accent); + border: none; + color: #fff; +} + +.plotter-info { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + font-size: 0.8125rem; + color: var(--text); +} + +.plotter-info-row { + margin: 0.25rem 0; + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.plotter-info-row strong { + color: var(--muted); + font-weight: 500; +} + +.btn-print:hover:not(:disabled) { + background: var(--accent-hover); +} + +.btn-check-plotter { + width: 100%; + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + cursor: pointer; +} + +.btn-check-plotter:hover { + background: var(--border); + border-color: var(--accent); +} + +.print-section { + padding: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} + +.message { + margin: 0.5rem 0 0; + font-size: 0.875rem; + min-height: 1.25rem; +} + +.message.error { + color: var(--danger); +} + +.message.success { + color: var(--success); +} + +.preview-section { + min-height: 400px; + background: #fafafa; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.preview-wrap { + position: relative; + width: 100%; + height: 100%; + min-height: 400px; + display: flex; + align-items: center; + justify-content: center; +} + +.preview { + max-width: 100%; + max-height: 70vh; + object-fit: contain; + display: none; +} + +.preview.loaded { + display: block; +} + +.preview-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 0.875rem; +} + +.preview-placeholder.hidden { + display: none; +} diff --git a/webui/templates/index.html b/webui/templates/index.html new file mode 100644 index 0000000..f304765 --- /dev/null +++ b/webui/templates/index.html @@ -0,0 +1,72 @@ + + + + + + HPGL Plotter + + + +
+
+

HPGL Plotter

+
+ No file + Plotter: — +
+
+ +
+ + +
+
+ HPGL preview +
+ Upload an HPGL file to preview +
+
+
+
+
+ + + +