개요
Excalidraw을 참고하여 드로잉 앱을 만들어봤습니다. 5가지의 툴을 바꿔가며 자유롭게 화이트보드에 그림을 그릴 수 있습니다.
기능
사용할 수 있는 도구는 아래와 같습니다.
| 도구 | 용도 |
|---|---|
| Pen | 기본적인 펜 |
| Rect | 드래그를 통해 사각형을 그리는 기능 |
| Circle | 드래그를 통해 원을 그리는 기능 |
| Eraser | 클릭 또는 드래그를 통해 그린 물체를 지울 수 있는 기능 |
| Select | 그린 물체를 클릭하여 선택. 그 후 이동시키거나 지울 수 있는 기능 |
- 각 도구로의 전환은 화면 왼쪽에 위치한 버튼을 누르거나 키보드에서 숫자 1-5를 입력하여 수행합니다.
- Select로 물체를 선택한 후 키보드의 delete 키를 입력하면 선택된 물체를 제거할 수 있습니다.
- 좌측의 메뉴에서 Fill Color, Stroke Color, Width를 각각 선택할 수 있습니다.
- 색상 중 최좌측에 위치한 것(흰색에 빗금이 쳐 있는 형태)은 alpha가 0인 투명을 의미합니다.
- Width로는 Pen/Rect/Circle의 StrokeWidth에 더불어 Eraser의 크기를 동시에 설정합니다.
시연
코드 설명
Pen
clickdown()에서 클릭 시 시작 지점을 설정한 후, motion()에서 lineTo를 사용해 점을 잇습니다. clickdown()의 lineTo()는 마우스를 움직이지 않은 상태에서 clickdown()과 clickup()만이 호출되는 케이스에 대해 점을 찍기 위해서 존재합니다.
// clickdown()
case Tool::Pen:
currentShape = tvg::Shape::gen();
currentShape->strokeWidth(toolWidth);
currentShape->strokeFill(strokeColor.r, strokeColor.g, strokeColor.b, strokeColor.a);
currentShape->strokeCap(tvg::StrokeCap::Round);
currentShape->strokeJoin(tvg::StrokeJoin::Round);
currentShape->moveTo(x, y);
currentShape->lineTo(x, y);
canvas->add(currentShape, selectedBB);
shapes.push_back(currentShape);
changed = true;
break;
// motion()
case Tool::Pen:
currentShape->lineTo(x, y);
canvas->update();
changed = true;
break;
Rect/Circle
clickdown()에서 마우스가 클릭된 좌표를 기억하고, motion()에서는 매 프레임 reset()을 호출하며 드래그하는 위치에 따라 그립니다.
// clickdown()
// Store start coord
sx = x;
sy = y;
// motion()
case Tool::Rect:
if(!currentShape)
{
currentShape = tvg::Shape::gen();
currentShape->strokeWidth(toolWidth);
currentShape->strokeFill(strokeColor.r, strokeColor.g, strokeColor.b, strokeColor.a);
currentShape->strokeCap(tvg::StrokeCap::Round);
currentShape->strokeJoin(tvg::StrokeJoin::Round);
currentShape->fill(fillColor.r, fillColor.g, fillColor.b, fillColor.a);
canvas->add(currentShape, selectedBB);
shapes.push_back(currentShape);
}
currentShape->reset();
currentShape->appendRect(sx, sy, x - sx, y - sy);
canvas->update();
changed = true;
break;
case Tool::Circle:
if(!currentShape)
{
currentShape = tvg::Shape::gen();
currentShape->strokeWidth(toolWidth);
currentShape->strokeFill(strokeColor.r, strokeColor.g, strokeColor.b, strokeColor.a);
currentShape->fill(fillColor.r, fillColor.g, fillColor.b, fillColor.a);
canvas->add(currentShape, selectedBB);
shapes.push_back(currentShape);
}
currentShape->reset();
currentShape->appendCircle((x + sx) * 0.5f, (y + sy) * 0.5f, (x - sx) * 0.5f, (y - sy) * 0.5f);
changed = true;
canvas->update();
break;
Eraser
Eraser 툴을 선택한 상태에서 클릭 또는 드래그를 통해 물체와 접촉하게 되면 pending에 해당 Shape를 추가하며,
// Collect candidates and mark them as transparent
void collectErase(uint32_t x, uint32_t y)
{
int32_t size = static_cast<int32_t>(toolWidth);
int32_t half = size / 2;
for (auto s: shapes)
{
if(s->intersects(x - half, y - half, size, size))
{
if(pending.insert(s).second)
{
uint8_t sR, sG, sB, sA;
uint8_t fR, fG, fB, fA;
s->strokeFill(&sR, &sG, &sB, &sA);
s->strokeFill(sR, sG, sB, sA / 2);
s->fill(&fR, &fG, &fB, &fA);
s->fill(fR, fG, fB, fA / 2);
}
}
}
}
마우스를 뗐을 때 처리합니다.
// clickup()
if(currentTool == Tool::Eraser)
{
if(!pending.empty()) changed = true;
for(auto s: pending)
canvas->remove(s);
shapes.erase(
std::remove_if(shapes.begin(), shapes.end(),
[&](tvg::Shape* s){ return pending.find(s) != pending.end(); }),
shapes.end());
pending.clear();
}
Select
선택한 물체, 그리고 그에 대한 bounding box, 좌표를 변수에 저장하여 사용합니다.
// Select
uint32_t selecterSize = 2;
tvg::Shape* selected = nullptr;
tvg::Shape* selectedBB = nullptr;
float selTx;
float selTy;
clickdown()에서 물체 선택 여부를 판단하고, 선택된 물체에 대해 변환 행렬을 쿼리한 후 translation 성분을 추출합니다.
// clickdown()
case Tool::Select:
if(!(selected && inBounds(selected, x, y)))
{
tvg::Shape* hit = nullptr;
changed = true;
for(auto s: shapes)
{
if(s->intersects(x - selecterSize / 2, y - selecterSize / 2, selecterSize, selecterSize))
{
hit = s;
break;
}
}
selected = hit;
selectedBB->reset();
if(selected) drawSelectedBox();
canvas->update();
}
if(selected)
{
tvg::Matrix& m = selected->transform();
selTx = m.e13;
selTy = m.e23;
}
break;
이후, motion()에서 마우스 드래그에 따라 물체를 이동시킵니다.
// motion()
case Tool::Select:
if(!selected) return false;
selected->translate(selTx + (x - sx), selTy + (y - sy));
drawSelectedBox();
canvas->update();
changed = true;
break;
drawSelectedBox()는 bounding box를 계산하여 점선으로 해당 구역을 그립니다.
void drawSelectedBox()
{
selectedBB->reset();
float x, y, w, h;
if(selected->bounds(&x, &y, &w, &h) == tvg::Result::Success)
{
selectedBB->appendRect(x, y, w, h);
selectedBB->strokeWidth(3.0f);
selectedBB->strokeFill(200, 100, 255, 255);
float dashPattern[] = {3.0f, 10.0f};
selectedBB->strokeDash(dashPattern, 2);
}
}
UI
좌측 메뉴는 Scene 위에 버튼(Shape + Text)을 얹어 구성했습니다. 버튼은 ‘{클릭 영역, 콜백 함수}’ 형태로 목록에 등록해두고, clickdown()에서 클릭 좌표가 어느 버튼 영역에 속하는지 검사해 등록된 콜백을 실행하는 방식입니다.
라벨 중앙 정렬에는 Text::align/Text::layout을, 메뉴 그림자에는 Scene::add(SceneEffect::DropShadow, ...)를 사용했습니다.
사용한 ThorVG API 목록
| API | 용도 |
|---|---|
Shape::gen / Text::gen / Scene::gen | 객체 생성 |
Shape::moveTo / Shape::lineTo | 펜 자유선 그리기 |
Shape::appendRect / Shape::appendCircle | 사각형·원 도형 |
Shape::reset | 드래그 중 도형 경로 갱신 |
Shape::fill | 도형 채우기 색 |
Shape::strokeWidth | 선 굵기 |
Shape::strokeFill | 선 색 |
Shape::strokeCap / Shape::strokeJoin | 선 끝·꺾임 모양(Round) |
Shape::strokeDash | 선택 박스 점선 |
Text::load | 폰트 로드 |
Text::font / Text::size / Text::text / Text::fill | 텍스트 설정 |
Text::align / Text::layout | 버튼 라벨 중앙 정렬 |
Paint::translate | 선택 도형 드래그 이동 |
Paint::transform | translation 성분 추출 |
Paint::bounds | 선택 도형의 바운딩 박스 계산 |
Paint::intersects | 지우개·선택 히트 테스트 |
Scene::add / Scene::remove | 메뉴 구성 |
Scene::add(SceneEffect::DropShadow, ...) | 메뉴 그림자 |
Canvas::add / Canvas::remove | 캔버스에 도형 추가·제거 |
Canvas::update | add 이후 수정한 도형을 캔버스에 반영 |
TODO
- 미리 정의된 색상 외에 임의의 색상을 지정하여 사용할 수 있도록 하기.
- 현재 설정된 Width에 따라 Eraser의 범위가 나타나도록 하기.
- Select 툴 선택 시 드래그로 여러 물체를 선택할 수 있도록 하기.
- 선택된 툴 또는 옵션이 표시되도록 하기.
- 사용 가능한 툴/옵션 추가.
댓글
Discussion 원문