Editor Gizmo
The manipulator system that renders and drives the three-axis handles you see when an entity is selected in Edit mode. It reuses the low-level gizmo draw list for rendering and the screen-ray helper for mouse-to-world projection, so it stays a pure "input + draw" layer with no GPU plumbing of its own.
Installed automatically by safi_app_init when enable_debug_ui is set.
Tools
Axis colours follow Unity/Unreal conventions: X = red, Y = green, Z = blue. The hovered axis (or the axis being dragged) paints yellow.
System behaviour
The gizmo runs a single system on EcsOnUpdate. On each tick it:
- Gates. Bails out unless
mode == EDIT,selected_tool != SELECT, andselected_entityis alive. Rotate/scale fall back to the translate geometry as a placeholder if their own path is disabled at build time — in shipping code all three tools draw their own geometry. - Sizes. Computes a world-space handle length that keeps the gizmo a roughly constant ~80 px on screen regardless of zoom:
len ≈ 2 · tan(fov/2) · dist_to_cam · target_px / viewport_h. - Hit-tests. Unprojects each axis segment (translate/scale) or samples each ring (rotate) to screen space and picks the nearest segment to the cursor within 12 px. Only runs when the cursor is over the viewport (not a panel).
- Drags. On LMB-down over a hovered handle, snapshots the entity's transform and the pre-inverted parent-world matrix (so child entities translate correctly in their parent's local frame). Each frame while held, applies the tool's drag math. LMB-up ends the drag.
- Draws. Enqueues the current tool's geometry through
safi_gizmo_draw_*.
Drag details
Translate. The handle follows the entity as it moves — the drag state stores a current_world position that drag_apply_translate updates each frame, which is read by the draw step. Reading SafiGlobalTransform directly would give the previous frame's value because transform propagation runs later on EcsPostUpdate.
Rotate. The anchor vector is the cursor's projection onto the ring plane at drag-start. The frame-by-frame delta angle is atan2((anchor × current) · axis, anchor · current) — signed, continuous across ±π. The handle itself does not rotate during drag (matches the pivot-stays-put convention in DCC tools); only the selected entity rotates.
Scale. The drag delta is divided by the handle length, giving a factor 1 + delta/len that feels like "drag the cube to 2× its resting distance → 2× scale" regardless of camera distance. Applied multiplicatively to the per-axis start scale, clamped to 0.01 so a careless drag doesn't zero out the transform.
Parent-aware translate
For child entities, drag_begin pre-computes the inverse of the parent's SafiGlobalTransform. drag_apply_translate multiplies the new world-space target through that matrix to produce a parent-local position, which is written back to SafiTransform.position. Unparented entities get the identity matrix, so the multiplication reduces to a copy.
Input gating
- Gizmo hit-test is skipped when
safi_debug_ui_mouse_over_viewport()is false — clicks landing on the Scene or Inspector panel never start a drag. - A live drag stays live even if the cursor sweeps over a panel (the LMB release is the only drag-end signal).
safi_editor_get_tool() == SELECTor leaving Edit mode cancels any live drag.
API
Idempotent. Called automatically in safi_app_init when the debug UI is on.
See also
- Editor Toolbar — where the user picks a tool
- Editor State —
selected_tool+selected_entitydrive the system - Camera Math —
safi_camera_screen_ray+safi_camera_world_to_screen - Gizmo Draw List — the line/wireframe primitive layer
- Editor plan — M4 — milestone this closes out