128 lines
4.9 KiB
Python
128 lines
4.9 KiB
Python
"""
|
|
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(
|
|
'<circle cx="{:.2f}" cy="{:.2f}" r="{:.2f}" fill="none" stroke="currentColor" stroke-width="1"/>'
|
|
.format(cx_svg, cy_svg, r))
|
|
|
|
parts = []
|
|
if path_segments:
|
|
path_d = ' '.join(path_segments)
|
|
parts.append('<path d="{}" fill="none" stroke="currentColor" stroke-width="1"/>'.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 (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
'width="{w}" height="{h}" viewBox="0 0 {w} {h}">\n'
|
|
'<g>\n{body}\n</g>\n</svg>'
|
|
).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 (
|
|
'<g stroke="#6b7280" fill="#374151" stroke-width="1">'
|
|
# X axis line and ticks
|
|
'<line x1="{x0_x:.1f}" y1="{y_xruler:.1f}" x2="{x1_x:.1f}" y2="{y_xruler:.1f}"/>'
|
|
'<line x1="{x0_x:.1f}" y1="{tick_y0:.1f}" x2="{x0_x:.1f}" y2="{tick_y1:.1f}"/>'
|
|
'<line x1="{x1_x:.1f}" y1="{tick_y0:.1f}" x2="{x1_x:.1f}" y2="{tick_y1:.1f}"/>'
|
|
'<text x="{x0_x:.1f}" y="{label_y:.1f}" text-anchor="middle" {font}>{xmin:.0f}</text>'
|
|
'<text x="{x1_x:.1f}" y="{label_y:.1f}" text-anchor="middle" {font}>{xmax:.0f}</text>'
|
|
# Y axis line and ticks
|
|
'<line x1="{x_yruler:.1f}" y1="{y0_y:.1f}" x2="{x_yruler:.1f}" y2="{y1_y:.1f}"/>'
|
|
'<line x1="{tick_x0:.1f}" y1="{y0_y:.1f}" x2="{tick_x1:.1f}" y2="{y0_y:.1f}"/>'
|
|
'<line x1="{tick_x0:.1f}" y1="{y1_y:.1f}" x2="{tick_x1:.1f}" y2="{y1_y:.1f}"/>'
|
|
'<text x="{y_label_x:.1f}" y="{y0_y:.1f}" text-anchor="end" dominant-baseline="middle" {font}>{ymin:.0f}</text>'
|
|
'<text x="{y_label_x:.1f}" y="{y1_y:.1f}" text-anchor="end" dominant-baseline="middle" {font}>{ymax:.0f}</text>'
|
|
'</g>'
|
|
).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 (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">\n'
|
|
'<text x="{x}" y="{y}" fill="#999">No content</text>\n</svg>'
|
|
).format(w=width, h=height, x=width // 2 - 30, y=height // 2)
|