(loop (print (eval (read))))

;;닭집을 차리기 위한 여정

quil 가지고 놀기

클로져 브릿지 행사를 마치고

귀차니즘의 영향으로 분명 늦은 포스팅이지만 상당히 인상깊은 행사였다고 생각하며 꼭 블로그에 글 써야지 하고 생각했던 행사였다.
사람들이 정말 전통적인 소프트웨어 강의를 얼마나 익숙치 않아하고 반대로 눈에 보이는 그래픽-한 과제에는 흥미를 보이는구나.
생각해보면 이런 종류의 교육용 커리큘럼은 plt-racket에 이미 있었고 꽤 흥미로웠던 기억이 난다.
그런데 plt-racket에 있는 http2/image 패키지랑은 다르게 quil은 좀 더 낮은 api만 제공하므로 이 글을 쓰는 계기가 됨.

quil로 도형 그리기

1
lein new quil foo

정도의 명령어로 quil 라이브러리가 포함되고 실행만 하면 되는 얼개가 구현된 상태의 quil 프로젝트가 만들어진다.

기본적으로 생성되는 프로젝트는 사실 너무 복잡하므로 그냥 도형만 그려보는 것으로 수정..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(ns foo.core
(:require [quil.core :refer :all]
[quil.middleware :as m]))
(defn setup []
(frame-rate 30)
(color-mode :rgb))
(defn draw-state [state]
(background 255 255 255)
(fill 255 0 0) ;; red
(rect 75 75 50 50))
(defsketch foo
:title "foo"
:size [200 200]
:setup setup
:draw draw-state
:features [:keep-on-top]
:middleware [m/fun-mode])
red_rect.png

일단은 여기서 draw-state 함수만 고치면 뭔가를 그려볼 수 있는 상태가 됨.
라켓의 http2/image 패키지에는 여러 레이아웃 함수들이 있어 편리하다. 그래서 그런걸 만들어보려는 마음이 생겼다.

처음 만들어 볼 것은 여러 모양을 가로로 늘어놓는 모양을 만드는 beside를 만들어보자.
그럴려면 모든 모양들은 아직 그려지지 않은 상태에서 그려지기 위한 데이터를 가지고 있어야 하며, 또한 한 가지 원칙으로 그려져야 괜찮을 것 같다-예를들어 삼각형은 각 꼭지점의 위치를 인자로 줘야 하는데 사각형과 마찬가지로 그냥 좌-상단위치와 가로길이,세로길이 를 인자로 받도록 하는게 더 좋을듯.

그럼 그리기! 함수를 한번 생각해보면 그리기!는 어떤 모양을 그리든지 나머지는 모양이 가진 데이터에 기초해서 그리되 함께 넘겨받은 left 위치와 top 위치 인자를 가지고 그리도록 하는게 좋겠다.

1
2
3
4
5
6
7
(defn 그리기!
"좌-상단 위치를 기준으로 모양을 그린다."
[left top 모양]
(rect left
top
(:width 모양)
(:heigth 모양)))

그리고 다음과 같이 draw-state 함수를 수정하면

1
2
3
4
(defn draw-state [state]
(background 255 255 255)
(fill 255 0 0) ;; red
(그리기! 75 75 {:width 50 :heigth 50}))

이전과 동일하게 동작하게 된다.
그리기! 함수는 무조건 rect 를 그리게 구현되었다. 이는 우리가 원한게 아니므로 그리기! 함수가 어떤 모양인지를 따져보고 알맞은 함수를 호출하도록 바꿔보자. 이럴때 단순히 조건분기하는 ifcond를 쓸 수도 있지만 어떤 모양인지 따져보고 알맞은 함수를 호출한다는 것은 clojure 에서 지원하는 문법인 defmulti 를 쓰기에 알맞아 보인다. defmulti는 실제 함수 구현부인 defmethod 와 짝을 이루며 인자를 살펴보거나 따져보아서 어떤 값을 기준으로 method를 호출할지 결정할 수 있고, defmethod 에서는 어떤 값에 따라 호출될지 구현부 앞에 밝혀적음으로써 누구나 의도를 파악하기 쉽도록 할 수 있다.

