Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
171d47b
Heart node
Keavon May 7, 2026
1cb9cb4
Add Heart drawing mode to the Shape tool with gizmo registration
Ayush2k02 May 25, 2026
127d095
Migrate the Heart node to the ranked node input API
Ayush2k02 Jul 24, 2026
c77531f
Add registry-driven generic gizmo system
Ayush2k02 Jul 6, 2026
2f1a5bf
Migrate the Circle gizmo to the generic registry system
Ayush2k02 Jul 6, 2026
82c24b5
Migrate the Polygon gizmo to the generic registry system
Ayush2k02 Jul 6, 2026
0d04001
Add the Heart's radius gizmo via the generic registry system
Ayush2k02 Jul 6, 2026
46c540a
Port the gizmo registry stack to current master's geometry and parame…
Ayush2k02 Aug 21, 2026
cb64b56
Cover the ported heart geometry with tests
Ayush2k02 Aug 21, 2026
8ba6db9
Give the generic gizmos an escape hatch for shape-specific behavior
Ayush2k02 Aug 22, 2026
7932c39
Restore the polygon dial's spokes and outline, lost in the generic mi…
Ayush2k02 Aug 22, 2026
8bbc2a5
Migrate the Star gizmos to the generic registry system
Ayush2k02 Aug 22, 2026
a34022d
Migrate the Spiral gizmo to the generic registry system
Ayush2k02 Aug 22, 2026
77857bd
Migrate the Arc gizmos to the generic registry system
Ayush2k02 Aug 22, 2026
ed4811d
Migrate the Grid gizmos to the generic registry system
Ayush2k02 Aug 22, 2026
e5ba9ae
Name the gizmo hook signatures
Ayush2k02 Aug 22, 2026
b295cbf
Restore the circumference grab for circle and arc radii, and stop the…
Ayush2k02 Aug 22, 2026
74dc71c
Match the sides dial to the one it replaced
Ayush2k02 Aug 22, 2026
6099aa2
Show a resting handle for gizmos that draw nothing of their own
Ayush2k02 Aug 24, 2026
7b41ed1
Give the polygon back its radius handles
Ayush2k02 Aug 24, 2026
c0e3302
Put handles on the heart's cleavage and shoulders
Ayush2k02 Aug 24, 2026
b8dd110
Measure a gizmo drag from where it started, not from where the cursor is
Ayush2k02 Aug 24, 2026
b427443
Read a circular radius drag horizontally, from wherever it was grabbed
Ayush2k02 Aug 24, 2026
d30d5b5
Write the guide for adding a gizmo to a node
Ayush2k02 Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions editor/src/messages/tool/common_functionality/gizmos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Adding a gizmo to a node

A gizmo is a draggable handle drawn on the canvas that edits one node input. This directory holds the
machinery for them. Adding one to a node usually means writing a table entry, not a file.

## How it fits together

```
gizmo_registry.rs which parameters get gizmos, declared as data
generic_gizmos/ the mechanics: hit-testing, hover/drag, overlays, writing the input
gizmo_behaviors.rs the shape-specific half, and the only place node geometry belongs
gizmo_manager.rs picks the right handler for the selected layer
```

The generic layer always owns the hover/drag state machine, arbitration between overlapping gizmos,
cursor feedback, and the write to the graph. You supply what is genuinely particular to your node, and
often that is nothing at all.

## The whole job, when the parameter is a length

The Heart's radius is the smallest complete example. It is one entry and no code:

```rust
const HEART_GIZMOS: &[GizmoInfo] = &[GizmoInfo {
parameter_index: heart::RadiusInput::INDEX,
gizmo_type: GizmoType::Slider,
name: "Radius",
min: Some(0.),
max: None,
behavior: GizmoBehavior::NONE,
position_hint: PositionHint::ParameterDerived,
}];
```

Then register the node so the manager can find it:

```rust
fn registered_gizmo_nodes() -> Vec<(ProtoNodeIdentifier, &'static [GizmoInfo])> {
vec![
// ...
(generator_nodes::heart::IDENTIFIER, HEART_GIZMOS),
]
}
```

That gives you a handle sitting `radius` out along the local +X axis, discoverable at rest, draggable,
clamped, undoable. If your parameter is a length measured from the layer's origin, stop here.

### Which `gizmo_type`

