Lesson 0003: Custom 2D Canvas & egui::Painter
Turning the empty CentralPanel into a multi-layer PCB viewport with Pos2, Vec2, Rect, and Copy semantics
Printing "Rendering Top Copper..." as a text label in Lesson 0002 proved our checkboxes were wired up, but it is a pretty underwhelming way to inspect a circuit board.
So, welcome to Act I: The 2D CAD Canvas & Viewport. We are going to replace those text placeholders with a real 2D vector canvas that draws an FR4 PCB substrate, Top and Bottom Copper traces, rectangular SMD pads, plated through-hole vias, Silkscreen courtyards, and unrouted Ratsnest airwires.
1. Reserving Canvas Space with ui.allocate_painter
Unlike standard widgets (ui.checkbox, ui.button) that know their own size, a 2D CAD viewport needs you to explicitly tell egui how much rectangular space to claim and what mouse interactions to listen for.See Ui::allocate_painter in the official egui docs: it returns a two-element Rust tuple (Response, Painter) clipped to the allocated rectangle.
egui::CentralPanel::default().show(ui, |ui| {
let (response, painter) =
ui.allocate_painter(ui.available_size(), egui::Sense::hover());
self.paint_static_board(&painter, response.rect);
});
Look at what happens in that single call:
ui.available_size()queries the remaining width and height insideCentralPanelas anegui::Vec2.(response, painter)destructures the returned tuple:response.rectis the screen-spaceegui::Rectreserved for our canvas, andpainter(egui::Painter) emits 2D vector primitives clipped to that rectangle.
flowchart LR
Avail["ui.available_size()<br/>(Vec2: width, height)"] --> Alloc["ui.allocate_painter(size, Sense::hover())"]
Alloc --> Resp["response.rect<br/>(Canvas screen bounds: min..max)"]
Alloc --> Paint["painter: egui::Painter<br/>(2D vector draw stream)"]
Resp --> Helper["centered_board_rect(canvas_rect, 28.0)<br/>smd_pad_rect(center, size)"]
Helper --> Primitives["painter.rect(...)<br/>painter.line_segment(...)<br/>painter.circle(...)"]
Paint --> Primitives
Figure 3.1: ui.allocate_painter reserves a screen-space Rect and returns a clipped Painter for drawing PCB layers.
2. Screen-Space Primitives & Rust's Copy Trait
In egui, screen coordinates are measured in logical points from the top-left corner of the window ((0.0, 0.0)), where +x points right and +y points down. Five lightweight types power all of our 2D CAD drawing:In Rust (Rust Book §4.1), small stack-only types like Pos2 (8 bytes), Vec2 (8 bytes), Rect (16 bytes), Color32 (4 bytes), and Stroke (8 bytes) implement the Copy trait. Passing f_cu_stroke into three painter.line_segment(...) calls copies 8 bytes on the stack each time—ownership never moves!
- Position (
Pos2/pos2(x, y)): A specific 2D coordinate point (e.g. a via centre or pad centre). Adding a displacement vector to a point yields a new point:origin + vec2(80.0, 90.0). - Displacement / Size (
Vec2/vec2(w, h)): A 2D offset or width/height pair (e.g.vec2(20.0, 12.0)for an SMD pad's dimensions). - Bounding Box (
Rect): Defined by its top-leftmin: Pos2and bottom-rightmax: Pos2corners. - Colour & Outline (
Color32&Stroke):Color32::from_rgb(r, g, b)specifies an RGB colour, andStroke::new(width_f32, color)bundles line thickness and colour for traces and component outlines.
3. Painting the Static Board & Extracting Pure Geometry: Block by Block
Now, if you bury all of your coordinate maths directly inside paint_static_board, you cannot unit-test your pad or board bounds without spinning up a full UI frame.
Instead, let's walk through all four blocks of code introduced in apps/0003-custom-2d-canvas-and-painter/src/main.rs: the expanded egui geometry imports and pure Rect helpers, the multi-layer paint_static_board method, the updated eframe::App::ui layout, and the geometry unit tests.Turns out, writing Stroke::new(5.0_f32, ...) with an explicit _f32 suffix saves you a compiler warning: Stroke::new accepts generic impl Into<f32>, and Rust 1.97+ warns if an untyped float literal falls back to f32 through a generic trait bound.
Block 1: Geometry Imports & Pure Bounding-Box Helpers (centered_board_rect & smd_pad_rect)
use eframe::egui::{self, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2};
/// Computes the inset FR4 board outline `Rect` inside the allocated `canvas_rect`
/// by shrinking all four edges inward by `margin` screen points.
pub fn centered_board_rect(canvas_rect: Rect, margin: f32) -> Rect {
Rect::from_min_max(
pos2(canvas_rect.min.x + margin, canvas_rect.min.y + margin),
pos2(canvas_rect.max.x - margin, canvas_rect.max.y - margin),
)
}
/// Computes the bounding `Rect` for a rectangular SMD pad centered at `center`
/// with width `size.x` and height `size.y`.
pub fn smd_pad_rect(center: Pos2, size: Vec2) -> Rect {
let half_w = size.x / 2.0;
let half_h = size.y / 2.0;
Rect::from_min_max(
pos2(center.x - half_w, center.y - half_h),
pos2(center.x + half_w, center.y + half_h),
)
}
use eframe::egui::{self, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2};: Rust's nestedusesyntax imports both theeguimodule itself (self) and the specific 2D types and constructor functions (pos2,vec2) we use on every line of CAD code.pub fn centered_board_rect(canvas_rect: Rect, margin: f32) -> Rect: Takes aRectby value (RectisCopy, so 16 bytes on the stack) and constructs an insetRectviaRect::from_min_max(...)by addingmargintomin(top-left) and subtractingmarginfrommax(bottom-right).pub fn smd_pad_rect(center: Pos2, size: Vec2) -> Rect: Computeshalf_w = size.x / 2.0andhalf_h = size.y / 2.0so a rectangular SMD pad is symmetric around itscenter: Pos2. Extracting these two pure functions makes all+,-, and/coordinate arithmetic directly testable bycargo testandcargo-mutants.
Block 2: Drawing the Multi-Layer Board with egui::Painter (paint_static_board)
fn paint_static_board(&self, painter: &egui::Painter, canvas_rect: Rect) {
// 1. Dark CAD background
painter.rect_filled(canvas_rect, 0.0, Color32::from_rgb(12, 16, 14));
// 2. Inset FR4 PCB substrate outline
let board_rect = centered_board_rect(canvas_rect, 28.0);
painter.rect(
board_rect,
6.0,
Color32::from_rgb(16, 40, 30),
Stroke::new(2.0_f32, Color32::from_rgb(47, 111, 82)),
StrokeKind::Inside,
);
let origin = board_rect.min;
// 3. Bottom Copper (B.Cu - Blue)
if self.show_bottom_copper {
let b_cu_stroke = Stroke::new(5.0_f32, Color32::from_rgb(59, 142, 234));
painter.line_segment(
[origin + vec2(80.0, 180.0), origin + vec2(220.0, 180.0)],
b_cu_stroke,
);
painter.line_segment(
[origin + vec2(220.0, 180.0), origin + vec2(280.0, 120.0)],
b_cu_stroke,
);
}
// 4. Top Copper (F.Cu - Terracotta Red) + SMD Pads + Plated Via
if self.show_top_copper {
let f_cu_color = Color32::from_rgb(224, 82, 67);
let f_cu_stroke = Stroke::new(5.0_f32, f_cu_color);
let pad_a = origin + vec2(80.0, 90.0);
let pad_b = origin + vec2(80.0, 130.0);
let via_pos = origin + vec2(280.0, 120.0);
painter.line_segment([pad_a, origin + vec2(200.0, 90.0)], f_cu_stroke);
painter.line_segment([origin + vec2(200.0, 90.0), via_pos], f_cu_stroke);
painter.line_segment([pad_b, origin + vec2(170.0, 130.0)], f_cu_stroke);
let pad_size = vec2(20.0, 12.0);
painter.rect_filled(smd_pad_rect(pad_a, pad_size), 2.0, f_cu_color);
painter.rect_filled(smd_pad_rect(pad_b, pad_size), 2.0, f_cu_color);
// Plated through-hole via ring + drill hole
painter.circle(
via_pos,
8.0,
Color32::from_rgb(16, 40, 30),
Stroke::new(3.5_f32, Color32::from_rgb(230, 193, 90)),
);
}
// 5. Front Silkscreen (F.SilkS - Cream component courtyard)
if self.show_silkscreen {
let u1_rect = Rect::from_min_size(origin + vec2(62.0, 68.0), vec2(54.0, 132.0));
painter.rect_stroke(
u1_rect,
2.0,
Stroke::new(1.5_f32, Color32::from_rgb(240, 230, 140)),
StrokeKind::Outside,
);
}
// 6. Unrouted Ratsnest airwire
if self.show_ratsnest {
painter.line_segment(
[origin + vec2(80.0, 130.0), origin + vec2(220.0, 180.0)],
Stroke::new(1.5_f32, Color32::from_rgb(111, 247, 255)),
);
}
}
- Shared Borrow (
&self) &&egui::Painter: Becausepaint_static_boardonly readsself.show_...flags and emits shapes intopainter, it takes an immutable reference&selfrather than&mut self. painter.rect_filled,painter.rect, andpainter.rect_stroke:rect_filled(rect, corner_radius, fill_color)draws a solid filled rectangle (used for the dark viewport backdrop and the two SMD pads computed bysmd_pad_rect).rect(rect, corner_radius, fill_color, stroke, StrokeKind::Inside)draws both the green FR4 substrate fill and its border stroke in one call.rect_stroke(rect, corner_radius, stroke, StrokeKind::Outside)draws an unfilled outline for theU1silkscreen courtyard.
painter.line_segment([p0, p1], stroke)&CopyReuse: Takes a two-element array[Pos2; 2]and aStroke. Notice howb_cu_strokeandf_cu_strokeare passed by value into multipleline_segmentcalls without.clone()becauseStrokeimplementsCopy.painter.circle(center, radius, fill_color, stroke): Draws the plated through-hole via atvia_poswith a gold annular ring (Stroke) and dark drill-hole centre (fill_color).
Block 3: Allocating the Painter in CentralPanel (impl eframe::App)
impl eframe::App for PcbReviewerApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::Panel::left("layer_panel").show(ui, |ui| {
ui.heading("PCB Layers");
ui.checkbox(&mut self.show_top_copper, "Top Copper (F.Cu)");
ui.checkbox(&mut self.show_bottom_copper, "Bottom Copper (B.Cu)");
ui.checkbox(&mut self.show_silkscreen, "Silkscreen (F.SilkS)");
ui.checkbox(&mut self.show_ratsnest, "Ratsnest (Unrouted)");
ui.separator();
if ui.button("Isolate Top Copper").clicked() {
self.isolate_top_copper();
}
if ui.button("Show All Layers").clicked() {
self.show_all_layers();
}
if ui.button("Reset Defaults").clicked() {
self.reset_layers();
}
});
egui::CentralPanel::default().show(ui, |ui| {
let (response, painter) = ui.allocate_painter(ui.available_size(), Sense::hover());
self.paint_static_board(&painter, response.rect);
});
}
}
if ui.button("Reset Defaults").clicked(): Wires up a third preset button inPanel::leftthat callsself.reset_layers()to restore the default layer state.let (response, painter) = ui.allocate_painter(ui.available_size(), Sense::hover());: Claims the entire remainingCentralPanelarea (ui.available_size()), registers hover sensing (Sense::hover()), and returns both the layoutResponse(response.rect) and the clippedPainter.self.paint_static_board(&painter, response.rect);: Passes a shared reference&painterand theCopyresponse.rectinto our board renderer.
Block 4: Unit Tests for Board & Pad Geometry (#[cfg(test)] mod tests)
#[test]
fn centered_board_rect_insets_all_four_edges_by_margin() {
let canvas = Rect::from_min_max(pos2(10.0, 20.0), pos2(310.0, 220.0));
let board = centered_board_rect(canvas, 25.0);
assert_eq!(board.min, pos2(35.0, 45.0));
assert_eq!(board.max, pos2(285.0, 195.0));
}
#[test]
fn smd_pad_rect_computes_symmetric_bounds_around_center() {
let pad = smd_pad_rect(pos2(100.0, 60.0), vec2(16.0, 10.0));
assert_eq!(pad.min, pos2(92.0, 55.0));
assert_eq!(pad.max, pos2(108.0, 65.0));
}
- Asymmetric, Non-Zero Test Inputs:
canvasuses distinctmin.x(10.0),min.y(20.0),max.x(310.0), andmax.y(220.0) values, andsmd_pad_rectusespos2(100.0, 60.0)withvec2(16.0, 10.0). assert_eq!onPos2: Verifies bothminandmaxcorners so that any+/-/*//operator flip introduced bycargo-mutantsimmediately fails an assertion.
Complete src/main.rs Listing
Putting all four blocks together, here is the complete, runnable apps/0003-custom-2d-canvas-and-painter/src/main.rs:
use eframe::egui::{self, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2};
fn main() -> eframe::Result {
let mut options = eframe::NativeOptions::default();
options.viewport = egui::ViewportBuilder::default().with_inner_size([1024.0, 720.0]);
eframe::run_native(
"Schematic & PCB Design Reviewer — Lesson 0003",
options,
Box::new(|_cc| Ok(Box::new(PcbReviewerApp::default()))),
)
}
/// Computes the inset FR4 board outline `Rect` inside the allocated `canvas_rect`
/// by shrinking all four edges inward by `margin` screen points.
pub fn centered_board_rect(canvas_rect: Rect, margin: f32) -> Rect {
Rect::from_min_max(
pos2(canvas_rect.min.x + margin, canvas_rect.min.y + margin),
pos2(canvas_rect.max.x - margin, canvas_rect.max.y - margin),
)
}
/// Computes the bounding `Rect` for a rectangular SMD pad centered at `center`
/// with width `size.x` and height `size.y`.
pub fn smd_pad_rect(center: Pos2, size: Vec2) -> Rect {
let half_w = size.x / 2.0;
let half_h = size.y / 2.0;
Rect::from_min_max(
pos2(center.x - half_w, center.y - half_h),
pos2(center.x + half_w, center.y + half_h),
)
}
pub struct PcbReviewerApp {
pub show_top_copper: bool,
pub show_bottom_copper: bool,
pub show_silkscreen: bool,
pub show_ratsnest: bool,
}
impl Default for PcbReviewerApp {
fn default() -> Self {
let mut app = Self {
show_top_copper: false,
show_bottom_copper: false,
show_silkscreen: false,
show_ratsnest: false,
};
app.reset_layers();
app
}
}
impl PcbReviewerApp {
pub fn reset_layers(&mut self) {
self.show_top_copper = true;
self.show_bottom_copper = true;
self.show_silkscreen = true;
self.show_ratsnest = false;
}
pub fn isolate_top_copper(&mut self) {
self.show_top_copper = true;
self.show_bottom_copper = false;
self.show_silkscreen = false;
self.show_ratsnest = false;
}
pub fn show_all_layers(&mut self) {
self.show_top_copper = true;
self.show_bottom_copper = true;
self.show_silkscreen = true;
self.show_ratsnest = true;
}
fn paint_static_board(&self, painter: &egui::Painter, canvas_rect: Rect) {
// 1. Dark CAD background
painter.rect_filled(canvas_rect, 0.0, Color32::from_rgb(12, 16, 14));
// 2. Inset FR4 PCB substrate outline
let board_rect = centered_board_rect(canvas_rect, 28.0);
painter.rect(
board_rect,
6.0,
Color32::from_rgb(16, 40, 30),
Stroke::new(2.0_f32, Color32::from_rgb(47, 111, 82)),
StrokeKind::Inside,
);
let origin = board_rect.min;
// 3. Bottom Copper (B.Cu - Blue)
if self.show_bottom_copper {
let b_cu_stroke = Stroke::new(5.0_f32, Color32::from_rgb(59, 142, 234));
painter.line_segment(
[origin + vec2(80.0, 180.0), origin + vec2(220.0, 180.0)],
b_cu_stroke,
);
painter.line_segment(
[origin + vec2(220.0, 180.0), origin + vec2(280.0, 120.0)],
b_cu_stroke,
);
}
// 4. Top Copper (F.Cu - Terracotta Red) + SMD Pads + Plated Via
if self.show_top_copper {
let f_cu_color = Color32::from_rgb(224, 82, 67);
let f_cu_stroke = Stroke::new(5.0_f32, f_cu_color);
let pad_a = origin + vec2(80.0, 90.0);
let pad_b = origin + vec2(80.0, 130.0);
let via_pos = origin + vec2(280.0, 120.0);
painter.line_segment([pad_a, origin + vec2(200.0, 90.0)], f_cu_stroke);
painter.line_segment([origin + vec2(200.0, 90.0), via_pos], f_cu_stroke);
painter.line_segment([pad_b, origin + vec2(170.0, 130.0)], f_cu_stroke);
let pad_size = vec2(20.0, 12.0);
painter.rect_filled(smd_pad_rect(pad_a, pad_size), 2.0, f_cu_color);
painter.rect_filled(smd_pad_rect(pad_b, pad_size), 2.0, f_cu_color);
// Plated through-hole via ring + drill hole
painter.circle(
via_pos,
8.0,
Color32::from_rgb(16, 40, 30),
Stroke::new(3.5_f32, Color32::from_rgb(230, 193, 90)),
);
}
// 5. Front Silkscreen (F.SilkS - Cream component courtyard)
if self.show_silkscreen {
let u1_rect = Rect::from_min_size(origin + vec2(62.0, 68.0), vec2(54.0, 132.0));
painter.rect_stroke(
u1_rect,
2.0,
Stroke::new(1.5_f32, Color32::from_rgb(240, 230, 140)),
StrokeKind::Outside,
);
}
// 6. Unrouted Ratsnest airwire
if self.show_ratsnest {
painter.line_segment(
[origin + vec2(80.0, 130.0), origin + vec2(220.0, 180.0)],
Stroke::new(1.5_f32, Color32::from_rgb(111, 247, 255)),
);
}
}
}
impl eframe::App for PcbReviewerApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::Panel::left("layer_panel").show(ui, |ui| {
ui.heading("PCB Layers");
ui.checkbox(&mut self.show_top_copper, "Top Copper (F.Cu)");
ui.checkbox(&mut self.show_bottom_copper, "Bottom Copper (B.Cu)");
ui.checkbox(&mut self.show_silkscreen, "Silkscreen (F.SilkS)");
ui.checkbox(&mut self.show_ratsnest, "Ratsnest (Unrouted)");
ui.separator();
if ui.button("Isolate Top Copper").clicked() {
self.isolate_top_copper();
}
if ui.button("Show All Layers").clicked() {
self.show_all_layers();
}
if ui.button("Reset Defaults").clicked() {
self.reset_layers();
}
});
egui::CentralPanel::default().show(ui, |ui| {
let (response, painter) = ui.allocate_painter(ui.available_size(), Sense::hover());
self.paint_static_board(&painter, response.rect);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_enables_physical_layers_and_hides_ratsnest() {
let app = PcbReviewerApp::default();
assert!(app.show_top_copper);
assert!(app.show_bottom_copper);
assert!(app.show_silkscreen);
assert!(!app.show_ratsnest);
}
#[test]
fn isolate_top_copper_forces_only_top_copper_visible() {
let mut app = PcbReviewerApp {
show_top_copper: false,
show_bottom_copper: true,
show_silkscreen: true,
show_ratsnest: true,
};
app.isolate_top_copper();
assert!(app.show_top_copper);
assert!(!app.show_bottom_copper);
assert!(!app.show_silkscreen);
assert!(!app.show_ratsnest);
}
#[test]
fn show_all_layers_enables_every_layer() {
let mut app = PcbReviewerApp {
show_top_copper: false,
show_bottom_copper: false,
show_silkscreen: false,
show_ratsnest: false,
};
app.show_all_layers();
assert!(app.show_top_copper);
assert!(app.show_bottom_copper);
assert!(app.show_silkscreen);
assert!(app.show_ratsnest);
}
#[test]
fn centered_board_rect_insets_all_four_edges_by_margin() {
let canvas = Rect::from_min_max(pos2(10.0, 20.0), pos2(310.0, 220.0));
let board = centered_board_rect(canvas, 25.0);
assert_eq!(board.min, pos2(35.0, 45.0));
assert_eq!(board.max, pos2(285.0, 195.0));
}
#[test]
fn smd_pad_rect_computes_symmetric_bounds_around_center() {
let pad = smd_pad_rect(pos2(100.0, 60.0), vec2(16.0, 10.0));
assert_eq!(pad.min, pos2(92.0, 55.0));
assert_eq!(pad.max, pos2(108.0, 65.0));
}
}
Painter Primitives & Rect Bounds
Pos2 • Vec2 • Rect • Stroke
Adjust the board margin, SMD pad width/height, and via radius below. Watch how centered_board_rect and smd_pad_rect recompute min and max coordinates for painter.rect, painter.rect_filled, painter.line_segment, and painter.circle.
1. Geometry Parameters
2. Computed Rect Values
Fill in the three blanks below to (1) compute half the pad width from size.x, (2) allocate the 2D canvas painter inside CentralPanel, and (3) pass the allocated screen rectangle from response into paint_static_board:
pub fn smd_pad_rect(center: Pos2, size: Vec2) -> Rect {
let half_w = ;
let half_h = size.y / 2.0;
Rect::from_min_max(
pos2(center.x - half_w, center.y - half_h),
pos2(center.x + half_w, center.y + half_h),
)
}
// Inside PcbReviewerApp::ui:
egui::CentralPanel::default().show(ui, |ui| {
let (response, painter) = ui.(ui.available_size(), Sense::hover());
self.paint_static_board(&painter, );
});
When cargo mutants inspects centered_board_rect and smd_pad_rect, it systematically mutates every + to - and *, every - to + and /, and every / to * and % (25 total mutants across the file!). The reason our test uses non-zero, asymmetric coordinates like pos2(100.0, 60.0) and vec2(16.0, 10.0) is interesting: if you tested at pos2(0.0, 0.0), then 0.0 - half_w mutated to 0.0 / half_w would both evaluate to 0.0 when half_w == 0.0!
#[test]
fn centered_board_rect_insets_all_four_edges_by_margin() {
let canvas = Rect::from_min_max(pos2(10.0, 20.0), pos2(310.0, 220.0));
let board = centered_board_rect(canvas, 25.0);
assert_eq!(board.min, pos2(35.0, 45.0));
assert_eq!(board.max, pos2(285.0, 195.0));
}
#[test]
fn smd_pad_rect_computes_symmetric_bounds_around_center() {
let pad = smd_pad_rect(pos2(100.0, 60.0), vec2(16.0, 10.0));
assert_eq!(pad.min, pos2(92.0, 55.0));
assert_eq!(pad.max, pos2(108.0, 65.0));
}
Copy Value Semantics
3 Questions • Equal-Length Options
Recommended Primary Source
egui::Painter Official Documentation and egui_demo_lib Painting Example.
Compare how allocate_painter reserves a Rect and clips all shapes drawn within that region.
board_rect.min. Ready to express the board in real physical millimetres (mm) and add smooth pan and cursor-anchored zoom in Lesson 0004: World-to-Screen Transforms, Pan, and Zoom (TSTransform)?