하지만 한가지 문제가 있는데 defmulti 는 repl 이 다시 실행될때까지 덮어 써지지 않는다;;
수정하려면 뭔가 특별한 조치를 취하지 않는이상 ide 에서 프로젝트 커넥션등을 다시 실행하거나 해야한다는것..

1
2
3
4
5
6
7
8
9
10
11
12
(defmulti 그리기!
"좌-상단 위치를 기준으로 모양을 그린다."
(fn [_ _ 모양] ;; _ 로 관심없는 인자를 표시..
(:type 모양))) ;; 어떤 값을 기준으로 할지 골라내는 방법
(defmethod 그리기!
::rect ;; 골라낸 값이 일치하는 경우 실행될 메서드임을 밝힘.
[left top 모양]
(rect left
top
(:width 모양)
(:heigth 모양)))

여기서는 모양 맵의 :type 을 보고 ::rect 인 경우에만 사각형을 그리도록 함수가 구현되어 있다. 이는 모양 맵이 :type 이 있음을 가정하고 있으므로 draw-state 함수는 다음과 같이 고쳐야 한다.

1
2
3
4
5
6
(defn draw-state [state]
(background 255 255 255)
(fill 255 0 0) ;; red
(그리기! 75 75 {:type ::rect
:width 50
:heigth 50}))

어떤 모양을 그릴때 이 모양은 하나가 아닐 수 있다. 특히나 우리가 구현하려고 하는 beside는 각 모양들의 집합이다. 따라서 이 모양들의 좌-상단위치가 같아서는 안 된다. 이를 나타내는 offset모양맵에 넣도록 하자. 덤으로 색깔도..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
;; 그리기! 함수를 이렇게..
(defmethod 그리기!
::rect
[left top 모양]
(when (:stroke 모양)
(apply stroke (:stroke 모양))) ;; apply 를 썼다.
(when (:fill 모양)
(apply fill (:fill 모양))) ;; apply 를 썼다.
(rect (+ left (:offset-left 모양))
(+ top (:offset-top 모양))
(:width 모양)
(:heigth 모양)))
;; draw-state 함수를 이렇게...
(defn draw-state [state]
(background 255 255 255)
(그리기! 0 0 {:type ::rect
:fill [255 0 0]
:stroke [0 0 0]
:offset-left 75
:offset-top 75
:width 50
:heigth 50}))

strokefill 함수는 인자들을 여러개를 받아서 색을 결정한다. 따라서 우리는 데이터를 벡터 형으로 기록하도록 하고 나중에 함수를 실행할때 벡터를 여러 인자를 넣어 실행한 것처럼 호출 해야한다. 이때 apply 라는 함수를 쓸 수 있다. 또, 어떤 경우에만 실행한다 라는 의미로 when 을 쓸 수 있다.
그리기! 메서드가 너무 길어져서 번잡해졌다. 한번 개선해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(defmethod 그리기!
::rect
[left top 모양]
(let [{:keys [offset-left
offset-top
width
height
stroke
fill]} 모양]
(when stroke
(apply quil.core/stroke stroke))
(when fill
(apply quil.core/fill fill))
(rect (+ left offset-left)
(+ top offset-top)
width
height)))

let은 내부에 밝혀적은 식대로 destructuring(구조분해) 해서 각 심볼로 그 값을 쓸 수 있게 해준다. 여기서는 모양맵을 구조분해 해서 각 키워드에 해당하는 값을 그대로 심볼로 쓸 수 있게 한 것.
그런데 여기서 stroke는 quil.core/stroke 함수를 가려버리게 되므로 stroke가 quil의 함수임을 밝혀적었다.

이제 beside 를 만들어보자.

