Project

roughrb

0.0
The project is in a healthy, maintained state
Ruby port of rough.js - creates graphics with a hand-drawn, sketchy appearance. SVG output only.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 5.0
~> 13.0
~> 1.0
 Project Readme

Roughrb

Roughrb is a Ruby graphics library that lets you draw in a sketchy, hand-drawn-like style. It defines primitives to draw lines, curves, arcs, polygons, circles, and ellipses. It also supports drawing SVG paths.

This is a Ruby port of rough.js by Preet Shihn. SVG output only, zero runtime dependencies.

Install

Add to your Gemfile:

gem "roughrb"

Or install directly:

gem install roughrb

Usage

Every drawing method on Rough::SVG returns an XML string fragment — a <g> element containing one or more <path> elements. These fragments are meant to be concatenated with + and placed inside an SVG document.

require "rough"

svg = Rough::SVG.new
svg.rectangle(10, 10, 200, 200, seed: 42)
# => "<g><path d=\"M...\" stroke=\"#000\" stroke-width=\"1\" fill=\"none\"/></g>"

Use + to combine multiple shapes into a single string, then wrap them in a complete SVG document with Rough::SVG.document:

doc = Rough::SVG.document(400, 240) do |svg|
  svg.rectangle(10, 10, 200, 200, seed: 42)
end
File.write("output.svg", doc)

The block should return the XML string to embed — either a single fragment or several concatenated with +.

Rectangle

Lines and Ellipses

Rough::SVG.document(450, 200) do |svg|
  svg.circle(80, 120, 50, seed: 42) +          # centerX, centerY, diameter
  svg.ellipse(300, 100, 150, 80, seed: 42) +   # centerX, centerY, width, height
  svg.line(80, 120, 300, 100, seed: 42)         # x1, y1, x2, y2
end

Lines and Ellipses

Filling

Rough::SVG.document(320, 220) do |svg|
  svg.circle(50, 50, 80, seed: 1, fill: "red") +
  svg.rectangle(120, 15, 80, 80, seed: 1, fill: "red") +
  svg.circle(50, 150, 80, seed: 1,
    fill: "rgb(10,150,10)",
    fill_weight: 3                                     # thicker fill lines
  ) +
  svg.rectangle(220, 15, 80, 80, seed: 1,
    fill: "red",
    hachure_angle: 60,                                 # angle of hachure
    hachure_gap: 8
  ) +
  svg.rectangle(120, 105, 80, 80, seed: 1,
    fill: "rgba(255,0,200,0.2)",
    fill_style: "solid"                                # solid fill
  )
end

Filling

Fill Styles

Fill styles: hachure (default), solid, zigzag, cross-hatch, dots, dashed, zigzag-line

styles = %w[hachure solid zigzag cross-hatch dots dashed zigzag-line]

Rough::SVG.document(600, 120) do |svg|
  styles.map.with_index do |style, i|
    svg.rectangle(10 + i * 82, 10, 70, 70, seed: 42, fill: "steelblue", fill_style: style)
  end.join
end

Fill Styles

Sketching Style

Rough::SVG.document(320, 120) do |svg|
  svg.rectangle(15, 15, 80, 80, seed: 1, roughness: 0.5, fill: "red") +
  svg.rectangle(120, 15, 80, 80, seed: 1, roughness: 2.8, fill: "blue") +
  svg.rectangle(220, 15, 80, 80, seed: 1, bowing: 6, stroke: "green", stroke_width: 3)
end

Sketching Style

SVG Paths

Rough::SVG.document(350, 320) do |svg|
  svg.path("M80 80 A 45 45, 0, 0, 0, 125 125 L 125 80 Z", seed: 1, fill: "green") +
  svg.path("M230 80 A 45 45, 0, 1, 0, 275 125 L 275 80 Z", seed: 1, fill: "purple") +
  svg.path("M80 230 A 45 45, 0, 0, 1, 125 275 L 125 230 Z", seed: 1, fill: "red") +
  svg.path("M230 230 A 45 45, 0, 1, 1, 275 275 L 275 230 Z", seed: 1, fill: "blue")
end

SVG Paths

Curves

Rough::SVG.document(400, 200) do |svg|
  svg.curve([[10, 100], [100, 10], [200, 100], [300, 10], [390, 100]], seed: 42) +
  svg.curve([[10, 150], [100, 190], [200, 130], [300, 190], [390, 150]], seed: 42, fill: "coral")
end

Curves

Polygons

Rough::SVG.document(400, 200) do |svg|
  svg.polygon([[50, 10], [150, 10], [180, 80], [100, 150], [20, 80]], seed: 42, fill: "khaki") +
  svg.polygon([[250, 20], [350, 50], [370, 150], [280, 180], [220, 100]], seed: 42,
    fill: "lavender", fill_style: "cross-hatch")
end

Polygons

Arcs

Rough::SVG.document(400, 200) do |svg|
  svg.arc(100, 100, 150, 150, 0, Math::PI, closed: true, seed: 42, fill: "tomato") +
  svg.arc(300, 100, 150, 150, Math::PI, Math::PI * 2, closed: true, seed: 42, fill: "dodgerblue")
