들어가며
이전 글에서 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_ids가 HashSet이므로 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하고 있다 - 그룹화: 선택된 스트로크를 하나의 그룹으로 묶기
데이터가 있으면, 가능성은 끝이 없다.