Lesson 0004: World-to-Screen Transforms, Pan & Cursor Zoom
Mapping physical PCB millimetres (mm) to screen pixels with TSTransform, Sense::drag(), and ui.input
Hardcoding PCB pads in screen pixels works fine for a static demo, right up until you try to inspect a real board.
In Lesson 0003, our pads and traces were drawn at fixed pixel offsets from the top-left corner of the window. Real KiCad boards, however, live in physical millimetres (mm)—say, a 100 mm × 70 mm board with a 0.8 mm power trace and a 4.0 mm × 2.2 mm SMD pad—and you need to pan across the board and zoom smoothly into dense pin pitches around your mouse cursor.
1. Two Coordinate Spaces: World (mm) vs. Screen (px)
So, every 2D CAD viewport maintains a mathematical camera that converts between two coordinate systems:Turns out, egui's maths crate (emath) provides egui::emath::TSTransform ("Translation + Scaling Transform") out of the box, complete with operator overloading (transform * pos) and .inverse().
- World Space (
world_mm: Pos2): Physical board coordinates in millimetres. A pad atpos2(20.0, 22.0)stays at(20.0 mm, 22.0 mm)forever, regardless of window size or zoom level. - Screen Space (
screen_pt: Pos2): Logical window points passed toegui::Painter.
The forward mapping (World → Screen) scales the millimetre coordinate by scaling (pixels per millimetre) and adds the camera offset translation:
$$\text{screen_pt} = \text{scaling} \cdot \text{world_mm} + \text{translation}$$
And the inverse mapping (Screen → World, implemented by self.transform.inverse() * screen_pt) subtracts translation and divides by scaling:
$$\text{world_mm} = \frac{\text{screen_pt} - \text{translation}}{\text{scaling}}$$
flowchart LR
World["World Space (mm)<br/>pos2(20.0, 22.0)"] -->|"world_to_screen()<br/>self.transform * world_mm"| Screen["Screen Space (px)<br/>pos2(200.0, 192.0)"]
Screen -->|"screen_to_world()<br/>self.transform.inverse() * screen_pt"| World
Drag["Pointer Drag<br/>response.drag_delta()"] -->|"pan_by(delta)"| Trans["translation += delta"]
Wheel["Scroll Wheel at Cursor<br/>ui.input(|i| i.smooth_scroll_delta.y)"] -->|"zoom_around(cursor, factor)"| Trans
Figure 4.1: BoardViewport wraps TSTransform to convert bi-directionally between physical millimetres and screen pixels.
2. Building the Camera & World-Space Renderer: Block by Block
Let's walk through all four blocks of code added or updated in apps/0004-world-to-screen-transforms-pan-and-zoom/src/main.rs before viewing the complete file in context.
Block 1: Encapsulating the Camera (struct BoardViewport)
Let's wrap TSTransform inside a dedicated BoardViewport struct with four testable methods:The reason zoom_around adjusts translation is a classic CAD rite of passage: if you simply multiply self.transform.scaling *= zoom_factor on its own, the board zooms toward (0, 0) in the top-left corner and flies right away from your mouse cursor! Recording world_anchor = self.screen_to_world(screen_anchor) before scaling and solving $\text{translation} = \text{screen_anchor} - \text{world_anchor} \cdot \text{new_scale}$ keeps the exact PCB pad under your cursor pinned in place.
pub const MIN_ZOOM: f32 = 1.5;
pub const MAX_ZOOM: f32 = 60.0;
/// Maps physical PCB coordinates in millimeters (`world_mm`) to `egui` screen points
/// using a translation + uniform-scaling transform (`TSTransform`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BoardViewport {
pub transform: TSTransform,
}
impl Default for BoardViewport {
fn default() -> Self {
Self {
transform: TSTransform::new(vec2(80.0, 60.0), 6.0),
}
}
}
impl BoardViewport {
pub fn world_to_screen(&self, world_mm: Pos2) -> Pos2 {
self.transform * world_mm
}
pub fn screen_to_world(&self, screen_pt: Pos2) -> Pos2 {
self.transform.inverse() * screen_pt
}
pub fn pan_by(&mut self, screen_delta: Vec2) {
self.transform.translation += screen_delta;
}
/// Zooms the viewport around `screen_anchor` (such as the mouse cursor position)
/// so the physical board point under the cursor stays stationary on screen.
pub fn zoom_around(&mut self, screen_anchor: Pos2, zoom_factor: f32) {
let world_anchor = self.screen_to_world(screen_anchor);
let new_scale = (self.transform.scaling * zoom_factor).clamp(MIN_ZOOM, MAX_ZOOM);
self.transform.scaling = new_scale;
self.transform.translation =
screen_anchor.to_vec2() - world_anchor.to_vec2() * new_scale;
}
}
pub const MIN_ZOOM: f32 = 1.5;&MAX_ZOOM: f32 = 60.0;: Compile-timeconstbounds in pixels per millimetre (px/mm) so the user can neither zoom out until the board vanishes into a sub-pixel dot nor zoom in until floating-point precision breaks down.#[derive(Clone, Copy, Debug, PartialEq)]: Automatically implements stack-copying (Copy/Clone), formatted printing for test failure output (Debug), and==equality checks (PartialEq) forassert_eq!.TSTransform::new(vec2(80.0, 60.0), 6.0): Initialises the default camera with an(80 px, 60 px)top-left margin and a scale of6.0 px/mm(so a100 mm × 70 mmboard spans600 px × 420 pxon startup).world_to_screen&screen_to_world:TSTransformoverloads the*operator (Mul<Pos2>) soself.transform * world_mmapplies scaling + translation, andself.transform.inverse() * screen_ptreverses it.pan_by&zoom_around:pan_byadds the pointer drag vector directly toself.transform.translation.zoom_aroundcapturesworld_anchorunder the cursor, clampsnew_scaleviaf32::clamp(MIN_ZOOM, MAX_ZOOM), and solves for the newtranslationusing.to_vec2()(which converts aPos2point into aVec2displacement from the origin so vector subtraction is type-safe).
Block 2: Storing viewport on PcbReviewerApp & Rendering Physical Millimetres (paint_board)
pub struct PcbReviewerApp {
pub show_top_copper: bool,
pub show_bottom_copper: bool,
pub show_silkscreen: bool,
pub show_ratsnest: bool,
pub viewport: BoardViewport,
}
impl PcbReviewerApp {
// ... reset_layers, isolate_top_copper, show_all_layers ...
pub fn reset_viewport(&mut self) {
self.viewport = BoardViewport::default();
}
fn paint_board(&self, painter: &egui::Painter, canvas_rect: Rect) {
painter.rect_filled(canvas_rect, 0.0, Color32::from_rgb(12, 16, 14));
// Physical 100 mm x 70 mm FR4 PCB outline in world space
let board_min = self.viewport.world_to_screen(pos2(0.0, 0.0));
let board_max = self.viewport.world_to_screen(pos2(100.0, 70.0));
let board_rect = Rect::from_min_max(board_min, board_max);
let scale = self.viewport.transform.scaling;
painter.rect(
board_rect,
1.5 * scale,
Color32::from_rgb(16, 40, 30),
Stroke::new((0.4 * scale).max(1.0), Color32::from_rgb(47, 111, 82)),
StrokeKind::Inside,
);
// Bottom Copper (B.Cu - Blue, physical mm coordinates)
if self.show_bottom_copper {
let b_stroke = Stroke::new((0.8 * scale).max(1.5), Color32::from_rgb(59, 142, 234));
painter.line_segment(
[
self.viewport.world_to_screen(pos2(20.0, 48.0)),
self.viewport.world_to_screen(pos2(55.0, 48.0)),
],
b_stroke,
);
painter.line_segment(
[
self.viewport.world_to_screen(pos2(55.0, 48.0)),
self.viewport.world_to_screen(pos2(68.0, 35.0)),
],
b_stroke,
);
}
// Top Copper (F.Cu - Terracotta Red, physical mm coordinates)
if self.show_top_copper {
let f_color = Color32::from_rgb(224, 82, 67);
let f_stroke = Stroke::new((0.8 * scale).max(1.5), f_color);
let pad_a = self.viewport.world_to_screen(pos2(20.0, 22.0));
let pad_b = self.viewport.world_to_screen(pos2(20.0, 32.0));
let mid = self.viewport.world_to_screen(pos2(52.0, 22.0));
let via = self.viewport.world_to_screen(pos2(68.0, 35.0));
painter.line_segment([pad_a, mid], f_stroke);
painter.line_segment([mid, via], f_stroke);
painter.line_segment(
[pad_b, self.viewport.world_to_screen(pos2(45.0, 32.0))],
f_stroke,
);
let pad_size = vec2(4.0 * scale, 2.2 * scale);
painter.rect_filled(Rect::from_center_size(pad_a, pad_size), 0.4 * scale, f_color);
painter.rect_filled(Rect::from_center_size(pad_b, pad_size), 0.4 * scale, f_color);
painter.circle(
via,
1.6 * scale,
Color32::from_rgb(16, 40, 30),
Stroke::new((0.6 * scale).max(1.2), Color32::from_rgb(230, 193, 90)),
);
}
// Silkscreen (F.SilkS - Cream component courtyard in mm)
if self.show_silkscreen {
let u1_min = self.viewport.world_to_screen(pos2(14.0, 16.0));
let u1_max = self.viewport.world_to_screen(pos2(28.0, 54.0));
painter.rect_stroke(
Rect::from_min_max(u1_min, u1_max),
0.3 * scale,
Stroke::new(1.5_f32, Color32::from_rgb(240, 230, 140)),
StrokeKind::Outside,
);
}
// Unrouted Ratsnest airwire
if self.show_ratsnest {
painter.line_segment(
[
self.viewport.world_to_screen(pos2(20.0, 32.0)),
self.viewport.world_to_screen(pos2(55.0, 48.0)),
],
Stroke::new(1.5_f32, Color32::from_rgb(111, 247, 255)),
);
}
}
}
pub viewport: BoardViewport&pub fn reset_viewport(&mut self): Adds the persistent camera state toPcbReviewerAppand provides a one-click reset back toBoardViewport::default().- Projecting World Coordinates (
self.viewport.world_to_screen(pos2(...))): Every board feature is now specified in real millimetres—from the100.0 mm × 70.0 mmFR4 outline down to the0.8 mmcopper trace widths ((0.8 * scale).max(1.5)) and4.0 mm × 2.2 mmSMD pads (Rect::from_center_size(pad_a, pad_size)). - Minimum Hairline Clamping (
.max(1.5)): Scaling physical trace widths byscalemakes traces thicken as you zoom in, while.max(1.5)guarantees they never vanish below1.5 pxwhen zoomed all the way out.
Block 3: Wiring Drag-to-Pan & Scroll-to-Zoom in ui() (impl eframe::App)
Now, inside PcbReviewerApp::ui, we upgrade our canvas allocation from Sense::hover() to Sense::drag() and inspect both response.dragged() and ui.input(|i| i.smooth_scroll_delta.y):
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();
}
ui.separator();
ui.label(format!("Zoom: {:.1} px/mm", self.viewport.transform.scaling));
if ui.button("Reset Camera (Fit)").clicked() {
self.reset_viewport();
}
});
egui::CentralPanel::default().show(ui, |ui| {
let (response, painter) = ui.allocate_painter(ui.available_size(), Sense::drag());
if response.dragged() {
self.viewport.pan_by(response.drag_delta());
}
if let Some(hover_pos) = response.hover_pos() {
let scroll_y = ui.input(|i| i.smooth_scroll_delta.y);
if scroll_y != 0.0 {
let zoom_factor = (scroll_y * 0.005).exp();
self.viewport.zoom_around(hover_pos, zoom_factor);
}
}
self.paint_board(&painter, response.rect);
});
}
}
format!("Zoom: {:.1} px/mm", self.viewport.transform.scaling): Formats the current camera scale to one decimal place (.1) for the sidebar readout, paired with a"Reset Camera (Fit)"button callingself.reset_viewport().Sense::drag()&response.drag_delta(): Allocating the painter withSense::drag()instructseguito track mouse drag gestures inside the canvas rectangle. Wheneverresponse.dragged()istrue,response.drag_delta()returns theVec2pointer displacement since the previous frame.if let Some(hover_pos) = response.hover_pos():hover_pos()returnsOption<Pos2>—Some(pos)when the pointer is inside the canvas rectangle, orNonewhen the pointer is over the sidebar or outside the window.ui.input(|i| i.smooth_scroll_delta.y)&(scroll_y * 0.005).exp(): Reads the vertical trackpad/mouse-wheel scroll delta fromegui::InputStatevia a short read-only closure. Convertingscroll_ywith the exponential function.exp()produces a smooth, symmetric multiplicativezoom_factor(> 1.0when scrolling up,< 1.0when scrolling down) that we pass intoself.viewport.zoom_around(hover_pos, zoom_factor).
Block 4: Transform Invariant Unit Tests (#[cfg(test)] mod tests)
#[test]
fn world_and_screen_transforms_are_exact_inverses() {
let vp = BoardViewport::default();
let world_pad = pos2(25.0, 40.0);
let screen_pt = vp.world_to_screen(world_pad);
assert_eq!(screen_pt, pos2(230.0, 300.0));
let round_trip = vp.screen_to_world(screen_pt);
assert!((round_trip.x - world_pad.x).abs() < 1e-4);
assert!((round_trip.y - world_pad.y).abs() < 1e-4);
}
#[test]
fn pan_by_translates_screen_coordinates_and_reset_restores_default() {
let mut app = PcbReviewerApp::default();
let before = app.viewport.world_to_screen(pos2(10.0, 10.0));
app.viewport.pan_by(vec2(15.0, -25.0));
let after = app.viewport.world_to_screen(pos2(10.0, 10.0));
assert_eq!(after, before + vec2(15.0, -25.0));
app.reset_viewport();
assert_eq!(app.viewport, BoardViewport::default());
}
#[test]
fn zoom_around_keeps_cursor_world_point_stationary_and_clamps_scale() {
let mut vp = BoardViewport::default();
let cursor_screen = pos2(200.0, 180.0);
let world_under_cursor_before = vp.screen_to_world(cursor_screen);
vp.zoom_around(cursor_screen, 2.0);
assert_eq!(vp.transform.scaling, 12.0);
let world_under_cursor_after = vp.screen_to_world(cursor_screen);
assert!((world_under_cursor_after.x - world_under_cursor_before.x).abs() < 1e-4);
assert!((world_under_cursor_after.y - world_under_cursor_before.y).abs() < 1e-4);
// Verify clamping at both extremes
vp.zoom_around(cursor_screen, 100.0);
assert_eq!(vp.transform.scaling, MAX_ZOOM);
vp.zoom_around(cursor_screen, 0.001);
assert_eq!(vp.transform.scaling, MIN_ZOOM);
}
- Round-Trip Inverse Check: Verifies both the exact forward projection (
pos2(25.0, 40.0)→pos2(230.0, 300.0)at6.0 px/mm+(80, 60)offset) and thatscreen_to_worldrecovers the original millimetre coordinate within floating-point tolerance (1e-4). - Pan & Reset Verification: Confirms
pan_by(vec2(15.0, -25.0))shifts projected screen coordinates by the exact drag delta and thatreset_viewport()restoresBoardViewport::default(). - Stationary Anchor & Clamp Verification: Asserts that
zoom_around(cursor_screen, 2.0)doublesscalingto12.0while keepingvp.screen_to_world(cursor_screen)stationary, and tests bothMAX_ZOOMandMIN_ZOOMclamp boundaries.
Complete src/main.rs Listing
Here is the complete, runnable apps/0004-world-to-screen-transforms-pan-and-zoom/src/main.rs with all blocks assembled in context:
use eframe::egui::{
self, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2, emath::TSTransform, pos2, vec2,
};
pub const MIN_ZOOM: f32 = 1.5;
pub const MAX_ZOOM: f32 = 60.0;
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 0004",
options,
Box::new(|_cc| Ok(Box::new(PcbReviewerApp::default()))),
)
}
/// Maps physical PCB coordinates in millimeters (`world_mm`) to `egui` screen points
/// using a translation + uniform-scaling transform (`TSTransform`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BoardViewport {
pub transform: TSTransform,
}
impl Default for BoardViewport {
fn default() -> Self {
Self {
transform: TSTransform::new(vec2(80.0, 60.0), 6.0),
}
}
}
impl BoardViewport {
pub fn world_to_screen(&self, world_mm: Pos2) -> Pos2 {
self.transform * world_mm
}
pub fn screen_to_world(&self, screen_pt: Pos2) -> Pos2 {
self.transform.inverse() * screen_pt
}
pub fn pan_by(&mut self, screen_delta: Vec2) {
self.transform.translation += screen_delta;
}
/// Zooms the viewport around `screen_anchor` (such as the mouse cursor position)
/// so the physical board point under the cursor stays stationary on screen.
pub fn zoom_around(&mut self, screen_anchor: Pos2, zoom_factor: f32) {
let world_anchor = self.screen_to_world(screen_anchor);
let new_scale = (self.transform.scaling * zoom_factor).clamp(MIN_ZOOM, MAX_ZOOM);
self.transform.scaling = new_scale;
self.transform.translation =
screen_anchor.to_vec2() - world_anchor.to_vec2() * new_scale;
}
}
pub struct PcbReviewerApp {
pub show_top_copper: bool,
pub show_bottom_copper: bool,
pub show_silkscreen: bool,
pub show_ratsnest: bool,
pub viewport: BoardViewport,
}
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,
viewport: BoardViewport::default(),
};
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;
}
pub fn reset_viewport(&mut self) {
self.viewport = BoardViewport::default();
}
fn paint_board(&self, painter: &egui::Painter, canvas_rect: Rect) {
painter.rect_filled(canvas_rect, 0.0, Color32::from_rgb(12, 16, 14));
// Physical 100 mm x 70 mm FR4 PCB outline in world space
let board_min = self.viewport.world_to_screen(pos2(0.0, 0.0));
let board_max = self.viewport.world_to_screen(pos2(100.0, 70.0));
let board_rect = Rect::from_min_max(board_min, board_max);
let scale = self.viewport.transform.scaling;
painter.rect(
board_rect,
1.5 * scale,
Color32::from_rgb(16, 40, 30),
Stroke::new((0.4 * scale).max(1.0), Color32::from_rgb(47, 111, 82)),
StrokeKind::Inside,
);
// Bottom Copper (B.Cu - Blue, physical mm coordinates)
if self.show_bottom_copper {
let b_stroke = Stroke::new((0.8 * scale).max(1.5), Color32::from_rgb(59, 142, 234));
painter.line_segment(
[
self.viewport.world_to_screen(pos2(20.0, 48.0)),
self.viewport.world_to_screen(pos2(55.0, 48.0)),
],
b_stroke,
);
painter.line_segment(
[
self.viewport.world_to_screen(pos2(55.0, 48.0)),
self.viewport.world_to_screen(pos2(68.0, 35.0)),
],
b_stroke,
);
}
// Top Copper (F.Cu - Terracotta Red, physical mm coordinates)
if self.show_top_copper {
let f_color = Color32::from_rgb(224, 82, 67);
let f_stroke = Stroke::new((0.8 * scale).max(1.5), f_color);
let pad_a = self.viewport.world_to_screen(pos2(20.0, 22.0));
let pad_b = self.viewport.world_to_screen(pos2(20.0, 32.0));
let mid = self.viewport.world_to_screen(pos2(52.0, 22.0));
let via = self.viewport.world_to_screen(pos2(68.0, 35.0));
painter.line_segment([pad_a, mid], f_stroke);
painter.line_segment([mid, via], f_stroke);
painter.line_segment(
[pad_b, self.viewport.world_to_screen(pos2(45.0, 32.0))],
f_stroke,
);
let pad_size = vec2(4.0 * scale, 2.2 * scale);
painter.rect_filled(Rect::from_center_size(pad_a, pad_size), 0.4 * scale, f_color);
painter.rect_filled(Rect::from_center_size(pad_b, pad_size), 0.4 * scale, f_color);
painter.circle(
via,
1.6 * scale,
Color32::from_rgb(16, 40, 30),
Stroke::new((0.6 * scale).max(1.2), Color32::from_rgb(230, 193, 90)),
);
}
// Silkscreen (F.SilkS - Cream component courtyard in mm)
if self.show_silkscreen {
let u1_min = self.viewport.world_to_screen(pos2(14.0, 16.0));
let u1_max = self.viewport.world_to_screen(pos2(28.0, 54.0));
painter.rect_stroke(
Rect::from_min_max(u1_min, u1_max),
0.3 * scale,
Stroke::new(1.5_f32, Color32::from_rgb(240, 230, 140)),
StrokeKind::Outside,
);
}
// Unrouted Ratsnest airwire
if self.show_ratsnest {
painter.line_segment(
[
self.viewport.world_to_screen(pos2(20.0, 32.0)),
self.viewport.world_to_screen(pos2(55.0, 48.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();
}
ui.separator();
ui.label(format!("Zoom: {:.1} px/mm", self.viewport.transform.scaling));
if ui.button("Reset Camera (Fit)").clicked() {
self.reset_viewport();
}
});
egui::CentralPanel::default().show(ui, |ui| {
let (response, painter) = ui.allocate_painter(ui.available_size(), Sense::drag());
if response.dragged() {
self.viewport.pan_by(response.drag_delta());
}
if let Some(hover_pos) = response.hover_pos() {
let scroll_y = ui.input(|i| i.smooth_scroll_delta.y);
if scroll_y != 0.0 {
let zoom_factor = (scroll_y * 0.005).exp();
self.viewport.zoom_around(hover_pos, zoom_factor);
}
}
self.paint_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,
viewport: BoardViewport::default(),
};
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,
viewport: BoardViewport::default(),
};
app.show_all_layers();
assert!(app.show_top_copper);
assert!(app.show_bottom_copper);
assert!(app.show_silkscreen);
assert!(app.show_ratsnest);
}
#[test]
fn world_and_screen_transforms_are_exact_inverses() {
let vp = BoardViewport::default();
let world_pad = pos2(25.0, 40.0);
let screen_pt = vp.world_to_screen(world_pad);
assert_eq!(screen_pt, pos2(230.0, 300.0));
let round_trip = vp.screen_to_world(screen_pt);
assert!((round_trip.x - world_pad.x).abs() < 1e-4);
assert!((round_trip.y - world_pad.y).abs() < 1e-4);
}
#[test]
fn pan_by_translates_screen_coordinates_and_reset_restores_default() {
let mut app = PcbReviewerApp::default();
let before = app.viewport.world_to_screen(pos2(10.0, 10.0));
app.viewport.pan_by(vec2(15.0, -25.0));
let after = app.viewport.world_to_screen(pos2(10.0, 10.0));
assert_eq!(after, before + vec2(15.0, -25.0));
app.reset_viewport();
assert_eq!(app.viewport, BoardViewport::default());
}
#[test]
fn zoom_around_keeps_cursor_world_point_stationary_and_clamps_scale() {
let mut vp = BoardViewport::default();
let cursor_screen = pos2(200.0, 180.0);
let world_under_cursor_before = vp.screen_to_world(cursor_screen);
vp.zoom_around(cursor_screen, 2.0);
assert_eq!(vp.transform.scaling, 12.0);
let world_under_cursor_after = vp.screen_to_world(cursor_screen);
assert!((world_under_cursor_after.x - world_under_cursor_before.x).abs() < 1e-4);
assert!((world_under_cursor_after.y - world_under_cursor_before.y).abs() < 1e-4);
// Verify clamping at both extremes
vp.zoom_around(cursor_screen, 100.0);
assert_eq!(vp.transform.scaling, MAX_ZOOM);
vp.zoom_around(cursor_screen, 0.001);
assert_eq!(vp.transform.scaling, MIN_ZOOM);
}
}
Click and drag inside the PCB canvas below to pan (pan_by), move your cursor over a pad, and click Zoom In / Out at Cursor (zoom_around). Watch how screen_to_world under the crosshair stays stationary when zooming!
1. Camera State (self.viewport)
2. Live Coordinate Math
BoardViewport Transform Methods
Skill Practice • Tight Feedback Loop
Complete the three blanks below so that (1) screen_to_world uses the inverse of self.transform, (2) zoom_around clamps the new scale between MIN_ZOOM and MAX_ZOOM, and (3) dragging the canvas pans by response.drag_delta():
impl BoardViewport {
pub fn screen_to_world(&self, screen_pt: Pos2) -> Pos2 {
self.transform. * screen_pt
}
pub fn zoom_around(&mut self, screen_anchor: Pos2, zoom_factor: f32) {
let world_anchor = self.screen_to_world(screen_anchor);
let new_scale = (self.transform.scaling * zoom_factor).;
self.transform.scaling = new_scale;
self.transform.translation =
screen_anchor.to_vec2() - world_anchor.to_vec2() * new_scale;
}
}
// Inside PcbReviewerApp::ui:
if response.dragged() {
self.viewport.pan_by(response.);
}
In apps/0004-world-to-screen-transforms-pan-and-zoom, we test three geometric invariants: (1) world_to_screen and screen_to_world are exact inverses, (2) pan_by shifts screen coordinates by the exact drag vector, and (3) zoom_around(cursor_screen, 2.0) leaves vp.screen_to_world(cursor_screen) unchanged while clamping at MIN_ZOOM and MAX_ZOOM.
#[test]
fn zoom_around_keeps_cursor_world_point_stationary_and_clamps_scale() {
let mut vp = BoardViewport::default();
let cursor_screen = pos2(200.0, 180.0);
let world_under_cursor_before = vp.screen_to_world(cursor_screen);
vp.zoom_around(cursor_screen, 2.0);
assert_eq!(vp.transform.scaling, 12.0);
let world_under_cursor_after = vp.screen_to_world(cursor_screen);
assert!((world_under_cursor_after.x - world_under_cursor_before.x).abs() < 1e-4);
assert!((world_under_cursor_after.y - world_under_cursor_before.y).abs() < 1e-4);
vp.zoom_around(cursor_screen, 100.0);
assert_eq!(vp.transform.scaling, MAX_ZOOM);
vp.zoom_around(cursor_screen, 0.001);
assert_eq!(vp.transform.scaling, MIN_ZOOM);
}
Recommended Primary Source
egui::emath::TSTransform API Documentation.
Inspect how TSTransform::inverse() and Mul<Pos2> are implemented in emath.
Vec<TraceSegment> and enum PcbLayer sorted in deterministic back-to-front Z-order in Lesson 0005: Modeling Layers, Traces, and Z-Order Rendering?