Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ca6bbe6
Heart node
Keavon May 7, 2026
bfcb4ca
Add Heart drawing mode to the Shape tool with gizmo registration
Ayush2k02 May 25, 2026
f1b203d
Migrate the Heart node to the ranked node input API
Ayush2k02 Jul 24, 2026
48796d5
Add registry-driven generic gizmo system
Ayush2k02 Jul 6, 2026
19a79e1
Migrate the Circle gizmo to the generic registry system
Ayush2k02 Jul 6, 2026
52f02bf
Migrate the Polygon gizmo to the generic registry system
Ayush2k02 Jul 6, 2026
fc87ae9
Add the Heart's radius gizmo via the generic registry system
Ayush2k02 Jul 6, 2026
8ab60ba
Port the gizmo registry stack to current master's geometry and parame…
Ayush2k02 Aug 21, 2026
c97ca64
Cover the ported heart geometry with tests
Ayush2k02 Aug 21, 2026
e479206
Give the generic gizmos an escape hatch for shape-specific behavior
Ayush2k02 Aug 22, 2026
0069cba
Restore the polygon dial's spokes and outline, lost in the generic mi…
Ayush2k02 Aug 22, 2026
b891871
Migrate the Star gizmos to the generic registry system
Ayush2k02 Aug 22, 2026
fae76a9
Migrate the Spiral gizmo to the generic registry system
Ayush2k02 Aug 22, 2026
e8edf73
Migrate the Arc gizmos to the generic registry system
Ayush2k02 Aug 22, 2026
1d76a3b
Migrate the Grid gizmos to the generic registry system
Ayush2k02 Aug 22, 2026
ba5a89b
Name the gizmo hook signatures
Ayush2k02 Aug 22, 2026
9d865b7
Restore the circumference grab for circle and arc radii, and stop the…
Ayush2k02 Aug 22, 2026
24fa18c
Match the sides dial to the one it replaced
Ayush2k02 Aug 22, 2026
cf3db70
Show a resting handle for gizmos that draw nothing of their own
Ayush2k02 Aug 24, 2026
2b0ffd2
Give the polygon back its radius handles
Ayush2k02 Aug 24, 2026
cc2c623
Put handles on the heart's cleavage and shoulders
Ayush2k02 Aug 24, 2026
190d2ef
Measure a gizmo drag from where it started, not from where the cursor is
Ayush2k02 Aug 24, 2026
0bb008c
Read a circular radius drag horizontally, from wherever it was grabbed
Ayush2k02 Aug 24, 2026
44ae618
Write the guide for adding a gizmo to a node
Ayush2k02 Aug 24, 2026
209be12
Rank a point handle above a region handle when the two overlap
Ayush2k02 Aug 26, 2026
e830897
Correct the stale comment on the polygon gizmo declarations
Ayush2k02 Aug 26, 2026
ad3dff3
Draw the heart in document units and skip degenerate drags
Ayush2k02 Aug 26, 2026
9b493fe
Describe the cleavage angle correctly and test symmetry against the c…
Ayush2k02 Aug 26, 2026
d0e3fdc
Scope the ParamCurve import to the tests that use it
Ayush2k02 Aug 26, 2026
745e688
Draw the heart through the shared window-aligned transform helper
Ayush2k02 Aug 26, 2026
53f9dd5
Resolve overlays against the layer the manager is given
Ayush2k02 Aug 26, 2026
3cb1c28
Stop defaulting the grid gizmo type into its panicking variant
Ayush2k02 Aug 26, 2026
3523690
Clamp the dial to the parameter's own type and the slider after snapping
Ayush2k02 Aug 26, 2026
0a2a89c
Guard the star's reciprocal snap radius against a near-zero factor
Ayush2k02 Aug 26, 2026
5709968
Re-sync the control bar when a gizmo drag ends
Ayush2k02 Aug 26, 2026
9686ca1
Look up a registry entry without allocating
Ayush2k02 Aug 26, 2026
f0e2b5b
Resolve the circular radius parameter in one lookup
Ayush2k02 Aug 26, 2026
16fe702
Remove the gizmo scaffolding the migration left behind
Ayush2k02 Aug 29, 2026
9a488c6
Drop the unused position hint
Ayush2k02 Aug 29, 2026
65119e7
Refresh the gizmo handlers while a shape is being drawn
Ayush2k02 Aug 29, 2026
761fa95
Merge branch 'master' into gizmo/registry-system
Ayush2k02 Aug 31, 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
160 changes: 160 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,160 @@
# 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 builds a handler per selected layer and routes events to it
```

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,
}];
```

Then register the node so the manager can find it:

```rust
pub fn registered_gizmo_nodes() -> [(ProtoNodeIdentifier, &'static [GizmoInfo]); 7] {
[
// ...
(generator_nodes::heart::IDENTIFIER, HEART_GIZMOS),
]
}
```

The array length is part of the signature, so remember to bump it.

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 |
| `extended_target` | what you grab is a region, so an overlapping point handle should outrank 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.
- **Handles go on the +X axis unless you say otherwise.** There is no bounding-box anchoring; a handle that
belongs somewhere else needs `handle_positions`.
- **Two overlapping handles are not ranked by distance alone.** A gizmo grabbed along a region reports how
far the cursor is from that region, which is near zero everywhere along it; a point handle reports its
real distance. Comparing those two numbers gives the region every grab. Mark the region one
`extended_target: true` and the point wins outright — this is what makes an arc's sweep endpoints
reachable at all, since they sit on the very circumference its radius is grabbed along.
- **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 dial that edits a `u32` node parameter, such as a polygon's side count.
//!
//! It sits at the layer's origin and turns a horizontal drag into integer steps: right to increase, left to
//! decrease.

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. Larger than the drawn indicator so the handle is easy to grab
/// and the press does not 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 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. A gizmo picks its
/// parameter from the registry at runtime, so it cannot name a parameter symbol at the call site.
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)
}

/// Whether this gizmo is grabbed along a region rather than at a point, which decides priority against an
/// overlapping handle. See `GizmoBehavior::extended_target`.
pub fn is_extended_target(&self) -> bool {
self.info.behavior.extended_target
}

/// The cursor's distance to the dial's centre when it is a hover candidate, else `None`. The dial occupies
/// a disc of `DIAL_HOVER_RADIUS` around the layer origin. Mutates nothing.
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);

// Once the shape is this small the hit disc covers the whole of it, and a press meant for the layer
// would be swallowed by the dial.
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)
}

/// Enter the hovered state, unless already hovered or dragging. The reference value is captured here
/// because `handle_click`, which starts the drag, has no access to the document.
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, clamped to the registry's bounds. The magnitude comes from the
/// total drag distance, so the dial answers motion in any direction, while the horizontal component
/// decides the sign: right increases, left decreases.
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(|min| min as i64).unwrap_or(0);
// u32::MAX, not i64::MAX: the cast below would wrap anything above it.
let max = self.info.max.map(|max| max as i64).unwrap_or(u32::MAX as i64);
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 at the layer origin: an outer ring plus a filled centre 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