WebAssembly

그림판에 선택 도구 만들기: 히트 테스트부터 러버밴드까지

· 15분 읽기
#Rust#WebAssembly#Canvas API#Hit Testing

들어가며

이전 글에서 Immediate Mode → Retained Mode 전환까지 다뤘다. 모든 스트로크를 Vec<Stroke>에 저장하고 매 프레임 전체를 다시 그리는 구조로 바꾼 것이다.

그때 회고에서 이렇게 썼다:

Retained 데이터 모델을 기반으로 Undo/Redo, 저장/불러오기, 레이어 등의 확장이 가능하다.

말만 하고 넘어갔는데, 이번에 실제로 확장해봤다. 선택, 이동, 복사, 붙여넣기. 그리고 이 과정에서 “데이터를 보존하고 있다”는 것이 구체적으로 어떤 의미인지 체감했다.


1. 히트 테스트 — “이 클릭이 어느 선 위인가?”

선택 도구의 첫 번째 문제: 사용자가 캔버스를 클릭했을 때, 그 좌표가 어떤 스트로크 위에 있는지 알아내야 한다.

바운딩 박스로는 부족하다

가장 단순한 접근은 각 스트로크의 바운딩 박스(최소/최대 좌표로 만든 사각형)를 검사하는 것이다. 하지만 자유곡선에는 맞지 않는다.

┌─────────────────────┐
│                  ╱   │ ← 바운딩 박스 안이지만
│ ✕              ╱    │    선과는 한참 떨어진 곳
│              ╱      │
│            ╱        │
│          ╱          │
└─────────────────────┘

대각선으로 길게 뻗는 선의 바운딩 박스는 대부분이 빈 공간이다. 여기를 클릭해도 “선택됨”으로 판정하면 UX가 나쁘다.

점에서 선분까지의 거리 계산

그래서 점(클릭 좌표)에서 선분(스트로크의 각 구간)까지의 최단 거리를 수학적으로 계산하는 방식을 택했다.

솔직히 고백하면, “벡터 투영으로 점과 선분 사이 최단 거리를 구하면 된다”는 건 AI한테 물어봐서 알았다. 고등학교 수학 시간에 자느라 내적(dot product)이 뭔지 제대로 들은 적이 없다. 수학은 못 들어도 AI는 기억하더라.

핵심은 벡터 투영(projection)이다:

fn point_to_segment_distance(p: &Point, a: &Point, b: &Point) -> f64 {
    let dx = b.x - a.x;
    let dy = b.y - a.y;
    let len_sq = dx * dx + dy * dy;

    if len_sq == 0.0 {
        // 선분 길이가 0 (점)
        let ex = p.x - a.x;
        let ey = p.y - a.y;
        return (ex * ex + ey * ey).sqrt();
    }

    // P를 선분 AB 위에 투영, [0, 1] 범위로 클램프
    let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len_sq;
    let t = t.clamp(0.0, 1.0);

    // 투영점과 P 사이의 거리
    let proj_x = a.x + t * dx;
    let proj_y = a.y + t * dy;
    let ex = p.x - proj_x;
    let ey = p.y - proj_y;
    (ex * ex + ey * ey).sqrt()
}

t가 0이면 점 A에 가장 가깝고, 1이면 점 B에 가장 가깝고, 0.5면 선분 중간이다. clamp(0.0, 1.0)으로 선분 범위 바깥으로 벗어나지 않게 한다.

스트로크의 모든 연속 선분 쌍에 대해 이 계산을 수행하고, 하나라도 허용 오차 이내면 히트로 판정한다:

pub(crate) fn hit_test_stroke(stroke: &Stroke, x: f64, y: f64) -> bool {
    let threshold = (stroke.width / 2.0 + 4.0).max(8.0);
    let p = Point { x, y };

    for i in 0..stroke.points.len().saturating_sub(1) {
        let dist = point_to_segment_distance(
            &p, &stroke.points[i], &stroke.points[i + 1]
        );
        if dist <= threshold {
            return true;
        }
    }
    false
}

허용 오차를 max(width/2 + 4, 8)px로 설정한 이유: 가느다란 선(1px)도 최소 8px의 히트 영역을 가져서 터치하기 쉽고, 굵은 선은 시각적 영역과 히트 영역이 자연스럽게 일치한다.


