diff --git a/site/static/drawings/experiments/open-book.svg b/site/static/drawings/experiments/open-book.svg
new file mode 100644
index 0000000..28918fe
--- /dev/null
+++ b/site/static/drawings/experiments/open-book.svg
@@ -0,0 +1 @@
+
diff --git a/site/static/drawings/experiments/ouroboros.svg b/site/static/drawings/experiments/ouroboros.svg
new file mode 100644
index 0000000..d0e34c1
--- /dev/null
+++ b/site/static/drawings/experiments/ouroboros.svg
@@ -0,0 +1 @@
+
diff --git a/site/static/drawings/experiments/quill.svg b/site/static/drawings/experiments/quill.svg
new file mode 100644
index 0000000..60cd5df
--- /dev/null
+++ b/site/static/drawings/experiments/quill.svg
@@ -0,0 +1 @@
+
diff --git a/site/static/drawings/experiments/serpent-spiral.svg b/site/static/drawings/experiments/serpent-spiral.svg
new file mode 100644
index 0000000..18004fa
--- /dev/null
+++ b/site/static/drawings/experiments/serpent-spiral.svg
@@ -0,0 +1 @@
+
diff --git a/tools/gleam.toml b/tools/gleam.toml
index e37a0d6..26c465e 100644
--- a/tools/gleam.toml
+++ b/tools/gleam.toml
@@ -6,6 +6,7 @@ target = "erlang"
[dependencies]
gleam_stdlib = ">= 1.0.0 and < 2.0.0"
gleam_json = ">= 3.1.0 and < 4.0.0"
+svg_path = ">= 3.1.1 and < 4.0.0"
[dev_dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
diff --git a/tools/manifest.toml b/tools/manifest.toml
index 17e868c..db54699 100644
--- a/tools/manifest.toml
+++ b/tools/manifest.toml
@@ -1,13 +1,20 @@
-# This file was generated by Gleam
-# You typically do not need to edit this file
+# Do not manually edit this file, it is managed by Gleam.
+#
+# This file locks the dependency versions used, to make your build
+# deterministic and to prevent unexpected versions from being included
+# in your application.
+#
+# You should check this file into your source control repository.
packages = [
{ name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
{ name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" },
{ name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" },
+ { name = "svg_path", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "svg_path", source = "hex", outer_checksum = "017D52D40F6AF71AEDD8751272F0C6E0C4A0D35708A982F9D22873E831574685" },
]
[requirements]
gleam_json = { version = ">= 3.1.0 and < 4.0.0" }
gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+svg_path = { version = ">= 3.1.1 and < 4.0.0" }
diff --git a/tools/src/fabric.gleam b/tools/src/fabric.gleam
index 35efc2c..93bc528 100644
--- a/tools/src/fabric.gleam
+++ b/tools/src/fabric.gleam
@@ -6,6 +6,7 @@ import fabric/book_check
import fabric/citation
import fabric/cite_check
import fabric/content
+import fabric/drawings
import fabric/logos
import fabric/notes_check
import fabric/ouroboric
@@ -24,6 +25,7 @@ fn commands() -> List(#(String, fn(List(String)) -> Int)) {
#("citation", citation.main),
#("cite-check", cite_check.main),
#("content", content.main),
+ #("drawings", drawings.main),
#("logos", logos.main),
#("notes-check", notes_check.main),
#("ouroboric", ouroboric.main),
diff --git a/tools/src/fabric/drawings.gleam b/tools/src/fabric/drawings.gleam
new file mode 100644
index 0000000..bebbcc8
--- /dev/null
+++ b/tools/src/fabric/drawings.gleam
@@ -0,0 +1,286 @@
+//// drawings: write experimental one-line drawings to site/static/drawings/experiments/.
+////
+//// Each figure is built in its own coordinates with svg_path (MIT, vistuleB/svg_path v3.1.1),
+//// then measured (length, bounds) and moved into the house canvas: viewBox 0 0 360 240, stroke
+//// 3.2, round caps and joins, no fill, a broken frame. The drawings are original, in the manner
+//// of the drawings on picasso's site; none traces or copies a work by Pablo Picasso.
+////
+//// tools/fabric drawings
+
+import fabric/sys
+import gleam/float
+import gleam/list
+import gleam/string
+import svg_path.{type Path, type Point, Point}
+import svg_path/basic_shapes
+import svg_path/bounds
+import svg_path/fit
+import svg_path/measure
+import svg_path/point
+import svg_path/serialize
+import svg_path/transform
+
+pub const dir = "site/static/drawings/experiments"
+
+/// The box the figure is scaled into, inside the 360 x 240 canvas.
+const box_w = 250.0
+
+const box_h = 164.0
+
+/// A drawing: its file name, what its
says, and its paths in canvas coordinates.
+pub type Drawing {
+ Drawing(name: String, title: String, path: Path)
+}
+
+pub fn all() -> List(Drawing) {
+ [
+ drawing("ouroboros", "A snake that bites its tail", ouroboros()),
+ drawing(
+ "serpent-spiral",
+ "A serpent coiled in a spiral, the eternal cycle",
+ spiral(),
+ ),
+ drawing("quill", "A quill pen and a drop of ink", quill()),
+ drawing("open-book", "An open book", book()),
+ ]
+}
+
+fn drawing(name: String, what: String, figure: Path) -> Drawing {
+ let title =
+ what
+ <> ", one line: an original drawing in the manner of the drawings on picasso's site"
+ Drawing(name, title, svg_path.path_combine([place(figure), frame()]))
+}
+
+/// Scale the figure by its exact bounds to fill the box, and centre it on the canvas.
+fn place(figure: Path) -> Path {
+ let assert Ok(b) = bounds.path_bounding_box(figure)
+ let c = bounds.bounding_box_center(b)
+ let s =
+ float.min(
+ box_w /. bounds.bounding_box_width(b),
+ box_h /. bounds.bounding_box_height(b),
+ )
+ let m =
+ transform.translate(x: 0.0 -. c.x, y: 0.0 -. c.y)
+ |> transform.chain(then: transform.scale(s))
+ |> transform.chain(then: transform.translate(x: 180.0, y: 120.0))
+ let assert Ok(placed) = transform.path(figure, by: m)
+ placed
+}
+
+/// Two corners of a frame that stop short, as on the drawings of picasso's site.
+fn frame() -> Path {
+ svg_path.Path([
+ svg_path.subpath_assert_polyline([
+ p(28.0, 74.0),
+ p(28.0, 22.0),
+ p(92.0, 22.0),
+ ]),
+ svg_path.subpath_assert_polyline([
+ p(268.0, 218.0),
+ p(332.0, 218.0),
+ p(332.0, 166.0),
+ ]),
+ ])
+}
+
+pub fn svg(d: Drawing) -> String {
+ "\n"
+}
+
+fn p(x: Float, y: Float) -> Point {
+ Point(x, y)
+}
+
+fn polar(r: Float, degrees: Float) -> Point {
+ point.scale(point.direction(degrees:), by: r)
+}
+
+fn pow(x: Float, e: Float) -> Float {
+ let assert Ok(y) = float.power(x, e)
+ y
+}
+
+/// A head drawn in a frame where the neck is at x = 0 and spans y = -1 (outside) to 1 (inside);
+/// x points forward. The jaws are open; `tongue` adds a forked tongue.
+fn head(tongue: Bool) -> List(svg_path.Subpath) {
+ let k = fn(x, y) { p(x /. 11.0, y /. 11.0 -. 1.0) }
+ let outline =
+ svg_path.subpath_assert([
+ svg_path.CubicBezier(
+ k(0.0, 0.0),
+ k(10.0, -10.0),
+ k(28.0, -12.0),
+ k(40.0, -2.0),
+ ),
+ svg_path.QuadraticBezier(k(40.0, -2.0), k(28.0, 4.0), k(14.0, 10.0)),
+ svg_path.QuadraticBezier(k(14.0, 10.0), k(26.0, 18.0), k(36.0, 28.0)),
+ svg_path.CubicBezier(
+ k(36.0, 28.0),
+ k(24.0, 34.0),
+ k(8.0, 30.0),
+ k(0.0, 22.0),
+ ),
+ ])
+ let assert Ok(eye) =
+ basic_shapes.circle(cx: 16.0 /. 11.0, cy: -0.5 /. 11.0 -. 1.0, r: 0.3)
+ case tongue {
+ False -> [outline, eye]
+ True -> [
+ outline,
+ eye,
+ svg_path.subpath_assert_polyline([
+ k(14.0, 10.0),
+ k(46.0, 10.0),
+ k(53.0, 5.0),
+ ]),
+ svg_path.subpath_assert_polyline([k(46.0, 10.0), k(53.0, 15.0)]),
+ ]
+ }
+}
+
+/// Put the head at the end of a line: its pose comes from the line's length, end point and
+/// tangent there, so the head follows the body whatever its shape. `half` is the neck's half
+/// width; the neck's outer edge is at `edge` (or the centre when the body is one line).
+fn head_at_end(
+ line: svg_path.Subpath,
+ half: Float,
+ from_edge: Bool,
+ tongue: Bool,
+) -> List(svg_path.Subpath) {
+ let assert Ok(len) = measure.subpath_length(line)
+ let assert Ok(end) = measure.subpath_point_at_length(line, distance: len)
+ let assert Ok(dir) = measure.subpath_derivative_at_length(line, distance: len)
+ let heading = point.heading(dir)
+ let shift = case from_edge {
+ True -> half
+ False -> 0.0
+ }
+ let m =
+ transform.translate(x: 0.0, y: shift /. half)
+ |> transform.chain(then: transform.scale(half))
+ |> transform.chain(then: transform.rotate(degrees: heading))
+ |> transform.chain(then: transform.translate(x: end.x, y: end.y))
+ list.map(head(tongue), fn(sp) {
+ let assert Ok(moved) = transform.subpath(sp, by: m)
+ moved
+ })
+}
+
+/// The ouroboros: a body that thins from the neck to the tail, drawn as one line (the inner
+/// edge out to the tail, the outer edge back), whose tail ends in the open mouth.
+fn ouroboros() -> Path {
+ let r = 100.0
+ let neck = -70.0
+ let tail = neck -. 342.0
+ let half = 16.0
+ let w = fn(t) {
+ half *. { 1.0 -. pow(1.0 -. { t -. tail } /. { neck -. tail }, 3.0) }
+ }
+ let assert Ok(inner) =
+ fit.subpath_from_parametric(from: neck, to: tail, point: fn(t) {
+ polar(r -. w(t), t)
+ })
+ let assert Ok(outer) =
+ fit.subpath_from_parametric(from: tail, to: neck, point: fn(t) {
+ polar(r +. w(t), t)
+ })
+ let body =
+ svg_path.subpath_assert(list.append(
+ svg_path.subpath_segments(inner),
+ svg_path.subpath_segments(outer),
+ ))
+ svg_path.Path([body, ..head_at_end(outer, half, True, False)])
+}
+
+/// The eternal cycle: an Archimedean spiral from a still centre out to a head with a tongue.
+fn spiral() -> Path {
+ let turns = 2.6
+ let assert Ok(coil) =
+ fit.subpath_from_parametric(from: 0.0, to: turns *. 360.0, point: fn(a) {
+ polar(4.0 +. a /. 360.0 *. 30.0, a)
+ })
+ svg_path.Path([coil, ..head_at_end(coil, 10.0, False, True)])
+}
+
+/// A quill, its vane drawn as one line with two notches, its shaft ending in a split nib.
+fn quill() -> Path {
+ let c = fn(a, b, cc, d) { svg_path.CubicBezier(a, b, cc, d) }
+ let l = fn(a, b) { svg_path.Line(a, b) }
+ let vane =
+ svg_path.subpath_assert([
+ c(p(46.0, -3.0), p(80.0, -30.0), p(170.0, -52.0), p(214.0, -44.0)),
+ l(p(214.0, -44.0), p(200.0, -36.0)),
+ c(p(200.0, -36.0), p(232.0, -40.0), p(262.0, -34.0), p(282.0, -22.0)),
+ c(p(282.0, -22.0), p(240.0, 4.0), p(186.0, 16.0), p(150.0, 14.0)),
+ l(p(150.0, 14.0), p(162.0, 6.0)),
+ c(p(162.0, 6.0), p(120.0, 16.0), p(80.0, 12.0), p(52.0, 3.0)),
+ ])
+ let shaft =
+ svg_path.subpath_assert([
+ c(p(0.0, 0.0), p(90.0, -4.0), p(200.0, -14.0), p(282.0, -22.0)),
+ ])
+ let nib =
+ svg_path.subpath_assert_polyline([p(0.0, 0.0), p(-26.0, 4.0), p(4.0, 6.0)])
+ let quill_path =
+ svg_path.Path([vane, shaft, nib])
+ |> transform.path(by: transform.rotate(degrees: -28.0))
+ let assert Ok(quill_path) = quill_path
+ let assert Ok(drop) =
+ basic_shapes.ellipse(cx: -30.0, cy: 36.0, rx: 5.0, ry: 7.0)
+ svg_path.path_append_subpath(quill_path, drop)
+}
+
+/// An open book: the left page is drawn once and mirrored for the right.
+fn book() -> Path {
+ let c = fn(a, b, cc, d) { svg_path.CubicBezier(a, b, cc, d) }
+ let page =
+ svg_path.Path([
+ svg_path.subpath_assert([
+ c(p(0.0, 0.0), p(-40.0, -22.0), p(-100.0, -22.0), p(-140.0, -6.0)),
+ svg_path.Line(p(-140.0, -6.0), p(-140.0, 120.0)),
+ c(p(-140.0, 120.0), p(-100.0, 104.0), p(-40.0, 104.0), p(0.0, 126.0)),
+ ]),
+ text_line(-22.0, 18.0, 82.0),
+ text_line(-22.0, 42.0, 96.0),
+ text_line(-22.0, 66.0, 70.0),
+ ])
+ let assert Ok(right) =
+ transform.path(page, by: transform.scale_xy(x: -1.0, y: 1.0))
+ let spine = svg_path.subpath_assert_polyline([p(0.0, 0.0), p(0.0, 126.0)])
+ svg_path.path_combine([page, right, svg_path.subpath_as_path(spine)])
+}
+
+/// A line of text on the left page, bent like the page.
+fn text_line(x: Float, y: Float, w: Float) -> svg_path.Subpath {
+ svg_path.subpath_assert([
+ svg_path.QuadraticBezier(
+ p(x, y),
+ p(x -. w /. 2.0, y -. 10.0),
+ p(x -. w, y -. 6.0),
+ ),
+ ])
+}
+
+pub fn main(_args: List(String)) -> Int {
+ let assert Ok(_) = sys.make_dirs(dir)
+ list.each(all(), fn(d) {
+ let file = sys.join(dir, d.name <> ".svg")
+ let assert Ok(_) = sys.write(file, svg(d))
+ sys.eprintln("drawings: wrote " <> file)
+ })
+ 0
+}
diff --git a/tools/test/drawings_test.gleam b/tools/test/drawings_test.gleam
new file mode 100644
index 0000000..303e584
--- /dev/null
+++ b/tools/test/drawings_test.gleam
@@ -0,0 +1,33 @@
+import fabric/drawings
+import gleam/list
+import gleam/string
+import svg_path/bounds
+
+pub fn every_drawing_fits_the_canvas_with_a_margin_test() {
+ list.each(drawings.all(), fn(d) {
+ let assert Ok(b) = bounds.path_bounding_box(d.path)
+ // 8 units of margin: more than half the stroke, so no round cap is cut off.
+ assert b.min.x >=. 8.0
+ assert b.min.y >=. 8.0
+ assert b.max.x <=. 352.0
+ assert b.max.y <=. 232.0
+ })
+}
+
+pub fn every_drawing_is_in_the_house_manner_test() {
+ assert list.length(drawings.all()) == 4
+ list.each(drawings.all(), fn(d) {
+ let s = drawings.svg(d)
+ assert string.contains(s, "viewBox=\"0 0 360 240\"")
+ assert string.contains(
+ s,
+ "stroke-width:3.2;stroke-linecap:round;stroke-linejoin:round",
+ )
+ assert string.contains(s, "fill:none")
+ assert string.contains(s, "" <> d.title <> "")
+ assert string.contains(
+ d.title,
+ "an original drawing in the manner of the drawings on picasso's site",
+ )
+ })
+}