このサイトは、只今WEB業界で活躍中のデザイナー、プログラマーの方々の情報を集めたweb統合情報サイトです。

Archives Details

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その5 図形ツール(Bresenham・楕円・塗りつぶし・スタンプ)

Rust

2026.09.30

どもです。

前回はペンツールとアンドゥの設計でした。今回はその上に乗っている図形ツール、つまり直線・矩形・楕円・塗りつぶし・スタンプの実装をまとめた形になります。

「描画ツールを厚くしたい」というのが R-CHR を作った動機のひとつだったので、個人的に一番楽しかったパートです。アルゴリズム多めでいきます。

連載の目次

  1. 概要とアーキテクチャ
  2. NES の CHR フォーマット(2BPP)と iNES ヘッダ、パレット
  3. egui で組む 3 パネル UI とバンクビュー
  4. ドットエディタの基礎とアンドゥ設計
  5. 図形ツール(Bresenham・楕円・塗りつぶし・スタンプ)(この記事)
  6. PNG インポートと 3 つのマッピング戦略
  7. macOS ネイティブメニュー・多言語対応・GitHub Actions でのリリース

共通の流れ:ドラッグ → プレビュー → リリースで確定

直線・矩形・楕円(ツール 2〜7)は、全部同じ流れで動いています。

毎フレーム:
  1. CHR から block をデコード(現在の確定データ)
  2. block をキャンバスに描画
  3. ドラッグ中なら、図形のドット列を painter で上書き描画(プレビュー)
  4. ボタンが離されたら ApplyLine で確定書き込み

大事なのは、ドラッグ中は CHR データを一切触らないことです。プレビューはあくまで painter で画面に描いているだけ。egui は即時モードなので「毎フレーム上から描くだけ」でプレビューが実現できて、CHR にもアンドゥスタックにも何の影響も与えません。

ドラッグ開始でブロック内の起点座標を記録します。

/// 線ツールのドラッグ起点(ブロック内ドット座標)
pub(super) line_start_dot: Option<(usize, usize)>,
if matches!(self.drawing_tool, 2..=7) {
    if response.drag_started_by(egui::PointerButton::Primary) {
        self.line_start_dot = Some((px, py));
        return None;
    }
    if response.dragged_by(egui::PointerButton::Primary) {
        return None; // プレビューは上で描画済み
    }
    // クリック(ドラッグなし): 1 点として確定
    if response.clicked_by(egui::PointerButton::Primary) {
        let pixels = vec![(tile_offset, dot_px, dot_py)];
        return Some(EditorAction::ApplyLine { pixels });
    }
    return None;
}
図形の種類は shape_dots() で振り分ける

「起点と現在位置からドット座標のリストを作る」部分は、ツール種別で振り分けるだけの小さなディスパッチ関数にまとめてあります。

/// ツール種別からドット座標列を生成(線・矩形・楕円共通)
fn shape_dots(sx: usize, sy: usize, ex: usize, ey: usize, tool: usize) -> Vec<(usize, usize)> {
    match tool {
        2 => bresenham(sx, sy, ex, ey),
        3 | 4 | 5 => rect_dots(sx, sy, ex, ey, tool),
        6 => ellipse_dots(sx, sy, ex, ey, false),
        7 => ellipse_dots(sx, sy, ex, ey, true),
        _ => vec![],
    }
}

プレビュー描画も、リリース時の確定も、どちらも同じ shape_dots() を通るので、「プレビューと確定結果が微妙に違う」という事故が起きません。

プレビュー

ドラッグ中はこう描いています。

if matches!(self.drawing_tool, 2..=7) {
    if let Some((sx, sy)) = self.line_start_dot {
        let cur = response.interact_pointer_pos().or_else(|| response.hover_pos());
        if let Some(p) = cur {
            let rx = p.x - rect.left();
            let ry = p.y - rect.top();
            if rx >= 0.0 && ry >= 0.0 {
                let cx = ((rx / dot_size) as usize).min(block_px.saturating_sub(1));
                let cy = ((ry / dot_size) as usize).min(block_px.saturating_sub(1));
                let preview_color = self.dat_palette.color32(
                    self.selected_palette_set,
                    self.drawing_color_idx as usize,
                    &self.master_palette,
                );
                for (dx, dy) in shape_dots(sx, sy, cx, cy, self.drawing_tool) {
                    if dx >= block_px || dy >= block_px { continue; }
                    let dot_rect = egui::Rect::from_min_size(
                        egui::pos2(rect.left() + dx as f32 * dot_size, rect.top() + dy as f32 * dot_size),
                        egui::vec2(dot_size, dot_size),
                    );
                    painter.rect_filled(dot_rect, 0.0, preview_color);
                }
            }
        }
    }
}