옆으로 늘어놓을때, 각각의 세로길이가 다른 모양을 늘어놓을 것이므로 이 모양들은 세로 기준 중앙 정렬 되어야 할 것이다.

그러므로 먼저 모양들 중 가장 긴 세로길이 를 구해야 하고,

1
(apply max (map :height 모양들)) ;; 모양들의 가장-긴-세로길이 를 구한다.

가장 긴 세로길이를 기준으로 offset-top 을 바꿔줘야 한다.

1
(quot (- 가장-긴-세로길이 height) 2) ;; 각 모양의 (가장-긴-세로길이 - 세로길이) / 2

또한 모든 모양들에 대해서 앞선 모양의 offset-left 에 현재 모양의 가로길이를 더한것이 다음번 모양의 offset-left 가 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(defn beside
"모양들을 옆으로 늘어놓는다. 이때 세로가 가장 긴 모양의 세로축 중심을 기준으로 중앙정렬한다."
[& 모양들]
(let [가장-긴-세로길이 (apply max (map :height 모양들))
자식요소 (loop [모양들 모양들
left 0
result []]
(if 모양들
(let [[이번-모양 & 남은-모양] 모양들
{:keys [width height]} 이번-모양
top (quot (- 가장-긴-세로길이 height) 2)
조정된-모양 (assoc 이번-모양
:offset-left left
:offset-top top)] ;; 중앙정렬
(recur 남은-모양
(+ left width) ;; 다음 모양은 현재 가로길이를 더한 값으로 offset-left 가 계산되도록 더해준다.
(conj result 조정된-모양)))
result))]
{:type ::grouped
:width (reduce + (map :width 모양들)) ;; 모양들의 가로길이 합이 합쳐진 모양의 가로길이가 된다.
:height 가장-긴-세로길이
:offset-left 0
:offset-top 0
:children 자식요소}))

& 모양들은 이 함수에 여러개의 인자를 넣어서 호출 할 수 있음을 밝히는 구문이다.
loop 는 초기값을 선언하고 몸체내의 식을 recur 로 반복할 수 있도록 만든 설탕문법이다. loop는 다른 함수나 letfn 등으로 바꿔 쓸 수 있다. assoc은 해당 키의 값을 덮어 쓰는데 사용한다.

즉, 모양들 을 순회하며 offset-leftoffset-top을 갱신하는 코드가 된다.
마지막으로 이 결과를 가지고 :type::grouped모양맵을 만들어낸다.

::grouped그리기!하는 method는 아직 없으므로 하나 만들어주자.

1
2
3
4
5
6
7
8
9
10
11
12
13
(defmethod 그리기!
::grouped
[left top 모양]
(let [{:keys [type
offset-left
offset-top
width
height
children]} 모양]
(doseq [child children]
(그리기! (+ offset-left left)
(+ offset-top top)
child))))

deseq 는 다른 플랫폼의 for-each 구문과 비슷하게 동작한다. child of children을 모두 그리기!하는 함수이되 ::grouped 모양도 결국은 offset 을 가지므로 이를 좌-상단 위치에 반영해준다.

이를 draw-state 함수를 고쳐 한번 테스트해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(defn draw-state [state]
(background 255 255 255)
(그리기! 25 25 (beside
{:type ::rect
:fill [255 0 0]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 25
:height 25}
{:type ::rect
:fill [255 255 255]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 25
:height 25}
{:type ::rect
:fill [255 0 0]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 25
:height 25})))
red_3_rect.png

clojure에는 cycle이라는 함수가 있어서 반복되는 값을 표현하기에 알맞다. 빨간색 사각형과 흰 사각형이 교대로 늘어서는 모양을 생각해보자. 이는 이렇게 표현할 수 있을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(clojure.pprint/pprint
(apply beside
(take 10
(cycle
[{:type ::rect
:fill [255 0 0]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 50
:height 50}
{:type ::rect
:fill [255 0 0]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 50
:height 50}]))))