2. 선택 시스템 설계

상태 관리: HashSet<u32>

선택 상태는 HashSet<u32>로 관리한다. 스트로크 ID의 집합이다.

pub(crate) selected_ids: HashSet<u32>,

이 자료구조를 선택한 이유:

  • O(1) 삽입/삭제/조회: “이 스트로크가 선택되어 있는가?” 확인이 상수 시간
  • 자연스러운 다중 선택: Shift+클릭으로 토글하면 된다
  • 렌더링 시 사용: 매 프레임마다 전체 스트로크를 순회하면서 선택 여부를 확인해야 하므로 빠른 조회가 중요

클릭 선택과 Shift 토글

선택 도구 모드에서 클릭하면 try_select_at이 호출된다:

pub fn try_select_at(&mut self, x: f64, y: f64, shift: bool) -> bool {
    // 역순 탐색: 나중에 그린 스트로크가 위에 보이므로 먼저 검사
    let mut hit_id: Option<u32> = None;
    for stroke in self.strokes.iter().rev() {
        if hit_test_stroke(stroke, x, y) {
            hit_id = Some(stroke.id);
            break;
        }
    }

    match hit_id {
        Some(id) => {
            if shift {
                // Shift+클릭: 토글 (이미 선택됐으면 해제, 아니면 추가)
                if !self.selected_ids.remove(&id) {
                    self.selected_ids.insert(id);
                }
            } else {
                // 일반 클릭: 단일 선택
                self.selected_ids.clear();
                self.selected_ids.insert(id);
            }
            true
        }
        None => {
            if !shift { self.selected_ids.clear(); }
            false
        }
    }
}

iter().rev()로 역순 탐색하는 이유: 나중에 그린 스트로크가 화면에서 위에 보인다. 겹쳐 있을 때 사용자가 보는 것과 선택되는 것이 일치해야 한다.


3. 러버밴드 — 드래그로 영역 선택

클릭 선택만으로는 부족하다. Photoshop, Figma 등 모든 에디터에 있는 기능—빈 영역을 드래그해서 사각형을 그리면 그 안의 요소가 전부 선택되는 것. 이걸 러버밴드(rubber band) 선택이라고 한다.

동작 흐름

빈 영역 mousedown → 러버밴드 시작 (시작점 저장)
mousemove → 반투명 사각형 실시간 렌더링
mouseup → 영역 내 스트로크 일괄 선택

영역-스트로크 교차 판정

러버밴드가 끝나면, 드래그한 사각형과 각 스트로크의 바운딩 박스가 겹치는지 검사한다. 여기서는 히트 테스트와 달리 **바운딩 박스 교차(AABB 충돌 검사)**가 적합하다—영역 선택은 “대략 이 범위 안에 있는 것”을 선택하는 것이므로 정밀도보다 직관성이 중요하기 때문이다.

impl BoundingBox {
    pub fn intersects(&self, other: &BoundingBox) -> bool {
        self.min_x <= other.max_x
            && self.max_x >= other.min_x
            && self.min_y <= other.max_y
            && self.max_y >= other.min_y
    }
}

네 가지 조건이 모두 참이면 두 사각형이 겹친다. 하나라도 거짓이면 떨어져 있다.

시각적 피드백

드래그 중에는 반투명 파란색 사각형이 실시간으로 그려진다:

pub(crate) fn draw_rubber_band(&self) {
    if !self.is_rubber_band { return; }

    let x = self.rubber_band_start_x.min(self.rubber_band_end_x);
    let y = self.rubber_band_start_y.min(self.rubber_band_end_y);
    let w = (self.rubber_band_end_x - self.rubber_band_start_x).abs();
    let h = (self.rubber_band_end_y - self.rubber_band_start_y).abs();

    self.ctx.save();
    // 반투명 파란 배경
    self.ctx.set_fill_style_str("rgba(59, 130, 246, 0.1)");
    self.ctx.fill_rect(x, y, w, h);
    // 파란 점선 테두리
    // ...
    self.ctx.restore();
}

min/abs를 사용하는 이유: 사용자가 오른쪽 아래에서 왼쪽 위로 드래그할 수도 있기 때문이다. 시작점이 항상 좌상단이 아닐 수 있다.