現在位置は .min(block_px - 1) でクランプしているので、キャンバスの外にドラッグしても図形が端で止まります。

リリース検出のハマりどころ

ここ、地味にハマりました。

egui の response.interact_pointer_pos() は「このウィジェットとインタラクション中のポインタ位置」を返すのですが、ボタンを離した瞬間のフレームでは None になることがあるんです。なので「離した瞬間の座標」を interact_pointer_pos() で取ろうとすると、終点が取れずに図形が確定できない、ということが起きます。

対策として、リリース検出は interact_pointer_pos() を見る前に ctx.input() で直接ボタンリリースを拾い、終点は hover_pos() から取るようにしました。

// ── 線・矩形ツール: ドラッグ終了検出(tool=2-5)
if matches!(self.drawing_tool, 2..=7) && self.line_start_dot.is_some() {
    let just_released = ui.ctx().input(|i| i.pointer.button_released(egui::PointerButton::Primary));
    if just_released {
        let end = ui.ctx().input(|i| i.pointer.hover_pos());
        let top_col = top_left_tile % 16;
        let top_row = top_left_tile / 16;
        let (sx, sy) = self.line_start_dot.take().unwrap();
        let (ex, ey) = end.and_then(|p| {
            let rx = p.x - rect.left();
            let ry = p.y - rect.top();
            (rx >= 0.0 && ry >= 0.0).then(|| {
                let ex = ((rx / dot_size) as usize).min(block_px.saturating_sub(1));
                let ey = ((ry / dot_size) as usize).min(block_px.saturating_sub(1));
                (ex, ey)
            })
        }).unwrap_or((sx, sy));
        let tool = self.drawing_tool;
        let pixels = shape_dots(sx, sy, ex, ey, tool)
            .into_iter()
            .filter_map(|(dx, dy)| {
                if dx >= block_px || dy >= block_px { return None; }
                let off = ((top_row + dy / 8) * 16 + (top_col + dx / 8)) * 16;
                (off + 16 <= chr_len).then_some((off, dx % 8, dy % 8))
            })
            .collect();
        return Some(EditorAction::ApplyLine { pixels });
    }
}

ブロック内のドット座標 (dx, dy) を「タイルオフセット + タイル内座標」の 3 つ組に変換して ApplyLine に詰めています。この 3 つ組は前回のペンと同じ形式なので、apply_action() 側は何も特別扱いしません。

直線:Bresenham のアルゴリズム

直線はド定番の Bresenham です。整数演算だけで、始点から終点まで途切れなく 1 ドット幅の線を引けます。

/// Bresenham の直線アルゴリズム(ドット座標リストを返す)
fn bresenham(x0: usize, y0: usize, x1: usize, y1: usize) -> Vec<(usize, usize)> {
    let (mut x, mut y) = (x0 as i32, y0 as i32);
    let (x1, y1) = (x1 as i32, y1 as i32);
    let dx = (x1 - x).abs();
    let dy = -(y1 - y).abs();
    let sx: i32 = if x < x1 { 1 } else { -1 };
    let sy: i32 = if y < y1 { 1 } else { -1 };
    let mut err = dx + dy;
    let mut pts = Vec::new();
    loop {
        pts.push((x as usize, y as usize));
        if x == x1 && y == y1 { break; }
        let e2 = 2 * err;
        if e2 >= dy { err += dy; x += sx; }
        if e2 <= dx { err += dx; y += sy; }
    }
    pts
}

「誤差 err を積み上げていって、閾値を超えたら軸方向に 1 歩進める」という古典的なやつですね。dy を負にしておいて err = dx + dy から始める、Wikipedia に載っている全方向対応版そのままです。浮動小数点を使わないので、どの角度でも同じ入力なら必ず同じドット列になります。ドット絵では「再現性」が結構大事でして。