- `Slider` — an `f64`, dragged along a ray. The default and the one most parameters want.
- `Dial` — a `u32` count, stepped by horizontal drag. Sides, points, rows.
- `Angle` — an angle in degrees. Runs on the slider's machinery, so it expects a custom `drag`.
- `Position` — **not implemented.** Declaring it silently produces no gizmo.

A declaration that supplies its own `drag` is hosted by the slider whatever it declares, because the
dial's step-drag is exactly what such a node is replacing.

## When the default is not enough

Everything below is optional and defaulted. Reach for a hook only when the default is wrong, and put
the function in `gizmo_behaviors.rs` rather than in the generic layer.

| Hook | Use it when |
|---|---|
| `handle_positions` | the handle does not belong on the +X axis — a star's radius is grabbable at every vertex |
| `hover_distances` | what you grab is not a point — a grid's rows are grabbed anywhere along an edge |
| `drag` | reading a distance along a ray is the wrong question — a spiral winds, an arc sweeps |
| `snap_targets` | the drag should settle onto values derived from the node's other inputs |
| `overlay` | the shape draws something of its own: an outline, a guide, ticks |
| `draws_own_handle` | your overlay already draws the thing being grabbed, so the generic handle would double it |
| `angle_deadzone` | a rotational drag needs a jitter guard near the origin |

A worked example, from `POLYGON_RADIUS`. A regular polygon's radius reaches every corner, so every
corner is a grab point:

```rust
fn polygon_radius_handles(context: &GizmoContext, value: f64) -> Vec<DVec2> {
let Some((sides, _)) = extract_polygon_parameters(Some(context.layer), context.document) else {
return Vec::new();
};

(0..sides)
.map(|vertex| {
let angle = ((vertex as f64) * TAU) / (sides as f64);
DVec2::new(value * angle.sin(), -value * angle.cos())
})
.collect()
}
```

The drag then runs along the ray through whichever corner was taken hold of, and `context.handle_index`
tells your overlay which one that is.

### Writing a `drag`

Return every input the motion implies, not just the one you declared. A spiral's turns cannot change
without its outer radius following, or the spiral tightens as it grows:

```rust
fn spiral_turns_drag(context: &GizmoContext, drag: &mut DragInput) -> DragWrites {
// ... read the starting values out of `drag.initial_parameters`
DragWrites::inputs(vec![
(TurnsInput.into(), TaggedValue::F64(new_turns)),
(OuterRadiusInput.into(), TaggedValue::F64(new_outer_radius)),
])
}
```

Three things worth knowing about `DragInput`:

- **It is mutable.** A gesture that reaches a limit and re-anchors rather than stopping — an arc dragged
past a full sweep hands over to its other endpoint — rewrites the baseline the rest of the drag is
measured against.
- **`initial_parameters` is the node as it was when the drag began.** Read from it, not from the
document: by the second frame the live values are the ones you already wrote.
- **`DragWrites` can carry a transform.** A control that repositions the shape as it resizes it needs
one; a grid grown from its top edge has to move up as it gains a row, or the edge slides out from
under the cursor.

## The invariant

A gizmo never mutates geometry. It writes a node input and re-runs the graph, then re-reads its own
position from the value it just wrote. Every edit path — gizmo, Properties panel, API — converges on the
same write, which is why a value changed in the panel moves the canvas handle for free. The grid's
transform is the one exception, and it moves the layer rather than the geometry.

## Things that will catch you

- **`INDEX` counts from the node's primary input**, so the first real parameter is `1`. Use the generated
symbol (`heart::RadiusInput::INDEX`) rather than a literal, and a node gaining an input will not
silently repoint your gizmo at the wrong one.
- **Respect the node's `#[hard(..)]` range.** Writing outside it does not clamp — it produces geometry the
renderer cannot draw. A heart with a cleavage deeper than its shoulders are high crosses its own lobes
and vanishes entirely.
- **A normalized parameter needs a `drag`.** The default writes a distance in document units straight
through, which is meaningless for a fraction-of-the-radius parameter.
- **The transform cage sits on top of the obvious grab points.** Its corner and edge handles land where a
circle's radius or an arc's endpoint invites the cursor, and it wins the press. Test away from them.
- **The bounding-box `PositionHint` variants are inert.** Every migrated shape derives its handle from a
parameter, so `BoundingBoxCenter` and friends currently fall through to the +X axis.
- **Nothing is drawn at rest unless something asks for it.** A slider with no overlay marks its grab
points; one that supplies an overlay is expected to draw its own resting state.

