44. Selection and Translation
%% In %% our first pass of the editor lsat lesson, we built a simple fly-camera that let us click a point in the 3D world and use console commands to spawn things. After using it even briefly, you've probably come to realise what I did: it doesn't feel great. Placing objects by clicking a point and typing door TownDoor is imprecise and tedious.
Today we're taking a step back to build a bespoke tool tailored for our needs. We are going to implement a proper 3D Translation Gizmo—the classic 3-axis arrows you see in engines like Unity or Blender—so we can select objects, click an axis, and drag them around the scene with grid snapping.
In this lesson we'll:
- Update our input state and change our exit hotkey (so we stop accidentally closing the game).
- Expand the
Editor_Stateto track selected objects and translation axes. - Build and render a 3D translation gizmo.
- Implement raycasting to select doors, triggers, and spawners.
- Add click-and-drag logic with discrete snapping increments.
Quality of Life and Input
First, let's get a small annoyance out of the way. Raylib defaults to closing the window when you press Escape. When you're tabbing through menus or cancelling an editor action, hitting Escape is muscle memory. Closing the game accidentally is infuriating.
In main.odin, tell Raylib to clear the exit key, and implement a custom combination (Ctrl+Q) to break the main loop:
defer rl.CloseAudioDevice()
rl.SetMasterVolume(0.5)
rl.SetExitKey(.KEY_NULL)
// ... inside the main loop:
if rl.IsKeyDown(.LEFT_CONTROL) && rl.IsKeyPressed(.Q) {
break
}
Next, our editor is going to need to know when we let go of the mouse button so it can stop dragging the gizmo. In input.odin and game.odin, add a releasing boolean:
// game.odin
Input_State :: struct {
// ...
clicking: bool,
releasing: bool,
holding: bool,
// ...
}
// input.odin
if rl.IsMouseButtonDown(.LEFT) do is.holding = true
if rl.IsMouseButtonPressed(.LEFT) do is.clicking = true
is.releasing = rl.IsMouseButtonReleased(.LEFT)
Expanding the Editor State
Our old Editor_State just held a selected_point. We need to track a lot more now: what type of object we have selected, exactly where it is, which axis we are currently dragging, and an accumulator to handle our discrete grid snapping.
In editor.odin, redefine the state and add the enums:
Editor_State :: struct {
is_active: bool,
free_mouse: bool,
selection_mode: Editor_Selection_Mode,
selection_pos: Vec3,
translate_acc: f32,
translate_dir: Editor_Dir,
translate_index: int,
}
Editor_Selection_Mode :: enum {
NONE,
POINT,
DOOR,
TRIGGER,
SPAWNER,
}
// Make sure NONE is last so that int(Y) == 1 and Editor_Dir(1) == Y
Editor_Dir :: enum {
X, Y, Z,
NONE,
}
We also need to define the physical dimensions of our gizmo and our snapping rules. Add these constants to the top of editor.odin:
SNAP_INCREMENT :: 0.25
SNAP_INCREMENT_MULT :: 1.0 / SNAP_INCREMENT
SNAP_THRESHOLD_2D :: 5
TG_SIDES :: 12
TG_SHAFT_RADIUS :: 0.05
TG_ARROW_RADIUS :: 0.15
TG_SHAFT_LENGTH :: 1.00
TG_ARROW_LENGTH :: 0.30
Drawing the Translation Gizmo
A translation gizmo is just visual feedback. It's composed of three cylinders pointing along the X, Y, and Z axes, usually coloured Red, Green, and Blue.
To make the gizmo interactable, we generate Axis-Aligned Bounding Boxes (AABBs) for each arrow. This allows us to cast a ray from the mouse cursor and determine if the user clicked the X, Y, or Z axis.
Add these procedures to the bottom of editor.odin:
translation_gizmo_positions :: proc(pos: Vec3) -> (ends: [3]Vec3, tips: [3]Vec3) {
end_x := pos + {TG_SHAFT_LENGTH, 0, 0}
tip_x := end_x + {TG_ARROW_LENGTH, 0, 0}
end_y := pos + {0, TG_SHAFT_LENGTH, 0}
tip_y := end_y + {0, TG_ARROW_LENGTH, 0}
end_z := pos + {0, 0, TG_SHAFT_LENGTH}
tip_z := end_z + {0, 0, TG_ARROW_LENGTH}
return {end_x, end_y, end_z}, {tip_x, tip_y, tip_z}
}
translation_gizmo_aabbs :: proc(pos: Vec3) -> (boxes: [3]raylib.BoundingBox) {
total_len: f32 = TG_SHAFT_LENGTH + TG_ARROW_LENGTH
thickness: f32 = TG_ARROW_RADIUS * 2
x_center := pos + {total_len / 2, 0, 0}
x_box := aabb_from_pos_size(x_center, {total_len, thickness, thickness})
y_center := pos + {0, total_len / 2.0, 0}
y_box := aabb_from_pos_size(y_center, {thickness, total_len, thickness})
z_center := pos + {0, 0, total_len / 2.0}
z_box := aabb_from_pos_size(z_center, {thickness, thickness, total_len})
return {x_box, y_box, z_box}
}
Now for drawing. We use rl.DrawCylinderEx to draw both the shaft and the arrowhead (by setting the top radius to 0). We also highlight the axis in white if it's currently being hovered or dragged.
translation_gizmo_draw :: proc(pos: Vec3, hovered_side: Editor_Dir = .NONE) {
ends, tips := translation_gizmo_positions(pos)
x_color := rl.RED
y_color := rl.GREEN
z_color := rl.BLUE
if hovered_side == .X do x_color = rl.WHITE
if hovered_side == .Y do y_color = rl.WHITE
if hovered_side == .Z do z_color = rl.WHITE
rl.DrawCylinderEx(pos, ends.x, TG_SHAFT_RADIUS, TG_SHAFT_RADIUS, TG_SIDES, x_color)
rl.DrawCylinderEx(ends.x, tips.x, TG_ARROW_RADIUS, 0, TG_SIDES, x_color)
rl.DrawCylinderEx(pos, ends.y, TG_SHAFT_RADIUS, TG_SHAFT_RADIUS, TG_SIDES, y_color)
rl.DrawCylinderEx(ends.y, tips.y, TG_ARROW_RADIUS, 0, TG_SIDES, y_color)
rl.DrawCylinderEx(pos, ends.z, TG_SHAFT_RADIUS, TG_SHAFT_RADIUS, TG_SIDES, z_color)
rl.DrawCylinderEx(ends.z, tips.z, TG_ARROW_RADIUS, 0, TG_SIDES, z_color)
rl.DrawSphere(pos, TG_SHAFT_RADIUS * 1.5, rl.WHITE)
}
Raycasting and Selection Logic
When the user clicks the screen, we cast a ray into the 3D world. But the order of operations is critical. If a gizmo overlaps a door, we want to grab the gizmo, not select the door again.
The priority is:
- Detect clicking a gizmo axis.
- Detect clicking an object (door, spawner, trigger).
- Detect clicking the raw level geometry (fallback point selection).
Update the signature of editor_update to take cursor: Vec2 and completely replace the is.clicking block:
ray := rl.GetScreenToWorldRay(cursor, camera^)
if is.clicking {
is_clicking_gizmo := false
is_clicking_object := false
boxes := translation_gizmo_aabbs(es.selection_pos)
es.translate_dir = .NONE
closest_dist := max(f32)
// 1. Check Gizmo
for i in 0..<3 {
collision := rl.GetRayCollisionBox(ray, boxes[i])
if collision.hit && collision.distance < closest_dist {
es.translate_dir = Editor_Dir(i)
closest_dist = collision.distance
is_clicking_gizmo = true
}
}
if is_clicking_gizmo {
// do nothing atm
} else {
// 2. Check Objects
closest_dist := max(f32)
closest_index := -1
es.selection_mode = .NONE
// Check Spawners
for spawner, i in gs.scene.entity_spawners {
box := aabb_from_pos_size(spawner.pos, 1)
collision := rl.GetRayCollisionBox(ray, box)
if collision.hit && collision.distance < closest_dist {
closest_index = i
closest_dist = collision.distance
is_clicking_object = true
es.selection_mode = .SPAWNER
es.selection_pos = spawner.pos
}
}
// NOTE: Repeat the same loop for doors and triggers
if closest_index >= 0 {
es.translate_index = closest_index
}
}
if is_clicking_object {
// do nothing as we modifed state in-place
// if hit no object nor gizmo...
} else if !is_clicking_gizmo {
model := gs.assets.models[gs.scene.scene_name]
mesh_count := model.meshCount
for mesh_index in 0..<mesh_count {
mesh := model.meshes[mesh_index]
collision := rl.GetRayCollisionMesh(ray, mesh, raylib.Matrix(1))
if collision.hit {
es.selection_mode = .POINT
es.selection_pos = collision.point
}
}
}
}
Dragging and Snapping
We don't want objects sliding smoothly along floating point values like 1.23419. We want them snapping cleanly to a grid (e.g., 0.25 increments) so that tiles and objects align perfectly.
For now we don't actually snap to the grid, so if an object starts misaligned, it'll remain misaligned. We'll fix this soon.
To do this, we accumulate the 2D mouse movement. Once the mouse moves past our SNAP_THRESHOLD_2D, we apply the SNAP_INCREMENT to the target axis, reset the accumulator, and write the new position back to the actual object array.
Directly below the is.clicking block, add the dragging code:
if is.holding {
if es.translate_dir != .NONE {
// Rough conversion of 2D screen mouse movement into 1D drag magnitude
dist := (is.move.x - is.move.y) / 2
es.translate_acc += dist
// translations are strictly orthogonal
side_index := int(es.translate_dir)
v := es.selection_pos[side_index]
if abs(es.translate_acc) > SNAP_THRESHOLD_2D {
v += clamp(es.translate_acc, -SNAP_INCREMENT, SNAP_INCREMENT)
es.selection_pos[side_index] = v
es.translate_acc = 0
// Move the actual object data in the scene
switch es.selection_mode {
case .NONE, .POINT:
case .DOOR:
gs.scene.doors[es.translate_index].pos = es.selection_pos
case .SPAWNER:
gs.scene.entity_spawners[es.translate_index].pos = es.selection_pos
case .TRIGGER:
gs.scene.triggers[es.translate_index].pos = es.selection_pos
}
}
}
}
if is.releasing {
es.translate_acc = 0
es.translate_dir = .NONE
}
Finally, hook up the rendering in editor_render. You'll want to draw bounding boxes around all the doors, spawners, and triggers so you can see what you are clicking, and then call translation_gizmo_draw(es.selection_pos) if an object is selected. Check the diff for the exact rendering loops.
The Big Idea
Tools are force multipliers. The time you spend building a functional editor inside your engine pays for itself tenfold the moment you start building real levels.
By projecting a 2D screen ray into our 3D space, interacting with AABBs, and manipulating discrete, snapped values, we have bypassed the nightmare of manually typing coordinates into JSON files. The editor state now correctly holds references to our in-memory scene data and mutates it live. In the future, we could add rotation, scaling, or turn off the snap increment with a hotkey, but for now, we have a robust foundation for authoring content visually.