矩形:枠線 / 塗り / パターン

矩形は 3 種類ありますが、1 つの関数で kind を切り替えているだけです。

/// 矩形ドット生成(kind: 3=枠線, 4=塗り, 5=パターン)
fn rect_dots(sx: usize, sy: usize, ex: usize, ey: usize, kind: usize) -> Vec<(usize, usize)> {
    let (x0, x1) = (sx.min(ex), sx.max(ex));
    let (y0, y1) = (sy.min(ey), sy.max(ey));
    let parity = (sx + sy) % 2;
    let mut pts = Vec::new();
    for y in y0..=y1 {
        for x in x0..=x1 {
            let draw = match kind {
                3 => x == x0 || x == x1 || y == y0 || y == y1,
                4 => true,
                5 => (x + y) % 2 == parity,
                _ => false,
            };
            if draw { pts.push((x, y)); }
        }
    }
    pts
}
  • 枠線:上下左右いずれかの辺上にあるドットだけ
  • 塗り:全部
  • パターン:(x + y) % 2 が起点のパリティと一致するドットだけ

パターン矩形のパリティを起点 (sx, sy) 基準にしているのがポイントで、こうしておくとドラッグをどちらの方向に伸ばしても、起点のドットは必ず塗られます。「クリックした場所から模様が始まる」ほうが直感に合うので。

楕円:バウンディングボックスから求める

楕円は、ドラッグの対角 2 点をバウンディングボックスとして、その内接楕円を描きます。

直線と違って、こちらは浮動小数点で素直に楕円の式 (x−cx)²/rx² + (y−cy)²/ry² ≤ 1 を使っています。中点楕円アルゴリズムのような整数版もあるのですが、キャンバスが最大 128×128 ドットなので、素直に計算しても全然問題ない速度です。

/// 楕円ドット生成(fill=false: 輪郭のみ / fill=true: 内部塗り)
fn ellipse_dots(sx: usize, sy: usize, ex: usize, ey: usize, fill: bool) -> Vec<(usize, usize)> {
    let (x0, x1) = (sx.min(ex) as i32, sx.max(ex) as i32);
    let (y0, y1) = (sy.min(ey) as i32, sy.max(ey) as i32);
    let cx = (x0 + x1) as f64 / 2.0;
    let cy = (y0 + y1) as f64 / 2.0;
    let rx = (x1 - x0) as f64 / 2.0;
    let ry = (y1 - y0) as f64 / 2.0;
    let rx2 = rx * rx;
    let ry2 = ry * ry;
    let mut set = std::collections::HashSet::new();

    if fill {
        // 各行の楕円内スキャンライン
        for y in y0..=y1 {
            let dy = y as f64 - cy;
            let t = if ry > 0.0 { 1.0 - dy * dy / ry2 } else { if dy.abs() < 0.5 { 1.0 } else { -1.0 } };
            if t < 0.0 { continue; }
            let hw = if rx > 0.0 { (rx2 * t).sqrt() } else { 0.0 };
            let xl = (cx - hw).ceil() as i32;
            let xr = (cx + hw).floor() as i32;
            for x in xl.max(x0)..=xr.min(x1) {
                set.insert((x as usize, y as usize));
            }
        }
    } else {
        // 各行の左右端点
        for y in y0..=y1 {
            let dy = y as f64 - cy;
            let t = if ry > 0.0 { 1.0 - dy * dy / ry2 } else { if dy.abs() < 0.5 { 1.0 } else { -1.0 } };
            if t < 0.0 { continue; }
            let hw = if rx > 0.0 { (rx2 * t).sqrt() } else { 0.0 };
            let xl = (cx - hw).round() as i32;
            let xr = (cx + hw).round() as i32;
            if (x0..=x1).contains(&xl) { set.insert((xl as usize, y as usize)); }
            if (x0..=x1).contains(&xr) { set.insert((xr as usize, y as usize)); }
        }
        // 各列の上下端点(隙間を埋める)
        for x in x0..=x1 {
            let dx = x as f64 - cx;
            let t = if rx > 0.0 { 1.0 - dx * dx / rx2 } else { if dx.abs() < 0.5 { 1.0 } else { -1.0 } };
            if t < 0.0 { continue; }
            let hh = if ry > 0.0 { (ry2 * t).sqrt() } else { 0.0 };
            let yt = (cy - hh).round() as i32;
            let yb = (cy + hh).round() as i32;
            if (y0..=y1).contains(&yt) { set.insert((x as usize, yt as usize)); }
            if (y0..=y1).contains(&yb) { set.insert((x as usize, yb as usize)); }
        }
    }
    set.into_iter().collect()
}
塗り:スキャンライン