## Testing

Registry declarations are cheap to assert directly — see the tests at the bottom of `gizmo_registry.rs`,
which check that each node exposes what it should and that behaviors carrying handles or drags actually
have them. Pure helpers are worth extracting and testing on their own; `nearest_snap_target` in
`generic_slider_gizmo.rs` is the pattern.

None of that catches a gizmo that is drawn in the wrong place or drags the wrong way. Run the editor and
grab the handle. Interaction code is exactly where tests pass and the control still feels wrong.
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
//! A generic dial that edits a discrete `u32` node parameter (e.g. a polygon's side count).
//!
//! Like [`GenericSliderGizmo`](super::generic_slider_gizmo::GenericSliderGizmo), this is fully
//! data-driven from the [gizmo registry]: it is anchored at the layer's origin and converts a
//! horizontal drag into integer steps (drag right to increase, left to decrease).
//!
//! [gizmo registry]: crate::messages::tool::common_functionality::gizmos::gizmo_registry

use crate::consts::{GIZMO_HIDE_THRESHOLD, NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH};
use crate::messages::frontend::utility_types::MouseCursorIcon;
use crate::messages::message::Message;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::InputConnector;
use crate::messages::prelude::{DocumentMessageHandler, FrontendMessage, InputPreprocessorMessageHandler, NodeGraphMessage, Responses};
use crate::messages::tool::common_functionality::gizmos::generic_gizmos::read_u32_input;
use crate::messages::tool::common_functionality::gizmos::gizmo_registry::{GizmoContext, GizmoInfo, GizmoState};
use crate::messages::tool::common_functionality::shape_editor::ShapeState;
use glam::DVec2;
use graph_craft::ProtoNodeIdentifier;
use graph_craft::document::NodeId;
use graph_craft::document::NodeInput;
use graph_craft::document::value::TaggedValue;
use graphene_std::ParameterRef;
use std::collections::VecDeque;

/// Horizontal drag distance (viewport px) that corresponds to one integer step.
const DIAL_PIXELS_PER_STEP: f64 = 25.;
/// Viewport radius of the drawn dial indicator.
const DIAL_INDICATOR_RADIUS: f64 = NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH;
/// Viewport radius of the clickable hit area. Deliberately larger than the drawn indicator so the
/// handle is easy to grab and the press doesn't fall through to the layer-move behavior.
const DIAL_HOVER_RADIUS: f64 = NUMBER_OF_POINTS_DIAL_SPOKE_LENGTH + 8.;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum GenericDialState {
#[default]
Inactive,
Hover,
Dragging,
}

/// A rotary dial bound to one `u32` parameter of one node.
#[derive(Clone, Debug)]
pub struct GenericDialGizmo {
layer: LayerNodeIdentifier,
node_id: NodeId,
identifier: ProtoNodeIdentifier,
info: GizmoInfo,
state: GenericDialState,
/// Parameter value captured when the drag began.
initial_value: u32,
}