4. 이동 — 좌표의 덧셈

선택된 스트로크를 드래그하면 이동해야 한다. Retained Mode에서 이동은 놀라울 정도로 단순하다:

pub fn translate(&mut self, dx: f64, dy: f64) {
    for p in &mut self.points {
        p.x += dx;
        p.y += dy;
    }
}

모든 점에 (dx, dy)를 더하면 끝이다. render()가 매 프레임 전체를 다시 그리니까, 데이터만 바꾸면 화면이 따라온다.

마우스 이벤트 쪽에서는 매 mousemove마다 이전 좌표와의 차이(delta)를 계산해서 넘긴다:

pub fn move_selected(&mut self, x: f64, y: f64) {
    if !self.is_moving { return; }
    let dx = x - self.move_start_x;
    let dy = y - self.move_start_y;

    for stroke in &mut self.strokes {
        if self.selected_ids.contains(&stroke.id) {
            stroke.translate(dx, dy);
        }
    }

    self.move_total_dx += dx;
    self.move_total_dy += dy;
    self.move_start_x = x;
    self.move_start_y = y;
    self.render();
}

move_start를 매번 갱신하는 이유: 절대 위치가 아니라 프레임 간 상대 이동량을 사용하기 때문이다. 이렇게 하면 드래그 중 스트로크가 마우스를 정확히 따라온다.

selected_idsHashSet이므로 contains가 O(1)이다. move_total_dx/dy는 프레임마다 누적되어 undo 시 전체 이동량을 한 번에 되돌리는 데 쓰인다.


5. 복사와 붙여넣기 — clone()의 힘

내부 클립보드

클립보드는 Rust 내부 Vec<Stroke>로 구현했다. 브라우저 Clipboard API는 텍스트/이미지용이지, 우리 스트로크 데이터를 주고받을 곳이 없다.

pub fn copy_selected(&mut self) {
    self.clipboard.clear();
    for stroke in &self.strokes {
        if self.selected_ids.contains(&stroke.id) {
            self.clipboard.push(stroke.clone());
        }
    }
}

Stroke#[derive(Clone)]이니까 clone() 한 줄이면 깊은 복사가 끝난다. 점 배열까지 전부 복사된다.

붙여넣기: 새 ID + 오프셋

pub fn paste(&mut self) {
    if self.clipboard.is_empty() { return; }

    let offset = 20.0;
    self.selected_ids.clear();

    for original in &self.clipboard.clone() {
        let mut cloned = original.clone();
        cloned.id = self.next_id;        // 새 ID 부여
        self.next_id += 1;
        cloned.translate(offset, offset); // 20px 오른쪽 아래로
        self.selected_ids.insert(cloned.id);
        self.strokes.push(cloned);
    }

    // 클립보드를 방금 붙여넣은 것으로 갱신 → 반복 붙여넣기 시 오프셋 누적
    self.clipboard = self.strokes.iter()
        .filter(|s| self.selected_ids.contains(&s.id))
        .cloned()
        .collect();

    self.render();
}

설계 포인트:

  • 새 ID 부여: next_id 카운터로 고유성 보장. 같은 ID가 두 개 있으면 선택/삭제가 꼬인다
  • 20px 오프셋: 원본과 정확히 겹치면 붙여넣은 건지 알 수 없다
  • 클립보드 갱신: Ctrl+V를 연속으로 누르면 20px, 40px, 60px… 계단식으로 붙여넣어진다

6. 렌더링 파이프라인 확장

기존 렌더링 파이프라인에 선택 관련 레이어가 두 개 추가됐다:

render()
├── clear_canvas()          ← 흰 배경
├── draw all strokes        ← 저장된 모든 스트로크
├── draw current stroke     ← 그리는 중인 스트로크
├── draw_selection_highlight()  ← 🆕 선택된 스트로크 바운딩 박스
├── draw_rubber_band()          ← 🆕 드래그 영역 사각형
└── draw_cursor_preview()   ← 커서 미리보기

모든 렌더링 레이어가 ctx.save()/ctx.restore()로 격리되어 있어서, 각 레이어의 선 스타일(점선, 색상, 굵기)이 다른 레이어에 영향을 주지 않는다.


7. JS 이벤트 라우팅 — 하나의 캔버스, 세 가지 행동