塗りは各行 y について、楕円の式を x について解いた半幅 hw = rx · √(1 − dy²/ry²) を求め、cx ± hw の範囲を塗るだけ。典型的なスキャンライン法です。

輪郭:行の端点 + 列の端点

輪郭のほうが少し工夫が要ります。

各行の左右端点だけを打っていくと、楕円の上下(傾きが急な部分)で隙間が空くんです。行ごとに 1 ドットずつしか進まないのに、x 方向には一気に何ドットも動くので。

そこで、各行の左右端点に加えて、各列の上下端点も打つようにしました。行方向で隙間が空く場所は列方向で埋まり、列方向で隙間が空く場所は行方向で埋まる、という関係です。両方打つと重複するドットが出るので HashSet で除去しています。

ry > 0.0 の分岐は、高さ 0(一直線にドラッグしたとき)のゼロ除算避けです。この場合は dy.abs() < 0.5 の行だけ、つまり横一線として描かれます。

数ドットしかない小さな楕円だと、さすがに「丸」というより「なんとなく丸っぽい何か」になりますが、8×8 のスプライトで使う分にはまぁ十分かなと。

塗りつぶし:BFS のフラッドフィル

塗りつぶしは、クリックしたドットと同じ色インデックスで隣接している領域を、描画色で塗ります。幅優先探索(BFS) で、上下左右の 4 方向連結です。

/// 塗りつぶし: クリック点から同色の隣接ドットを BFS で収集
fn flood_fill_dots(
    block: &[Vec],
    sx: usize, sy: usize,
    block_px: usize,
    top_row: usize, top_col: usize,
    fill_color: u8, chr_len: usize,
) -> Vec<(usize, usize, usize)> {
    let target = block[sy][sx];
    if target == fill_color { return vec![]; }
    let mut visited = vec![vec![false; block_px]; block_px];
    let mut queue = std::collections::VecDeque::new();
    queue.push_back((sx, sy));
    visited[sy][sx] = true;
    let mut pixels = Vec::new();
    while let Some((x, y)) = queue.pop_front() {
        let off = ((top_row + y / 8) * 16 + (top_col + x / 8)) * 16;
        if off + 16 <= chr_len {
            pixels.push((off, x % 8, y % 8));
        }
        for (nx, ny) in [
            (x + 1, y), (x.wrapping_sub(1), y),
            (x, y + 1), (x, y.wrapping_sub(1)),
        ] {
            if nx < block_px && ny < block_px && !visited[ny][nx] && block[ny][nx] == target {
                visited[ny][nx] = true;
                queue.push_back((nx, ny));
            }
        }
    }
    pixels
}

いくつかポイントを。

  • 比較するのは RGB ではなくパレットの色インデックス(0〜3)です。CHR に入っているのはインデックスなので、これが正しい
  • 探索対象はデコード済みの blockなので、探索中に CHR を再パースしません
  • target == fill_color なら何もしない。同じ色で塗ろうとして無限ループ、を防ぐ定番のガード
  • x.wrapping_sub(1) は usize の 0 − 1 で panic しないようにする小技で、ラップして巨大な値になっても nx < block_px で弾かれます
  • 結果は (オフセット, px, py) の 3 つ組のリストなので、そのまま ApplyLine に流せます

塗りつぶしの範囲は現在表示中のフォーカスブロック内に限定されます。フォーカス 8px なら 1 タイル内、128px なら 16×16 タイルの範囲。CHR 全体に広がったりはしないので、安心して使えます。

apply_line:全図形ツール共通の書き込み

直線・矩形・楕円・塗りつぶしは全部 ApplyLine { pixels } として apply_action() に届き、apply_line() で処理されます。

