Schmavery / reprocessing

ReasonML graphics library inspired by Processing
https://schmavery.github.io/reprocessing/
MIT License
683 stars 24 forks source link

Drawing interface design #57

Closed lpil closed 5 years ago

lpil commented 6 years ago

Hello! Thank you for this excellent library. I had some thoughts on API design that I'd love to discuss.

The original processing library works by mutating some global state.

pushMatrix();
translate(xSize, xSize, env);
rotate(45);
translate(xSize, ySize);
rect(xSize, ySize, env);
popMatrix(env)

In this library we have removed the global mutable state by passing around an env data structure.

Draw.pushMatrix(env);
Draw.translate(~x=xSize, ~y=xSize, env);
Draw.rotate(45, env);
Draw.translate(~x=xSize, ~y=ySize, env);
Draw.rect(~width=xSize, ~height=ySize, env);
Draw.popMatrix(env);

This is great because we've removed the global mutable state, but we still have localised mutable state and an API that is near identical to the procedural Java-style Processing API.

I'd like to propose that we remove the env state and adopt a pure functional and composable API.

Peter Henderson's 1982 paper "Functional Geometry" (here explained in John Hughes and Mary Sheeran's Lambda Days keynote https://youtu.be/1qBHf8DrWR8?t=1030) shows us how we build an equally powerful interface with just pure functions.

Elm's Graphics library (which predates Elm the language, interestingly) provides a similar API.

The above code re-designed could look something like this:

let shape =
  Draw.rect(~width=xSize, ~height=ySize)
  |> Draw.translate(~x=xSize, ~y=xSize)
  |> Draw.rotate(45)
  |> Draw.translate(~x=xSize, ~y=ySize);
Draw.render(shape);

Here Draw.rect doesn't plot a rectangle, instead it just returns a data shape structure. Each function that sets a colour, changes the drawing position, or transforms the drawing just immutably update that structure. Finally we give it to a render function which iterates the data structure and then mutates the canvas or whatever we're drawing to.

To me this seems like a much friendlier API. The definition of the drawing has been split from the implementation, and we can use all the nice properties of persistent immutable data structures that we enjoy elsewhere in our Reason code.

Thanks for reading! What are you thoughts? :)

Schmavery commented 6 years ago

Hey @lpil, thanks for checking out Reprocessing and taking the time to write this. One thing I like to do in these design situations is try to ask myself what problem I'm trying to solve.

Upon reading this issue, it seemed like the concrete problem you were expressing was something along the lines of matrix operations being unwieldy and hard to manage, especially because something simple like a rotation can take a couple of lines and the pollutes the rest of your calls if you forget to popMatrix. Feel free to correct me if I misinterpreted the problem you were conveying.

The problem of matrix ops being complicated is one that we've been thinking a fair bit about lately, because it's always surprisingly difficult to express a simple rotation etc. It would be really nice to have a simpler API. I frequently get confused about what order I should be translating/scaling/rotating...

Your idea of fixing this problem by allowing "scoped" matrix changes using a similar api is pretty neat. I'm worried about the following problems:

In talking about this with @bsansouci, we were liking the idea of adding one or two extra optional arguments where necessary to draw calls to make explicit matrix and style modifications almost unneeded in most code.

Draw.rect(~pos=(x, y), ~width=xSize, ~height=ySize, ~rotate=40, ~rotationCenter=(0, 0), env);

Draw.rect(
  ~width=xSize,
  ~height=ySize,
  ~fill=Constants.red,
  ~strokeColor=Constants.black,
  ~strokeWeight=5,
  ~strokeCap=Round,
  env
);

This would have the benefits of:

One might argue that the function calls will have too many arguments, but I don't think it will get to an absurd level (and would question whether or not having a lot of optional args is a big problem). One way or another, you need to express all of these properties, so this is about as concise as possible.

We would keep around the old matrix api as well to give people the option of having more power in exchange for a more complicated interface, but it wouldn't be used nearly as often.

ncthbrt commented 6 years ago

What about including a paint structure? This would allow you to specify the drawing properties such as fill, stroke, etc as an data structure and passing it into draw commands. Skia does this and it is a really nice feature.

var paint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Blue,
        StrokeWidth = 10,
        StrokeCap = SKStrokeCap.Round,
        StrokeJoin = SKStrokeJoin.Round
    };

An alternative would be making transformations, layers and styles part of a lamda expression. It makes it's clear when styles and transformations are being applied. I took this approach in a prototype I made for F# a while back. It composes well too: translate(~x=100, ~y=-10, (nestedEnv) => { /* draw here */ }, env);

pixelprizm commented 6 years ago

I'd like to propose that we remove the env state and adopt a pure functional and composable API.

I think that there's some value for Reprocessing to have as similar an API to the original Processing as possible, to ensure that there are no limitations. Maybe it would be best to leave it to another library maker to build a pure functional library that wraps the Reprocessing API using managed side effects, similar to Elm, so that users of this pure library could write only pure functions.

lpil commented 6 years ago

How does adopting a similar procedural and mutable API ensure there are no limitations? Functional programming is no less expressive that procedural programming, they are equally capable.

bsansouci commented 6 years ago

Let’s not go down the rabbit hole of imperative vs functional ;) Unless anyone can think of something bad about the API @schmavery proposed, we’d love some help in implementing it. I think it would improve the usability of Reprocessing with barely any compromise!

Edit: just to be clear, @lpil the points you brought up were useful for us to come up with some improvements, so thanks for that.

ncthbrt commented 6 years ago

Let’s not go down the rabbit hole of imperative vs functional ;)

I agree with that. But I still think that scoped transformations/styling even if imperative is less error-prone than having to worry about pushing/popping while being just as powerful. Having to specify transforms/styling on each draw call could in contrast get quite unwieldy. I'd hate to implement something like L-Systems without having some sort of nested transform stack. (Of course nothing is stopping both approaches from being available, and @Schmavery's approach may be more ergonomic in many cases)

A final point is that this approach is not without precedent, as the withImage API is also scoped as well.

Example of proposed vs current API:

translate(~x=100,~y=-10, env, (nestedEnv) => { /* draw here */ });

vs

pushMatrix(env);
translate(~x=100,~y=-10, env);
/* draw here */
popMatrix(env);

A naive implementation of this scoped translate could be as simple as:

let translate = (~x=0, ~y=0, env, f) => {
    pushMatrix(env);
    translateInternal(x,y, env);
    f(env);
    popMatrix(env);
};