impl GenericDialGizmo {
pub fn new(layer: LayerNodeIdentifier, node_id: NodeId, identifier: ProtoNodeIdentifier, info: GizmoInfo) -> Self {
Self {
layer,
node_id,
identifier,
info,
state: GenericDialState::Inactive,
initial_value: 0,
}
}

pub fn is_hovered(&self) -> bool {
self.state == GenericDialState::Hover
}

pub fn is_dragging(&self) -> bool {
self.state == GenericDialState::Dragging
}

pub fn cleanup(&mut self) {
self.state = GenericDialState::Inactive;
}

pub fn handle_click(&mut self) {
if self.state == GenericDialState::Hover {
self.state = GenericDialState::Dragging;
}
}

/// The registry entry's parameter, re-paired with the node it was declared for. `ParameterRef` is the
/// runtime form of a parameter symbol: the generic gizmos choose their parameter from the registry at
/// runtime, so they cannot name a symbol at the call site, but the identifier and index still travel together.
fn parameter(&self) -> ParameterRef {
ParameterRef {
node_identifier: self.identifier.clone(),
input_index: self.info.parameter_index,
}
}

fn context<'a>(&self, document: &'a DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&'a ShapeState>) -> GizmoContext<'a> {
GizmoContext {
layer: self.layer,
document,
parameter: self.parameter(),
state: match self.state {
GenericDialState::Inactive => GizmoState::Inactive,
GenericDialState::Hover => GizmoState::Hover,
GenericDialState::Dragging => GizmoState::Dragging,
},
mouse_position,
shape_editor,
handle_index: 0,
}
}

fn current_value(&self, document: &DocumentMessageHandler) -> Option<u32> {
read_u32_input(self.layer, document, &self.identifier, self.info.parameter_index)
}

/// Hover detection: the dial occupies a disc of `DIAL_INDICATOR_RADIUS` around the layer origin.
/// Pure hover test: returns the mouse's distance to the dial center when it is a hover
/// candidate, or `None` otherwise. Used by the manager to resolve overlap priority. Performs
/// no state mutation.
pub fn hover_distance(&self, mouse_position: DVec2, document: &DocumentMessageHandler) -> Option<f64> {
self.current_value(document)?;

let viewport = document.metadata().transform_to_viewport(self.layer);
let center = viewport.transform_point2(DVec2::ZERO);

// Hide the dial once the shape is too small on screen to sit around: the hit disc would cover the
// whole thing, and a press meant for the layer would be swallowed by the gizmo.
let bounds = document.metadata().bounding_box_viewport(self.layer)?;
if (bounds[1] - bounds[0]).max_element() / 2. < GIZMO_HIDE_THRESHOLD {
return None;
}

let distance = mouse_position.distance(center);
(distance <= DIAL_HOVER_RADIUS).then_some(distance)
}

/// Transition into the hovered state (no-op if already hovered or dragging), capturing the
/// reference value because `handle_click` has no document access.
pub fn enter_hover(&mut self, document: &DocumentMessageHandler, _mouse_position: DVec2, responses: &mut VecDeque<Message>) {
if self.state != GenericDialState::Inactive {
return;
}
let Some(value) = self.current_value(document) else { return };

self.state = GenericDialState::Hover;
self.initial_value = value;
responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::EWResize });
}

/// Transition out of the hovered state. Leaves an in-progress drag untouched.
pub fn exit_hover(&mut self, responses: &mut VecDeque<Message>) {
if self.state == GenericDialState::Hover {
self.state = GenericDialState::Inactive;
responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default });
}
}

/// Convert the drag into integer steps. The magnitude comes from the total drag distance (so the
/// dial responds to motion in any direction, not just horizontal), while the horizontal direction
/// decides the sign: drag right to increase, left to decrease. Clamped to the registry's bounds.
pub fn handle_update(&self, drag_start: DVec2, _document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque<Message>) {
let drag = input.mouse.position - drag_start;
let direction = (input.mouse.position.x - drag_start.x).signum();
let steps = ((drag.length() / DIAL_PIXELS_PER_STEP).round() * direction) as i64;

let min = self.info.min.map(|m| m as i64).unwrap_or(0);
let max = self.info.max.map(|m| m as i64).unwrap_or(i64::MAX);
let new_value = (self.initial_value as i64 + steps).clamp(min, max) as u32;

responses.add(NodeGraphMessage::SetInput {
input_connector: InputConnector::node(self.node_id, self.parameter()),
input: NodeInput::value(TaggedValue::U32(new_value), false),
});
responses.add(NodeGraphMessage::RunDocumentGraph);
}

/// Draw the dial as a grabbable handle at the layer origin: an outer ring (the hit target) plus
/// a filled center dot so it reads as draggable.
pub fn overlays(&self, document: &DocumentMessageHandler, mouse_position: DVec2, shape_editor: Option<&ShapeState>, overlay_context: &mut OverlayContext) {
if let Some(overlay) = self.info.behavior.overlay {
overlay(&self.context(document, mouse_position, shape_editor), overlay_context);
}

if self.state == GenericDialState::Inactive {
return;
}

let viewport = document.metadata().transform_to_viewport(self.layer);
let center = viewport.transform_point2(DVec2::ZERO);

overlay_context.circle(center, DIAL_INDICATOR_RADIUS, None, None);
overlay_context.manipulator_handle(center, self.state == GenericDialState::Dragging, None);
}

pub fn mouse_cursor_icon(&self) -> Option<MouseCursorIcon> {
match self.state {
GenericDialState::Hover | GenericDialState::Dragging => Some(MouseCursorIcon::EWResize),
GenericDialState::Inactive => None,
}
}
}
Loading
Loading