pub(super) fn apply_line(&mut self, pixels: Vec<(usize, usize, usize)>) {
    if pixels.is_empty() { return; }
    let chr_len = match &self.rom {
        Some(r) => r.chr_data().len(),
        None => return,
    };
    // undo バッチ: 影響タイルを重複なく保存
    let mut seen = std::collections::HashSet::new();
    let mut batch: Vec<(usize, [u8; 16])> = Vec::new();
    for &(off, _, _) in &pixels {
        if off + 16 <= chr_len && seen.insert(off) {
            let saved: [u8; 16] = self.rom.as_ref().unwrap().chr_data()
                [off..off + 16].try_into().unwrap();
            batch.push((off, saved));
        }
    }
    self.push_undo_batch(batch);
    // ドット書き込み
    let color = self.drawing_color_idx;
    for (off, px, py) in pixels {
        if off + 16 <= chr_len {
            if let Some(rom) = &mut self.rom {
                encode_dot(&mut rom.chr_data_mut()[off..off + 16], px, py, color);
            }
        }
    }
    self.is_modified = true;
    self.texture_dirty = true;
}

やっていることは 2 段階で、まず影響するタイルのオフセットを HashSet で重複除去しながらスナップショットを取り、push_undo_batch() に積む。それから各ドットを encode_dot() で書く。書く前に全部スナップショットを取るのが重要で、順番を逆にすると塗った後の状態を保存してしまいます。

前回のペンでは「ストローク中に初めて触れたタイル」を追跡する必要がありましたが、図形ツールはドット列が一度に確定するので、こちらのほうがシンプルですね。

スタンプ:2 フェーズのコピー & ペースト

最後はスタンプツールです。これだけ他とは毛色が違って、2 段階の状態を持つツールになっています。

フェーズ 操作 動作
Phase 1(選択) ドラッグ 点線の枠で範囲をプレビュー
  リリース 範囲を確定し、色インデックスをバッファにコピー
Phase 2(貼り付け) ドラッグ 点線の枠 + ドットのプレビューがカーソルに追従
  リリース CHR に書き込む(バッファは保持)
  右クリック キャンセルして Phase 1 に戻る

バッファが保持されるので、同じ絵を何度でもポンポン押せます。スタンプ、というネーミングはそこからとなります。

状態
/// スタンプ Phase1: 選択ドラッグ起点
pub(super) stamp_sel_start: Option<(usize, usize)>,
/// スタンプ Phase2: 確定済み (w, h, pixels[h][w]) バッファ
pub(super) stamp_buffer: Option<(usize, usize, Vec<vec>)>,
/// スタンプ Phase2: 現在の貼り付け位置(ブロック左上ドット座標)
pub(super) stamp_paste_pos: (usize, usize),
/// スタンプ Phase2: ドラッグ中のアンカーオフセット(ドラッグ開始点 - 貼り付け位置)
pub(super) stamp_drag_anchor: Option<(i32, i32)>,</vec

この 4 つの組み合わせでフェーズが決まります。

状態 条件
待機 buffer = None, sel_start = None
Phase 1 選択中 sel_start = Some
Phase 2 待機 buffer = Some, sel_start = None, anchor = None
Phase 2 ドラッグ中 buffer = Some, sel_start = None, anchor = Some

専用の enum を切っても良かったのですが、egui の即時モードだと「この条件のときはこう描く」を if で並べたほうが読みやすかったので、こういう形に落ち着いています。

Phase 1:範囲をバッファに取り込む

リリース時に、デコード済みの block から範囲を切り出すだけです。

let (x0, x1) = (sx.min(ex), sx.max(ex));
let (y0, y1) = (sy.min(ey), sy.max(ey));
let w = x1 - x0 + 1;
let h = y1 - y0 + 1;
let pixels: Vec<vec> = (y0..=y1)
    .map(|y| (x0..=x1).map(|x| block[y][x]).collect())
    .collect();
self.stamp_buffer = Some((w, h, pixels));
self.stamp_paste_pos = (x0, y0);
self.stamp_drag_anchor = None;</vec

バッファに入るのは色インデックスなので、貼り付け先でパレットセットを変えても、インデックスとして正しく解釈されます。前回のタイルクリップボードが CHR バイト列だったのと同じ考え方ですね。

ドラッグせずにクリックだけした場合は、その 1 ドットを 1×1 のスタンプとして取り込みます。