이를 repl 에서 실행시켜 보면 offset-left 차이나는 10개의 모양맵들이 보일 것이다.
clojure.pprint/pprint 함수는 데이터를 예쁘게 출력해주는 함수다.
draw-state를 고치면 실제로 그려지는 모양을 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(defn draw-state [state]
(background 255 255 255)
(그리기! 25 25
(apply beside
(take 10
(cycle
[{:type ::rect
:fill [255 0 0]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 15
:height 15}
{:type ::rect
:fill [255 255 255]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 15
:height 15}])))))

beside와 비슷한 above 함수를 만들어보자. 이 함수는 가로가 아닌 세로로 늘어놓는 함수다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(defn above
"모양들을 위아래로 늘어놓는다. 이때 가로가 가장 긴 모양의 가로축 중심을 기준으로 중앙정렬한다."
[& 모양들]
(let [가장-긴-가로길이 (apply max (map :width 모양들))
자식요소 (loop [모양들 모양들
top 0
result []]
(if 모양들
(let [[지금-모양 & 남은-모양] 모양들
{:keys [width height]} 지금-모양
left (quot (- 가장-긴-가로길이 width) 2)
조정된-모양 (assoc 지금-모양
:offset-left left
:offset-top top)] ;; 중앙정렬
(recur 남은-모양
(+ top height)
(conj result 조정된-모양)))
result))]
{:type ::grouped
:width 가장-긴-가로길이
:height (reduce + (map :height 모양들))
:offset-left 0
:offset-top 0
:children 자식요소}))

이제 이 모양들로 체스판을 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
(defn draw-state [state]
(background 255 255 255)
(그리기! 25 25
(apply above
(take 10
(cycle
[(apply beside
(take 10
(cycle
[{:type ::rect
:fill [255 0 0]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 15
:height 15}
{:type ::rect
:fill [255 255 255]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 15
:height 15}])))
(apply beside
(take 10
(cycle
[{:type ::rect
:fill [255 255 255]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 15
:height 15}
{:type ::rect
:fill [255 0 0]
:stroke [0 0 0]
:offset-left 0
:offset-top 0
:width 15
:height 15}])))])))))
red_chess.png

일단 ::grouped 모양을 만들면 이 모양을 그리기!하는 함수는 이미 있으므로 이 모양을 다시 cycle을 하든 그걸 다시 above로 묶던 내부적으로 이를 그리는 방법은 신경 쓸 필요가 없다.

이제 이 함수를 가지고 plt-racket의 홈페이지 대문 예제 중 하나인 시어핀스키의 삼각형을 그려보자.

먼저 삼각형을 그리기!하는 함수를 만들어보면,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(defmethod 그리기!
::triangle
[left top 모양]
(let [{:keys [offset-left
offset-top
width
height
stroke
fill]} 모양]
(when stroke
(apply quil.core/stroke stroke))
(when (:fill 모양)
(apply quil.core/fill fill))
(let [left (+ left offset-left)
top (+ top offset-top)]
(triangle (+ left (quot width 2)) top
left (+ top height)
(+ left width) (+ top height)))))

이렇게 하면 사실 정삼각형을 그리기 힘들다 (가로 세로만 입력가능하니까..)

또, 시어핀스키의 삼각형을 만드는 함수를 만들어보자. above, beside 면 간단히 만들 수 있다.

1
2
3
4
5
6
7
8
(defn sierpinski [n]
(if (zero? n)
{:type ::triangle
:width 10
:height 8.66}
(let [result (sierpinski (dec n))]
(above result
(beside result result)))))

draw-state를 고쳐서 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
(defn draw-state [state]
(background 255 255 255)
(no-stroke)
(그리기! 40 40 (sierpinski 5)))
;; 윈도우 사이즈를 400으로 늘림.
(defsketch foo
:title "foo"
:size [400 400]
:setup setup
:draw draw-state
:features [:keep-on-top]
:middleware [m/fun-mode])
sierpinski.png