← 과제 목록

[과제] 2주차) Basic Programming 김두람

@alpakaDurumi
  • #과제
목차

개요

Excalidraw을 참고하여 드로잉 앱을 만들어봤습니다. 5가지의 툴을 바꿔가며 자유롭게 화이트보드에 그림을 그릴 수 있습니다.

브랜치 링크

image

기능

사용할 수 있는 도구는 아래와 같습니다.

도구용도
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::transformtranslation 성분 추출
Paint::bounds선택 도형의 바운딩 박스 계산
Paint::intersects지우개·선택 히트 테스트
Scene::add / Scene::remove메뉴 구성
Scene::add(SceneEffect::DropShadow, ...)메뉴 그림자
Canvas::add / Canvas::remove캔버스에 도형 추가·제거
Canvas::updateadd 이후 수정한 도형을 캔버스에 반영

TODO

  • 미리 정의된 색상 외에 임의의 색상을 지정하여 사용할 수 있도록 하기.
  • 현재 설정된 Width에 따라 Eraser의 범위가 나타나도록 하기.
  • Select 툴 선택 시 드래그로 여러 물체를 선택할 수 있도록 하기.
  • 선택된 툴 또는 옵션이 표시되도록 하기.
  • 사용 가능한 툴/옵션 추가.

댓글

Discussion 원문