Phase 2:アンカーで自然に動かす

貼り付け位置をドラッグで動かすとき、単純に「カーソル位置 = スタンプの左上」にすると、掴んだ瞬間にスタンプがカーソルへスナップして飛んでしまいます。

そこで、ドラッグ開始時に「カーソル位置 − 現在の貼り付け位置」をアンカーとして記録し、ドラッグ中はカーソル位置からアンカーを引いた場所に置きます。

// Phase2: ドラッグ開始 → アンカー記録
if response.drag_started_by(egui::PointerButton::Primary) {
    let (ppx, ppy) = self.stamp_paste_pos;
    self.stamp_drag_anchor = Some((px as i32 - ppx as i32, py as i32 - ppy as i32));
    return None;
}
// ドラッグ中の位置更新
if let Some((ax, ay)) = self.stamp_drag_anchor {
    self.stamp_paste_pos = (
        (cx as i32 - ax).max(0) as usize,
        (cy as i32 - ay).max(0) as usize,
    );
}

これで、スタンプのどこを掴んでもその相対位置を保ったまま動きます。.max(0) で左上にはみ出さないようにしています。

点線の枠

選択範囲と貼り付け位置の枠は、白と黒を交互に描いた点線にしています。ドット絵の背景色は何色になるか分からないので、白だけ・黒だけだと背景と同化して見えなくなることがあるんですよね。

/// 点線の矩形アウトラインを描画(白と暗色を交互に)
fn draw_dashed_rect_outline(painter: &egui::Painter, r: egui::Rect, dash: f32) {
    let edges = [
        (r.left_top(),    r.right_top()),
        (r.right_top(),   r.right_bottom()),
        (r.right_bottom(),r.left_bottom()),
        (r.left_bottom(), r.left_top()),
    ];
    for (p0, p1) in edges {
        let dx = p1.x - p0.x;
        let dy = p1.y - p0.y;
        let len = (dx * dx + dy * dy).sqrt();
        if len < 0.1 { continue; }
        let steps = (len / dash).ceil() as usize;
        for i in 0..steps {
            let t0 = (i as f32 * dash) / len;
            let t1 = ((i as f32 + 1.0) * dash / len).min(1.0);
            let color = if i % 2 == 0 { egui::Color32::WHITE } else { egui::Color32::from_black_alpha(200) };
            painter.line_segment(
                [egui::pos2(p0.x + dx * t0, p0.y + dy * t0),
                 egui::pos2(p0.x + dx * t1, p0.y + dy * t1)],
                egui::Stroke::new(1.5, color),
            );
        }
    }
}

4 辺をそれぞれ dash の長さで分割して、偶数番目は白、奇数番目は黒の線分を引く。egui には破線を引く API も一応あるのですが、2 色交互にしたかったので自前です。

確定:ApplyStamp

Phase 2 のリリースで、バッファの各ドットを「(オフセット, px, py, 色)」の 4 つ組に変換して ApplyStamp を返します。

let stamp_pixels: Vec<(usize, usize, usize, u8)> = if let Some((sw, sh, pixels)) = &self.stamp_buffer {
    let (sw, sh) = (*sw, *sh);
    (0..sh).flat_map(|row| {
        let pixels_row = &pixels[row];
        (0..sw).filter_map(move |col| {
            let bx = ppx + col;
            let by = ppy + row;
            if bx >= block_px || by >= block_px { return None; }
            let off = ((top_row + by / 8) * 16 + (top_col + bx / 8)) * 16;
            if off + 16 > chr_len { return None; }
            Some((off, bx % 8, by % 8, pixels_row[col]))
        }).collect::<vec<_>>()
    }).collect()
} else { vec![] };</vec<_>

ApplyLine との違いは、ドットごとに色を持っていることだけです。ApplyLine は「描画色で塗る」なので色は 1 つですが、スタンプはコピー元の色をそのまま持ってきます。apply_action() 側の処理も apply_line() とほぼ同じで、スナップショット → push_undo_batch() → encode_dot() の順です。

キャンバスからはみ出す部分は bx >= block_px で捨てているので、端に押してもクリップされるだけで壊れません。