end

Arcs

Full Composition

Rough::SVG.document(500, 350) do |svg|
  svg.rectangle(0, 0, 500, 200, seed: 10, fill: "lightskyblue", fill_style: "solid", stroke: "none") +
  svg.rectangle(0, 200, 500, 150, seed: 11, fill: "olivedrab", fill_style: "solid", stroke: "none") +
  svg.rectangle(150, 120, 200, 150, seed: 42, fill: "burlywood", stroke_width: 2) +
  svg.polygon([[140, 120], [250, 40], [360, 120]], seed: 42, fill: "firebrick", stroke_width: 2) +
  svg.rectangle(220, 190, 60, 80, seed: 42, fill: "saddlebrown") +
  svg.rectangle(170, 160, 40, 40, seed: 42, fill: "lightyellow") +
  svg.rectangle(290, 160, 40, 40, seed: 42, fill: "lightyellow") +
  svg.circle(430, 60, 60, seed: 42, fill: "gold") +
  svg.rectangle(60, 180, 20, 70, seed: 42, fill: "saddlebrown") +
  svg.circle(70, 160, 70, seed: 42, fill: "forestgreen")
end

Composition

Using the Generator Directly

If you want the raw path data without SVG markup, use Rough::Generator:

gen = Rough::Generator.new
drawable = gen.rectangle(10, 10, 200, 100, seed: 42)
paths = gen.to_paths(drawable)

paths.each do |path_info|
  puts path_info.d           # SVG path "d" attribute
  puts path_info.stroke      # stroke color
  puts path_info.stroke_width
  puts path_info.fill
end

Options

Option Default Description
roughness 1 Numerical value indicating how rough the drawing is. 0 = architect, higher = rougher
bowing 1 Numerical value indicating how much bowing is in the lines
stroke "#000" Color of the drawn lines
stroke_width 1 Width of the drawn lines
fill nil Fill color. When set, shapes are filled
fill_style "hachure" Fill style: hachure, solid, zigzag, cross-hatch, dots, dashed, zigzag-line
fill_weight -1 Weight of fill lines. -1 = half of stroke_width
hachure_angle -41 Angle of hachure lines in degrees
hachure_gap -1 Gap between hachure lines. -1 = stroke_width * 4
seed 0 Seed for the random number generator. Same seed = same drawing. 0 = non-deterministic
disable_multi_stroke false Don't draw each line twice (less hand-drawn look but faster)
preserve_vertices false Don't randomize endpoints

Seeds and reproducibility

Every shape method accepts a seed: option. Same seed, same input, same output — every time.

svg.rectangle(0, 0, 100, 100, seed: 42)   # always the same sketch
svg.rectangle(0, 0, 100, 100, seed: 42)   # identical
svg.rectangle(0, 0, 100, 100, seed: 43)   # subtly different
svg.rectangle(0, 0, 100, 100)             # different on every call

Without an explicit seed:, output uses Kernel#rand and changes on every render.

Reproducible scenes with random:

For a whole scene to be reproducible from a single integer, pass a stateful Random (or a seed: integer) to the constructor. Each subsequent shape pulls a fresh per-shape seed from it, so consecutive shapes look different from each other but the entire scene replays identically when the same Random seed is used:

svg = Rough::SVG.new(random: Random.new(42))
# or, equivalently:
svg = Rough::SVG.new(seed: 42)

doc = Rough::SVG.document(400, 200) do |svg|
  svg.circle(80, 100, 80) +              # picks seed off the rng
    svg.rectangle(180, 60, 80, 80) +     # picks the next seed
    svg.line(20, 20, 380, 180)           # and so on
end

The random: keyword matches the convention of Array#shuffle(random:) and Array#sample(random:). Pass either random: or seed: — passing both raises ArgumentError. An explicit per-shape seed: always wins and does not consume entropy from the scene Random, so you can override one shape without disturbing the rest of the scene.

Parity with rough.js

When you supply the same seed: to a roughrb shape and a rough.js shape with otherwise identical options, the resulting move / bcurveTo / lineTo operations are byte-identical. The Rough::Random class implements rough.js's Park–Miller LCG (Math.imul(48271, seed) & 0x7FFFFFFF) exactly, and every renderer/filler routes through it. The test/test_parity_regression.rb suite locks this in with ~16k assertions against fixture data captured from rough.js.

There is one deliberate exception: fill_style: "dots". rough.js's dot filler calls Math.random() unconditionally, so even with a seed its dot positions vary between renders. roughrb instead routes dot positions through the seeded randomizer, so seeded dot fills are reproducible in roughrb but will not match rough.js's dot output. Unseeded dots fall through to Kernel#rand and stay stochastic, matching rough.js's behaviour for unseeded use.

Credits

Ruby port based on rough.js by Preet Shihn.

Core algorithms adapted from handy processing lib. Arc-to-cubic conversion adapted from Mozilla codebase.

License

MIT License