같은 mousedown 이벤트가 도구 모드에 따라 완전히 다른 동작을 해야 한다:

canvasEl.addEventListener('mousedown', (e) => {
  const pos = getPosition(e, canvasEl);

  if (currentTool === 'select') {
    if (canvas.has_selection() && canvas.is_over_selected(pos.x, pos.y)) {
      canvas.start_move(pos.x, pos.y);       // 1. 선택된 것 위 → 이동
    } else {
      const hit = canvas.try_select_at(pos.x, pos.y, e.shiftKey);
      if (hit) {
        canvas.start_move(pos.x, pos.y);     // 2. 스트로크 위 → 선택 + 이동 준비
      } else {
        canvas.start_rubber_band(pos.x, pos.y); // 3. 빈 영역 → 러버밴드
      }
    }
  } else {
    canvas.start_drawing(pos.x, pos.y);       // 4. 펜/지우개 → 그리기
  }
});

하나의 이벤트에서 네 가지 분기가 나온다. 이걸 깔끔하게 처리하는 핵심은 상태 판별을 Rust 쪽에 위임하는 것이다. has_selection(), is_over_selected(), try_select_at() 모두 Rust에서 계산하고 JS는 결과에 따라 분기만 한다.


회고

Retained Mode가 열어준 것

이전 글에서 “Retained Mode면 확장이 쉽다”고 했는데, 실제로 해보니 정말 그랬다.

기능구현 핵심난이도
선택HashSet<u32> + 히트 테스트히트 테스트 수학이 좀 필요
이동translate(dx, dy)놀라울 정도로 단순
복사clone()Rust의 derive가 다 해줌
붙여넣기clone() + 새 ID + 오프셋단순
러버밴드AABB 교차 검사조건 4개
삭제retain(|s| !selected)한 줄

Immediate Mode였다면? 이미 찍힌 픽셀에서 “이건 어떤 스트로크의 일부인가?”를 역추적할 방법이 없다. 데이터를 보존하고 있었기 때문에 이 모든 게 가능했다.

의외로 어려웠던 것: 이벤트 분기

수학이나 데이터 구조보다, mousedown 하나에서 “이동인가 선택인가 러버밴드인가”를 정확히 판별하는 로직이 가장 신경 쓰였다. 상태 머신이 복잡해질수록 엣지 케이스가 늘어난다—Shift 누른 채로 빈 영역 클릭, 선택된 스트로크 위에서 Shift+클릭, 러버밴드 중 마우스가 캔버스 밖으로 나감 등.

이런 상태 전이를 깔끔하게 관리하는 게 에디터 개발의 진짜 챌린지라는 걸 느꼈다.

그 다음: Undo/Redo

선택/이동/복사/삭제를 구현하고 나니, Undo/Redo가 자연스럽게 다음 과제가 됐다. 커맨드 패턴으로 모든 동작을 기록하면 된다는 건 알고 있었는데, 실제로 해보니 이동의 undo가 의외로 까다로웠다.

문제는 move_selected가 매 mousemove마다 호출된다는 것이다. 프레임마다 (dx, dy)를 적용하니까, undo할 “하나의 이동”이 실제로는 수십~수백 번의 미세 이동으로 구성되어 있다. 이걸 해결하기 위해 start_move에서 move_total_dx/dy를 0으로 초기화하고, 매 프레임마다 누적한 뒤, stop_move에서 총 이동량을 하나의 MoveStrokes 액션으로 기록했다.

enum Action {
    AddStroke { stroke: Stroke },
    DeleteStrokes { strokes: Vec<Stroke> },
    MoveStrokes { ids: Vec<u32>, dx: f64, dy: f64 },
    PasteStrokes { strokes: Vec<Stroke> },
    ClearAll { strokes: Vec<Stroke> },
}

undo는 액션을 역실행하고 redo 스택으로 옮기고, redo는 그 반대다. 새로운 액션이 발생하면 redo 스택을 비운다—“과거를 바꿨으면 미래는 사라진다”는 원칙이다.

다음 단계

  • 저장/불러오기: Stroke가 이미 Serialize를 derive하고 있다
  • 그룹화: 선택된 스트로크를 하나의 그룹으로 묶기

데이터가 있으면, 가능성은 끝이 없다.