まとめ

  • 図形ツールは「ドラッグ中は painter でプレビューだけ、リリースで ApplyLine」。CHR もアンドゥスタックも汚さない
  • プレビューと確定が同じ shape_dots() を通るので結果がずれない
  • リリース時は interact_pointer_pos() が None になることがあるので、button_released() + hover_pos() で終点を取る
  • 直線は Bresenham、矩形は kind で枠 / 塗り / パターンを切り替え、楕円は式を直接使って塗りはスキャンライン、輪郭は行と列の両方の端点
  • 塗りつぶしは色インデックス比較の 4 方向 BFS。デコード済みブロック上で探索
  • スタンプは 4 つの Option でフェーズを表現し、アンカーで自然なドラッグ、2 色の点線で視認性を確保
  • 全部 (オフセット, px, py[, 色]) のリストに落として push_undo_batch() + encode_dot() に流すだけ

書き込みの出口を encode_dot()、アンドゥの入口を push_undo_batch() に絞っておいたおかげで、ツールを 1 つ足すたびにやることが「ドット座標のリストを作る関数を書く」だけになりました。楕円ツールなんかは本当に ellipse_dots() を足して shape_dots() の match に 2 行加えただけです。

次回は PNG インポート。Aseprite で描いた絵をそのまま放り込みたい、という欲望を叶えるための、3 つのマッピング戦略と透過処理の話になります。

ではではぁ。

またまたぁ。

Comment

Related Article

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その5 図形ツール(Bresenham・楕円・塗りつぶし・スタンプ)

2026.09.30

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その4 ドットエディタの基礎とアンドゥ設計

2026.09.28

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その3 egui で組む 3 パネル UI とバンクビュー

2026.09.26

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その2 CHR の 2BPP フォーマットと iNES ヘッダ、パレット

2026.09.24

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その1 概要とアーキテクチャ

2026.09.22

ゲーム開発に必要な基本数学入門

2025.08.15

Godot 4 & Rust 始め方

2025.06.19

Rustでつくる ふっかつのじゅもん

2025.04.29

Tauri(Rust) × AI で作る GitGUIクライアントアプリ その5

2025.04.14

Tauri(Rust) × AI で作る GitGUIクライアントアプリ その4

2025.04.10

CATEGORY LIST

LATEST NEWS

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その5 図形ツール(Bresenham・楕円・塗りつぶし・スタンプ)

Rust

2026.09.30

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その4 ドットエディタの基礎とアンドゥ設計

Rust

2026.09.28

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その3 egui で組む 3 パネル UI とバンクビュー

Rust

2026.09.26

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その2 CHR の 2BPP フォーマットと iNES ヘッダ、パレット

Rust

2026.09.24

【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その1 概要とアーキテクチャ

Rust

2026.09.22

M5Stack Stopwatchで、オリジナル LT(Lightning Talks)タイマーを作成した。

電子工作

2026.09.21

【Railway】GitHub Actions で定期的にサーバーをリスタートさせる

運用

2026.08.30

ラズパイが高騰しているのでストレス解消のためAliExpressで購入した電子パーツ達

電子工作

2026.08.02

【2026】【VMware Fusion】MacでWindowsを無料で扱う。

windows

2026.07.19

Raspberry Pi 5 でマインクラフトサーバーを立てる(Java版 × 統合版クロスプレイ対応)

RaspberryPi

2026.06.24

ラズパイが高い。

RaspberryPi

2026.05.26

【Claude Code】フル稼働。ToDo Appを様々なGUIフレームワーク用いて作らせる。

AI・Bot・algorithm

2026.05.24

RANKING

Follow

SPONSOR

現在、掲載募集中です。



Links

About Us

WEBデザイナーの、WEBデザイナーによる、WEBデザイナーの為のサイト。「みんなで書こう!」と仲間を募ってみたが、結局書くのは自分だけとなってしまいました。日々のメモを綴っていきます。

Entry Profile

Graphical FrontEnd Engineer
- Daisuke Takayama

MAD CITY 北九州市で生まれ育つ。20代はバンド活動に明け暮れ、ふと「webデザイナーになりたい。」と思い、デジタルハリウッド福岡校入学。卒業後、数々の賞を受賞、web業界をざわつかせる。
現在、主に、ゲーム制作中心に港区六本木界隈で活動中。

FOLLOW US