I had few attempts to write a complete guide for writing a McCLIM backend. This time I will try to do it in few iterations starting from a naive output-only backend, through more complete solution up to the interactive version. Some McCLIM-specific interfaces may be used.
We are going to write a HTML5 backend. Our output will be targetting a canvas object and we will manipulate it with a generated javascript. Our file hierarchy is somewhat minimalistic. Below is the project skeleton on which we will build the backend.
- eu.turtleware.clim.html5-backend.asd
- system definition
(in-package #:asdf-user) (defsystem "eu.turtleware.clim.html5-backend" :author "Daniel 'jackdaniel' Kochmański" :license "LGPL-2.1+" :description "McCLIM backend for creating html5 content." :depends-on ("mcclim" "alexandria" "cl-who") :components ((:static-file "eu.turtleware.clim.html5-backend.asd") (:static-file "eu.turtleware.clim.html5-backend.tutorial.org") (:static-file "eu.turtleware.clim.html5-backend.examples.lisp") (:file "eu.turtleware.clim.html5-backend"))) (defsystem "eu.turtleware.clim.html5-backend/examples" :depends-on ("eu.turtleware.clim.html5-backend" "hunchentoot") :components ((:file "eu.turtleware.clim.html5-backend.examples")))
- eu.turtleware.clim.html5-backend.lisp
- implementation
(defpackage #:eu.turtleware.clim.html5-backend (:export #:with-html5-stream)) (defpackage #:eu.turtleware.clim.html5-backend.implementation (:use #:clim-lisp #:eu.turtleware.clim.html5-backend)) (in-package #:eu.turtleware.clim.html5-backend.implementation) (defmacro with-html5-stream ((var stream &rest options) &body body) (let ((cont (gensym))) `(flet ((,cont (,var) (declare (ignorable ,var)) ,@body)) (declare (dynamic-extent #',cont)) (invoke-with-html5-stream #',cont ,stream ,@options)))) (defun invoke-with-html5-stream (cont stream &rest options) (declare (ignore cont options)) (setf (cl-who:html-mode) :html5) (cl-who:with-html-output (stream stream :prologue t :indent t) (:html (:head) (:body (:canvas :id "McCLIM" :width 800 :height 600 :style "border:1px solid #000000") (:script)))))
- eu.turtleware.clim.html5-backend.examples.lisp
- examples
(defpackage #:eu.turtleware.clim.html5-backend.examples (:use #:clim-lisp) (:local-nicknames (#:html5 #:eu.turtleware.clim.html5-backend)) (:export #:start #:stop #:test)) (in-package #:eu.turtleware.clim.html5-backend.examples) (defvar *acceptor* (make-instance 'hunchentoot:easy-acceptor :port 4242)) (defvar *stream* nil) (defvar *stream-render* nil) (defun start (&optional debug) (when *stream* (clim:close *stream*)) (setf *stream* (clim:open-window-stream :record nil :label "CLX")) (when *stream-render* (clim:close *stream-render*)) (setf *stream-render* (let ((clim:*default-server-path* :clx-fb)) (clim:open-window-stream :record nil :label "FB"))) (setf hunchentoot:*catch-errors-p* (not debug)) (hunchentoot:start *acceptor*)) (defun stop () (when *stream* (clim:close *stream*) (setf *stream* nil)) (when *stream-render* (clim:close *stream-render*) (setf *stream-render* nil)) (hunchentoot:stop *acceptor*)) (declaim (notinline test)) (defun test (stream) (clim:draw-rectangle* stream 100 100 700 500 :ink clim:+dark-blue+)) (hunchentoot:define-easy-handler (say-yo :uri "/yo") () (flet ((show-window-stream (stream) (clim:window-clear stream) (clim:draw-rectangle* stream 0 0 800 600 :filled nil) (test stream) (finish-output stream))) (show-window-stream *stream*) (show-window-stream *stream-render*)) ;; html5 stream (setf (hunchentoot:content-type*) "text/html") (with-output-to-string (stream) (html5:with-html5-stream (stream stream) (test stream))))
- eu.turtleware.clim.html5-backend.tutorial.org
- this tutorial
#1=(#1#)
We define two systems: eu.turtleware.clim.html5-backend
for backend
implementation and eu.turtleware.clim.html5-backend/examples
for
sample code.
The main system is called eu.turtleware.clim.html5-backend
and has
two packages:
eu.turtleware.clim.html5-backend
- this package exports symbols
which are meant to be consumed by the API client. No package
(even
common-lisp
) is used by this package eu.turtleware.clim.html5-backend.implementation
- this is an
implementation package where everything is defined. It “uses”
eu.turtleware.clim.html5-backend
This package organization is my favourite. The API packages have no code and are used strictly for symbol export and all implementation code is written in a single package. Thanks to this approach there is no risk of symbol name conflicts in a single system. Alternative approaches put a significant burden on me to manage the package definitions (especially when something changes).
Notice also that I’m not using package prefix for defpackage
– if
someone shadows the symbol when loading my systems I expect that they
know what they are doing and it is a deliberate change. Same goes for
the operator in-package
.
An API of the output-only backend is trival: with-html5-stream
is a
macro which creates a CLIM stream on which we may perform drawing
operations. Macro is responsible for creating the appropriate context
and binds the clim stream to a variable. Since our backend outputs the
text we may see what will happen if we bind the *standard-output*
to
the stream:
CL-USER> (add-package-local-nickname "HTML5" "EU.TURTLEWARE.CLIM.HTML5-BACKEND")
#<PACKAGE "COMMON-LISP-USER">
CL-USER> (html5:with-html5-stream (stream *standard-output*)
(clim:draw-rectangle* stream 0 0 10 10))
<!DOCTYPE html>
<html>
<head></head>
<body>
<canvas id='McCLIM' width='800' height='600' style='border:1px solid #000000'></canvas>
<script></script>
</body>
</html>
NIL
An example output above hints how we are going to tackle the problem. First we create a canvas and then generate a script which performs drawing. All in a single HTML document for simplicity.
The interactive testing code defined in the system
eu.turtleware.clim.html5-backend/examples
is tailored for a
differential testing between the html5, the clx and the framebuffer
renderer backends. The same body is executed for each context.
The function start
opens two CLIM window streams and the
hunchentoot
acceptor. Function stop
closes both windows and the
acceptor. When an easy handler say-yo
is invoked windows all streams
are redrawn by the function test
. We test by redefining the test
function to perform tested operations and then refresh the page.
Now load the system ~”eu.turtleware.clim.html5-backend/examples”~,
start the acceptor and open the browser on
http://localhost:4242/yo. Currently with-html5-output
is a stub
which ignores its body, so the canvas is empty. You should see a dark
blue rectangle in two other windows.
Canvas in HTML5 is a drawing area with an API to access it from
JavaScript. To create a canvas it is enough to insert a canvas
tag into the html document body:
<canvas id='McCLIM' width='800' height='600' style='border:1px solid #000000'> </canvas>
ID is used to find the object in the script. All drawing is done
through a context
handler which may be requested from the canvas
object. For instance we could draw a rectangle like this:
<script> var canvas = document.getElementById('McCLIM'); var context = canvas.getContext('2d'); context.beginPath(); context.moveTo(0, 0); context.lineTo(20, 0); context.lineTo(20, 20); context.lineTo(0, 20); context.closePath(); context.fill(); </script>
All operations are performed in four steps:
- Start a new path with
beginPath()
- Configure a transformation, a color and a line style (optional)
- Create the path with a set of operations like moveTo, lineTo etc
- Execute the operation (
fill()
,stroke()
orclip()
)
For complete reference of available functions see the official
w3schools canvas reference. Another useful resource is the MDN API for
a canvas rendering context. We will use one extension which is
supported by all so-called “modern” browsers, that is a function
setLineDash()
. Another extension we will use is the Path2D API which
allows concatenating paths.
Macro name with-html-stream
clearly indicates, that we are dealing
with a stream. CLIM streams are sheets
which are the fundamental
window abstraction defined by CLIM. A graft
is a special kind of
sheet directly connected to a display server – usually a root window
of the display (i.e the screen, in our case the canvas).
Logical representation of a display service is called a port
(i.e an
instance maintaining the socket for communication with X11 server, in
our case it is a text stream to which we write the html output).
It is possible to have many ports running in McCLIM at the same
time. Ports are designated by their server path, if the port can’t be
found it is created. For example here is a possible server path for
the X11 server (:clx :host "localhost")
. The first agument is the
backend designator – this symbol plist should have two properties
defined:
:port-type
- a name of the port class
:server-path-parser
- a function taking whole server path and returning its canonicalized version
When McCLIM looks for a port it first canonicalizes the path and then
if there is no existing port with the same (equal
-wise path), then
it creates an instance of it. The find function could look like this:
(defun find-port (&key (server-path *default-server-path*))
(let* ((port-type (first server-path))
(server-path-parser (get port-type :server-path-parser))
(server-path* (funcall server-path-parser) server-path)
(port-type* (first server-path*)))
(or (find server-path* *all-ports* :key #'port-server-path :test #'equal)
(let ((port (make-instance port-type :server-path server-path*)))
(prog1 port (push port *all-ports*))))))
This means in particular, that the server-path-parser
may return a
server path with a different backend designator than the
original. Without further ado let’s define the port class.
(defclass html5-port (clim:basic-port)
;; This stream is an output sink (i.e a html file).
((stream :accessor html5-port-stream)))
(defmethod initialize-instance :after ((port html5-port) &key)
(let* ((options (cdr (clim:port-server-path port)))
(stream (getf options :stream)))
(setf (html5-port-stream port) stream))
(climb:make-graft port))
(setf (get :html5 :port-type) 'html5-port)
(setf (get :html5 :server-path-parser) 'identity)
Since we are writing a backend which performs only output there is no
need for a windowing hierarchy whatsoever. In that case our stream
will be both a graft
and a standard-extended-output-stream
. Things
will get more interesting when we will go beyond canvas in the next
parts of the tutorial.
The standard extended output stream interface allows us to use the
stream in i.e cl:format
. Such stream maintains the text cursor
position, text style, margins, line break strategy etc. The output
recording interface allows us to format the output before it is put on
the canvas (i.e to layout a graph).
;;; Represents the canvas.
(defclass html5-stream (clim:graft ; sheet - a root window
clim:sheet-mute-input-mixin ; output only
clim:sheet-mute-repainting-mixin ; doesn't repaint itself
clim:permanent-medium-sheet-output-mixin ; has a medium
clim:standard-extended-output-stream ; maintains a text page
clim:standard-output-recording-stream ; records its output
)
())
(defmethod climb:make-graft
((port html5-port) &key (orientation :default) (units :device))
(make-instance 'html5-stream :port port
:mirror (html5-port-stream port)
:orientation orientation
:units units))
A medium is responsible for maintaining the drawing context for a
sheet and the actual drawing operations are specialized on medium.
Display-specific drawing operations are performed on an opaque handler
accessed with a function medium-drawable
.
The reference to a window system object is called a mirror
. We have
two arguments for specialization when creating a medium: the port and
the sheet class. That allows us to handle different kinds of mirrors
on the same port depending on a sheet class.
Initial test function draws a rectangle. To avoid an error we will
implement a single (incomplete) method for drawing a rectangle called
medium-draw-rectangle*
. We will use a slightly modified example from
the crash course from before. The medium is expected to emit a
javascript code which draws on the canvas.
;;; Maintains a html5-stream drawing context.
(defclass html5-medium (clim:basic-medium)
())
(defmethod clim:make-medium ((port html5-port) (sheet html5-stream))
(make-instance 'html5-medium :sheet sheet))
(defmethod clim:medium-draw-rectangle*
((medium html5-medium) x1 y1 x2 y2 filled)
(let ((mirror (clim:medium-drawable medium))
(width (- x2 x1))
(height (- y2 y1))
(op (if filled "fill" "stroke")))
(format mirror
"
var canvas = document.getElementById('McCLIM');
var context = canvas.getContext('2d');
context.beginPath();
context.rect(~f, ~f, ~f, ~f);
context.closePath();
context.~a();"
x1 y1 width height op)))
Now that we have all necessary classes we may create the context in
the function invoke-with-html5-stream
. We wrap the body in a macro
climb:with-port
, which binds the variable to a port found from the
server path. Then we bind the graft and call the continuation on it in
a (:script)
section of the html.
(defun invoke-with-html5-stream (cont stream &rest options)
(declare (ignore options))
(climb:with-port (port :html5 :stream stream)
(let ((html5-stream (clim:find-graft :port port)))
(setf (cl-who:html-mode) :html5)
(cl-who:with-html-output (stream stream :prologue t :indent t)
(:html
(:head)
(:body
(:canvas :id "McCLIM" :width 800 :height 600
:style "border:1px solid #000000")
(:script (funcall cont html5-stream))))))))
Now that we have everything in place it is time to test whether the backend works.
> (ql:quickload "eu.turtleware.clim.html5-backend/examples") > (eu.turtleware.clim.html5-backend.examples:start t)
When you open now the url http://localhost:4242/yo in a web browser which implements html5 canvas you should see a black rectangle. Also in two created windows you should see rectangles with the same size but filled with a blue color. If you check the source of the web page you will see the generated script.
A backend is expected to implement a few medium-specific drawing functions. Higher levels of abstraction will use that for rendering. Proper drawing requires drawing a shape with properties derived from the medium: a line style, a filling style, a text style and a clipping region.
To bring a joy of the interactive development we will introduce a new test function and drawing methods stubs, so after modifying each method we’ll be able to recompile it and refresh the page to see the result immedietely. The new test function will draw each shape in a box normalized to coordinates [0,0] -> [1,1]. Modify the test function in the examples file. For now we will fill two first rows with various figures.
Keep in mind, that even the reference backend may exhibit some invalid behavior, that’s why different outputs (i.e the framebuffer drawing and x11 protocol drawing) may differ. Differences may be also a result of different implementation of the same concepts.
(defun test (stream)
;; draw a grid
(loop for x from 100 upto 700 by 100
do (clim:draw-line* stream x 0 x 600))
(loop for y from 100 upto 600 by 100
do (clim:draw-line* stream 0 y 800 y))
(clim:with-drawing-options (stream :line-thickness 5)
(macrolet ((with-box ((column row) &body body)
"Estabilishes normalized local coordinates."
`(clim:with-translation (stream (* ,column 100) (* ,row 100))
(clim:with-scaling (stream 100 100)
,@body))))
(flet ((test-point ()
(clim:draw-point* stream .5 .5))
(test-points ()
(let* ((coords-1 '(.5 .1 .5 .3 .5 .7 .5 .9))
(coords-2 '(.1 .5 .3 .5 .7 .5 .9 .5))
(coords (append coords-1 coords-2)))
(clim:draw-points* stream coords)))
(test-line ()
(clim:draw-line* stream .1 .1 .9 .9))
(test-lines ()
(let* ((coords-1 '(.1 .1 .9 .9))
(coords-2 '(.1 .9 .9 .1))
(coords (append coords-1 coords-2)))
(clim:draw-lines* stream coords)))
(test-polyline ()
(let* ((coords-1 '(.1 .1 .9 .9))
(coords-2 '(.1 .9 .9 .1))
(coords (append coords-1 coords-2)))
(clim:draw-polygon* stream coords :filled nil :closed t)))
(test-frame ()
(clim:draw-rectangle* stream .1 .1 .9 .9 :filled nil))
(test-frames ()
(let* ((coords-1 '(.1 .1 .5 .5))
(coords-2 '(.3 .3 .9 .9))
(coords (append coords-1 coords-2)))
(clim:draw-rectangles* stream coords :filled nil)))
(test-elliptical-arc ()
;; draw radius lines to make things more apparent
(multiple-value-bind (cx cy rdx1 rdy1 rdx2 rdy2)
(values .5 .5 .3 .3 -.2 .2)
(clim:with-drawing-options (stream :line-thickness 1 :line-dashes t)
(clim:draw-line* stream cx cy (+ cx rdx1) (+ cy rdy1))
(clim:draw-line* stream cx cy (+ cx rdx2) (+ cy rdy2)))
(clim:draw-ellipse* stream cx cy rdx1 rdy1 rdx2 rdy2
:filled nil :start-angle 0 :end-angle (* 3/2 pi))))
(test-polygon ()
(let* ((coords-1 '(.1 .1 .9 .9))
(coords-2 '(.1 .9 .9 .1))
(coords (append coords-1 coords-2)))
(clim:draw-polygon* stream coords :filled t)))
(test-rectangle ()
(clim:draw-rectangle* stream .1 .1 .9 .9 :filled t))
(test-rectangles ()
(let* ((coords-1 '(.1 .1 .5 .5))
(coords-2 '(.3 .3 .9 .9))
(coords (append coords-1 coords-2)))
(clim:draw-rectangles* stream coords :filled t)))
(test-ellipse ()
(multiple-value-bind (cx cy rdx1 rdy1 rdx2 rdy2)
(values .5 .5 .3 .3 -.2 .2)
(clim:draw-ellipse* stream cx cy rdx1 rdy1 rdx2 rdy2
:filled t :start-angle 0 :end-angle (* 3/2 pi))))
(test-text-1 ()
(clim:draw-text* stream "hello world!" .5 .5
:align-x :center :align-y :center))
(test-text-2 ()
(clim:with-rotation (stream (/ pi 4) (clim:make-point .5 .5))
(clim:with-scaling (stream .01 .01 (clim:make-point .5 .5))
(clim:draw-text* stream "hello world!" .5 .5
:align-x :center :align-y :center
:transform-glyphs t))))
(test-text-3 ()
(clim:draw-text* stream "hello world!" 1.0 1.0
:align-x :right :align-y :bottom))
(test-text-4 ()
;; draw a helper line
(clim:with-drawing-options (stream :line-thickness 1 :line-dashes t)
(clim:draw-line* stream .1 .3 .9 .8))
(clim:draw-text* stream "hello world!" .1 .3
:toward-x .9 :toward-y .8)))
(with-box (0 0) (test-point))
(with-box (1 0) (test-points))
(with-box (2 0) (test-line))
(with-box (3 0) (test-lines))
(with-box (4 0) (test-polyline))
(with-box (5 0) (test-frame))
(with-box (6 0) (test-frames))
(with-box (7 0) (test-elliptical-arc))
(with-box (0 1) (test-text-1))
(with-box (1 1) (test-text-2))
(with-box (2 1) (test-text-3))
(with-box (3 1) (test-text-4))
(with-box (4 1) (test-polygon))
(with-box (5 1) (test-rectangle))
(with-box (6 1) (test-rectangles))
(with-box (7 1) (test-ellipse))))))
Also we want to know when something goes wrong, so we will wrap the
html5 stream invocation in the handler-case
operator:
(hunchentoot:define-easy-handler (say-yo :uri "/yo") ()
(flet ((show-window-stream (stream)
(clim:window-clear stream)
(clim:draw-rectangle* stream 0 0 800 600 :filled nil)
(test stream)
(finish-output stream)))
(show-window-stream *stream*)
(show-window-stream *stream-render*))
;; html5 stream
(setf (hunchentoot:content-type*) "text/html")
(handler-case (with-output-to-string (stream)
(html5:with-html5-stream (stream stream)
(test stream)))
(error (c)
(setf (hunchentoot:content-type*) "text/plain")
(format nil "~a" c))))
Finally, to avoid too many errors from the start we will define
“default” methods which do nothing. Since our stream is recording
output it needs to know text dimensions, we will provide a method stub
for the function climb:text-bounding-rectangle*
too.
(defmethod clim:medium-draw-point* ((medium html5-medium) x y))
(defmethod clim:medium-draw-line* ((medium html5-medium) x1 y1 x2 y2))
(defmethod clim:medium-draw-polygon* ((medium html5-medium) coord-seq closed filled))
(defmethod clim:medium-draw-rectangle* ((medium html5-medium) x1 y1 x2 y2 filled))
(defmethod clim:medium-draw-ellipse* ((medium html5-medium) cx cy rdx1 rdy1 rdx2 rdy2
start-angle end-angle filled))
(defmethod climb:medium-draw-text* ((medium html5-medium) string x y start end
align-x align-y toward-x toward-y transform-glyphs))
(defmethod climb:text-bounding-rectangle*
((medium html5-medium) string
&key text-style start end align-x align-y direction)
(declare (ignore medium string text-style start end
align-x align-y direction))
(values 0 0 0 0))
- medium-draw-point* (medium x y)
HTML5 canvas API does not mention points, however it does mention drawing arcs and point is a small circle. We set the radius to .5.
(defmethod clim:medium-draw-point* ((medium html5-medium) x y) (let ((mirror (clim:medium-drawable medium))) (format mirror "context.beginPath();~%") (format mirror "context.arc(~f, ~f, ~f, ~f, ~f);~%" x y .5 0 (* 2 pi)) (format mirror "context.fill();")))
- medium-draw-line* (medium x1 y1 x2 y2)
Drawing a line requires moving the pen first to the line start coordinate. Then we use a function
lineTo
and stoke the path.(defmethod climb:medium-draw-line* ((medium html5-medium) x1 y1 x2 y2) (let ((mirror (clim:medium-drawable medium))) (format mirror "context.beginPath();~%") (format mirror "context.moveTo(~f, ~f);~%" x1 y1) (format mirror "context.lineTo(~f, ~f);~%" x2 y2) (format mirror "context.stroke();~%")))
- medium-draw-polygon* (medium coord-seq closed filled)
Drawing a polygon (or a polyline!) poses little more problem. First we need to move the pen, and then draw lines until
coord-seq
has ended. After that optinally we need to close the path.(defmethod climb:medium-draw-polygon* ((medium html5-medium) coord-seq closed filled) (let ((mirror (clim:medium-drawable medium))) (format mirror "context.beginPath();~%") (loop with coords = (coerce coord-seq 'list) with x1 = (pop coords) with y1 = (pop coords) initially (format mirror "context.moveTo(~f, ~f);~%" x1 y1) for (x y) on coords by #'cddr do (format mirror "context.lineTo(~f, ~f);~%" x y) finally (when closed (format mirror "context.closePath();~%"))) (if filled (format mirror "context.fill();") (format mirror "context.stroke();"))))
- medium-draw-rectangle*
Unlike CLIM canvas’s API requires width and height of the rectangle instead of coordinates of the opposite vertex.
(defmethod clim:medium-draw-rectangle* ((medium html5-medium) x1 y1 x2 y2 filled) (let ((mirror (clim:medium-drawable medium))) (format mirror "context.beginPath();~%") (format mirror "context.rect(~f, ~f, ~f, ~f);~%" x1 y1 (- x2 x1) (- y2 y1)) (if filled (format mirror "context.fill();") (format mirror "context.stroke();~%"))))
- medium-draw-ellipse* (medium cx cy rdx1 rdy1 rdx2 rdy2
start-angle end-angle filled)
Drawing an ellipse as it is specified is a bit problematic. Radiuses are not necessarily orthogonal, the ellipse may be rotated and have the start-angle and the end-angle[fn:1]. The canvas API has the function
arc
, however it is used only for drawing circles. Fortunetely for us we may apply a transformation to the canvas to draw the ellipse. This approach is not entirely right, but we’ll focus on correcting that in a separate section.I’ll use the CLIM internal function to reparametrize an ellipse to have more drawing-friendly representation: two scalar radiuses and a rotation. Then we’ll use that to apply the necessary tranformations, correct angles and to draw the circle.
(defmethod clim:medium-draw-ellipse* ((medium html5-medium) cx cy rdx1 rdy1 rdx2 rdy2 start-angle end-angle filled) (let ((mirror (clim:medium-drawable medium))) (format mirror "context.beginPath();~%") (multiple-value-bind (a b theta) (climi::reparameterize-ellipse rdx1 rdy1 rdx2 rdy2) (format mirror "context.save();~%") (format mirror "context.translate(~f, ~f);~%" cx cy) (format mirror "context.rotate(~f);~%" theta) (format mirror "context.scale(~f, ~f);~%" (/ a b) -1) (format mirror "context.arc(~f, ~f, ~f, ~f, ~f, false);~%" 0 0 b (+ start-angle theta) (+ end-angle theta)) (format mirror "context.restore();~%")) (if filled (progn (format mirror "context.lineTo(~f, ~f);~%" cx cy) (format mirror "context.fill();~%")) (format mirror "context.stroke();~%"))))
- medium-draw-text*
The following functions have default methods built on top of the above:
- [X] medium-draw-points* (medium coord-seq)
- [X] medium-draw-lines* (medium coord-seq)
- [X] medium-draw-rectangles* (medium coord-seq filled)
CLIM II specification does not specify the filled argument present in
some functions, however for instance it mentions that
medium-draw-polygon*
draws a polygon or a polyline. From that
we’ve assumed that it is a missing argument and McCLIM includes it.
To avoid repeated code we’ll move the variable assignment for canvas
directly into invoke-with-html5-stream
function.
(:script
(fresh-line stream)
(format stream "var canvas = document.getElementById('McCLIM');~%")
(format stream "var context = canvas.getContext('2d');~%")
(funcall cont html5-stream))
All coordinates are printed as floats with the format
directive
f
. This way “our” numbers will be accepted by javascript.
[fn:1] Angles are measured in radians and are counter-clockwise in graphics coordinate system, that is when the positive Y axis grows upwards (in right-handed coordinate system). Since our coordinate system is left-handed (because Y grows downards), we may treat angles as being directed clockwise. bla bla, this needs a better description.