git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit84100ef9b89253ad89119a1eb7ff6c4ada78a386
parent708d58b054
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 16:48
feat: shapes and text on an image are placed by handles in the viewport

page_shape and page_text are viewer states: a shape has a middle that moves
it, a corner that sizes it about the middle and an edge that turns it; a
line a middle and an end; text its anchor and a handle one Size under it.
The add_image_* commands enter the state of what they add.

The framework gains what handles that are not their own world positions
need: a HandleCtx (the page's frame and the world unit) handed to read and
write, a drag hook so handles that hang off one another are carried
together, a plane a drag follows the cursor's ray to, and an outline and a
cage switch for the overlay. Handle labels draw on a dark tab.

page::resolve_frame reads a page's size, unit and place off its chain
without composing it, and rebuild_page keeps the GPU image while the
raster's size holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md                  |  41 ++++++-
 src/curve_tool.rs          |   6 +-
 src/image_handles.rs       | 269 +++++++++++++++++++++++++++++++++++++++++++++
 src/image_tools.rs         |   6 +
 src/main.rs                | 219 ++++++++++++++++++++++++++++++++++++
 src/page.rs                | 180 +++++++++++++++++++++++-------
 src/render.rs              |  42 ++++++-
 src/soft_transform_tool.rs |   6 +-
 src/viewer_state.rs        | 137 ++++++++++++++++++++---
 9 files changed, 842 insertions(+), 64 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ca87d92..cf62985 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -223,7 +223,7 @@ gone from cce-ui with the wgpu path).
   is editable — adding a source makes it appear in the menu without touching
   the menu.
 
-  Two implementations ship, deliberately different in shape, because an
+  Four implementations ship. The first two are deliberately different in shape, because an
   abstraction with a single implementation has not been shown to be one:
   `src/curve_tool.rs` (an open-ended list of world positions in the `curve`
   node's Points parameter, extensible) and `src/soft_transform_tool.rs` (a
@@ -235,6 +235,26 @@ gone from cce-ui with the wgpu path).
   drag and would quietly discard half of every restored undo snapshot, since
   `write` is handed a full set of handles with no word about which moved.
 
+  **Three hooks were added for handles that are not world positions of
+  their own** (2026-09-29, for the image tools). `read` and `write` take a
+  `HandleCtx` — the `PageFrame` of the page the node draws on, and what a
+  world unit is — gathered by the framework ahead of the call, because
+  `write` holds the node mutably and can look nothing up. `drag(handles,
+  moved, to)` is what the whole set is after one handle moves: moving the
+  one is the default, and a source whose handles hang off one another
+  carries them there (a shape's corner goes with its middle), so `write`
+  is still handed a whole set that means one thing whether it came from a
+  drag or an undo snapshot — the objection the soft transform's pair
+  raises against "keep the translation when the centre moves" does not
+  arise, since the rule is applied to the handles and not inside `write`.
+  `plane` is the plane the handles live in: a drag then follows the
+  cursor's ray to it, where without one it goes to the camera-facing
+  plane at the grab depth, which leaves a flat thing's plane as soon as
+  the view is not square to it. Two more are for the overlay: `outline`,
+  a closed loop drawn under the handles, and `cage`, whether the handles
+  are joined in order. Handle labels draw on a dark tab, since a handle
+  can stand over a white image.
+
   The HUD draws one line ABOVE the scale readout, sharing its left margin — not
   at the top, because the viewport is full-bleed and the pane plates float over
   its top edge, so a mode line there lands under the collapsed stubs. It exists
@@ -1606,6 +1626,25 @@ the shown one, else a new image), placed at the image's middle and sized
 from it IN THE IMAGE'S UNIT, shown and selected. Added to the middle of a
 chain the node is inserted: what read the target reads the new node.
 
+**Shapes and text are placed by their handles** (`src/image_handles.rs`,
+since 2026-09-29): the third and fourth `HandleSource`s, entered by Edit
+Handles like the others and straight away by the `add_image_*` commands.
+A shape has three — **move** (its middle), **size** (a corner of its box,
+which grows about the middle) and **turn** (the middle of its right edge,
+whose direction from the middle is the Rotation) — a line two, its middle
+and an end that sets length and angle together; text has its anchor and a
+handle one Size under it. They needed three things of the framework,
+which the viewer-state section below describes: a `HandleCtx`, the
+`drag` hook and the `plane`. The rows are written in the image's unit
+through `PageFrame::row` — whole pixels, thousandths of anything longer.
+`page::resolve_frame` is what makes the handles affordable: the page
+WITHOUT its pixels, read off the `page` node up the chain, since the
+overlay asks on every frame it is drawn and composing a sheet to learn
+its size would be a sheet a frame. A drag still recomposes the image on
+every motion, as dragging a slider does; `rebuild_page` keeps the GPU
+image while the size holds (`update_pixels`), where it used to free and
+upload one per rebuild and wait on the device each time.
+
 The two contexts do not mix, and `is_page_node` is the one place that says so.
 A page node contributes nothing to the viewport's geometry and a geometry node
 cannot feed a page: page chains resolve through `resolve_page`, never through
diff --git a/src/curve_tool.rs b/src/curve_tool.rs
index 89edbe3..cf45446 100644
--- a/src/curve_tool.rs
+++ b/src/curve_tool.rs
@@ -23,7 +23,7 @@
 
 use crate::app::FsNode;
 use crate::geometry::{format_curve_points, node_param_str, parse_curve_points};
-use crate::viewer_state::HandleSource;
+use crate::viewer_state::{HandleCtx, HandleSource};
 use glam::Vec3;
 
 /// Re-exported for the call sites that predate the framework. The projection
@@ -43,11 +43,11 @@ impl HandleSource for CurveHandles {
         node_type.eq_ignore_ascii_case("curve")
     }
 
-    fn read(&self, node: &FsNode) -> Vec<Vec3> {
+    fn read(&self, node: &FsNode, _ctx: &HandleCtx) -> Vec<Vec3> {
         parse_curve_points(&node_param_str(node, "Points", ""))
     }
 
-    fn write(&self, node: &mut FsNode, handles: &[Vec3]) {
+    fn write(&self, node: &mut FsNode, handles: &[Vec3], _ctx: &HandleCtx) {
         let formatted = format_curve_points(handles);
         if let Some(p) = node.params.iter_mut().find(|p| p.name == "Points") {
             p.set_text(formatted);
diff --git a/src/image_handles.rs b/src/image_handles.rs
new file mode 100644
index 0000000..1489927
--- /dev/null
+++ b/src/image_handles.rs
@@ -0,0 +1,269 @@
+//! The viewer states of the 2D context: a shape and a run of text on an
+//! image, placed by dragging where the image stands in the viewport.
+//!
+//! The third and fourth [`HandleSource`]s, and the first whose handles are
+//! not where their parameters say. A shape's rows are in the IMAGE'S unit
+//! from the image's top-left corner, y running down; its handles are world
+//! positions on a quad standing in the scene. [`PageFrame`] is the
+//! conversion, and the framework hands it over in the [`HandleCtx`], read
+//! off the `page` node up the chain without composing anything.
+//!
+//! They are also the first whose handles hang off one another, which is
+//! what [`HandleSource::drag`] is for:
+//!
+//! - **move** — the centre. The other handles go with it: a shape dragged
+//!   by its middle is moved, not resized.
+//! - **size** — a corner of the box, in the shape's own turned frame. The
+//!   box grows about its centre, so the opposite corner moves the other way.
+//! - **turn** — the middle of the box's right edge. Its DIRECTION from the
+//!   centre is the rotation; the corner is carried round with it.
+//!
+//! A line has two: its middle, and an end that sets its length and its
+//! angle together, which is how a line is drawn.
+//!
+//! Every drag follows the cursor's ray to the image's plane
+//! ([`HandleSource::plane`]), so a handle stays under the pointer from any
+//! orbit, and not only while the view is square to the image.
+
+use crate::app::FsNode;
+use crate::geometry::{node_param_f32, node_param_str};
+use crate::page::PageFrame;
+use crate::viewer_state::{HandleCtx, HandleSource};
+use glam::Vec3;
+
+pub struct ShapeHandles;
+pub struct TextHandles;
+
+/// `v` turned by `degrees`, in the page's frame: y runs down, so a positive
+/// angle is clockwise as the page is looked at — the way `Page::shape`
+/// turns the shape itself.
+fn turned(degrees: f32, v: [f32; 2]) -> [f32; 2] {
+    let (sin, cos) = degrees.to_radians().sin_cos();
+    [v[0] * cos - v[1] * sin, v[0] * sin + v[1] * cos]
+}
+
+fn add(a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
+    [a[0] + b[0], a[1] + b[1]]
+}
+
+fn sub(a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
+    [a[0] - b[0], a[1] - b[1]]
+}
+
+fn length(v: [f32; 2]) -> f32 {
+    v[0].hypot(v[1])
+}
+
+/// The angle of `v` in degrees, or None for a vector too short to have one.
+fn angle_of(v: [f32; 2], frame: &PageFrame) -> Option<f32> {
+    (length(v) > one_pixel(frame) * 0.5).then(|| v[1].atan2(v[0]).to_degrees())
+}
+
+/// One pixel of the raster, in the page's unit: the least a size can be.
+fn one_pixel(frame: &PageFrame) -> f32 {
+    frame.unit.from_inches(1.0 / frame.scale(), frame.scale())
+}
+
+fn set(node: &mut FsNode, name: &str, value: String) {
+    if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
+        p.set_text(value);
+    }
+}
+
+fn is_line(node: &FsNode) -> bool {
+    node_param_str(node, "Shape", "Rectangle").eq_ignore_ascii_case("Line")
+}
+
+/// The shape's centre, box and turn, as its rows have them.
+fn shape_rows(node: &FsNode) -> ([f32; 2], [f32; 2], f32) {
+    (
+        [node_param_f32(node, "X", 0.0), node_param_f32(node, "Y", 0.0)],
+        [node_param_f32(node, "Width", 1.0).abs(), node_param_f32(node, "Height", 1.0).abs()],
+        node_param_f32(node, "Rotation", 0.0),
+    )
+}
+
+impl HandleSource for ShapeHandles {
+    fn name(&self) -> &'static str {
+        "Image Shape"
+    }
+
+    fn accepts(&self, node_type: &str) -> bool {
+        node_type.eq_ignore_ascii_case("page_shape")
+    }
+
+    fn read(&self, node: &FsNode, ctx: &HandleCtx) -> Vec<Vec3> {
+        let Some(frame) = ctx.page else { return Vec::new() };
+        let (c, size, turn) = shape_rows(node);
+        let world = |at: [f32; 2]| frame.to_world(at, ctx.world_unit_mm);
+        let edge = add(c, turned(turn, [size[0] * 0.5, 0.0]));
+        if is_line(node) {
+            vec![world(c), world(edge)]
+        } else {
+            let corner = add(c, turned(turn, [size[0] * 0.5, size[1] * 0.5]));
+            vec![world(c), world(corner), world(edge)]
+        }
+    }
+
+    fn write(&self, node: &mut FsNode, handles: &[Vec3], ctx: &HandleCtx) {
+        let Some(frame) = ctx.page else { return };
+        let page = |p: &Vec3| frame.from_world(*p, ctx.world_unit_mm);
+        let least = one_pixel(&frame);
+        let kept = node_param_f32(node, "Rotation", 0.0);
+        let (c, size, turn) = match handles {
+            [c, end] => {
+                let (c, along) = (page(c), sub(page(end), page(c)));
+                let turn = angle_of(along, &frame).unwrap_or(kept);
+                (c, [length(along) * 2.0, node_param_f32(node, "Height", 1.0)], turn)
+            }
+            [c, corner, edge] => {
+                let c = page(c);
+                let turn = angle_of(sub(page(edge), c), &frame).unwrap_or(kept);
+                // The corner in the shape's own frame: the page turned back.
+                let half = turned(-turn, sub(page(corner), c));
+                (c, [half[0].abs() * 2.0, half[1].abs() * 2.0], turn)
+            }
+            _ => return,
+        };
+        set(node, "X", frame.row(c[0]));
+        set(node, "Y", frame.row(c[1]));
+        set(node, "Width", frame.row(size[0].max(least)));
+        if handles.len() == 3 {
+            set(node, "Height", frame.row(size[1].max(least)));
+        }
+        set(node, "Rotation", format!("{turn:.1}"));
+    }
+
+    fn drag(&self, handles: &mut Vec<Vec3>, moved: usize, to: Vec3, ctx: &HandleCtx) {
+        let Some(frame) = ctx.page else {
+            handles[moved] = to;
+            return;
+        };
+        let page = |p: Vec3| frame.from_world(p, ctx.world_unit_mm);
+        let world = |at: [f32; 2]| frame.to_world(at, ctx.world_unit_mm);
+        match (moved, handles.len()) {
+            // The middle carries the rest.
+            (0, _) => {
+                let by = to - handles[0];
+                for h in handles.iter_mut() {
+                    *h += by;
+                }
+            }
+            // The corner sets the box; the turn handle sits on the box's
+            // edge, so it follows the width.
+            (1, 3) => {
+                let c = page(handles[0]);
+                let turn = angle_of(sub(page(handles[2]), c), &frame).unwrap_or(0.0);
+                let half = turned(-turn, sub(page(to), c));
+                handles[1] = to;
+                handles[2] = world(add(c, turned(turn, [half[0].abs(), 0.0])));
+            }
+            // The turn handle carries the corner round the middle.
+            (2, 3) => {
+                let c = page(handles[0]);
+                let was = angle_of(sub(page(handles[2]), c), &frame);
+                let now = angle_of(sub(page(to), c), &frame);
+                if let (Some(was), Some(now)) = (was, now) {
+                    let corner = sub(page(handles[1]), c);
+                    handles[1] = world(add(c, turned(now - was, corner)));
+                }
+                handles[2] = to;
+            }
+            _ => handles[moved] = to,
+        }
+    }
+
+    fn plane(&self, ctx: &HandleCtx) -> Option<(Vec3, Vec3)> {
+        ctx.page.map(|frame| (Vec3::from_array(frame.origin), Vec3::Z))
+    }
+
+    fn outline(&self, node: &FsNode, ctx: &HandleCtx) -> Vec<Vec3> {
+        let Some(frame) = ctx.page else { return Vec::new() };
+        let (c, size, turn) = shape_rows(node);
+        let (hw, hh) = (size[0] * 0.5, size[1] * 0.5);
+        let corners: &[[f32; 2]] = if is_line(node) {
+            &[[-hw, 0.0], [hw, 0.0]]
+        } else {
+            &[[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]
+        };
+        corners
+            .iter()
+            .map(|v| frame.to_world(add(c, turned(turn, *v)), ctx.world_unit_mm))
+            .collect()
+    }
+
+    fn cage(&self) -> bool {
+        false
+    }
+
+    fn extensible(&self) -> bool {
+        false
+    }
+
+    fn hints(&self) -> &'static str {
+        "drag the middle to move, the corner to size, the edge to turn"
+    }
+
+    fn handle_label(&self, i: usize) -> String {
+        ["move", "size", "turn"].get(i).copied().unwrap_or("").to_string()
+    }
+}
+
+impl HandleSource for TextHandles {
+    fn name(&self) -> &'static str {
+        "Image Text"
+    }
+
+    fn accepts(&self, node_type: &str) -> bool {
+        node_type.eq_ignore_ascii_case("page_text")
+    }
+
+    /// The anchor — what the text is aligned against — and a handle one
+    /// Size below it, so the type's size is a length that can be seen.
+    fn read(&self, node: &FsNode, ctx: &HandleCtx) -> Vec<Vec3> {
+        let Some(frame) = ctx.page else { return Vec::new() };
+        let at = [node_param_f32(node, "X", 0.0), node_param_f32(node, "Y", 0.0)];
+        let size = node_param_f32(node, "Size", 0.25).abs();
+        vec![
+            frame.to_world(at, ctx.world_unit_mm),
+            frame.to_world(add(at, [0.0, size]), ctx.world_unit_mm),
+        ]
+    }
+
+    fn write(&self, node: &mut FsNode, handles: &[Vec3], ctx: &HandleCtx) {
+        let Some(frame) = ctx.page else { return };
+        let [at, below] = handles else { return };
+        let at = frame.from_world(*at, ctx.world_unit_mm);
+        let size = length(sub(frame.from_world(*below, ctx.world_unit_mm), at));
+        set(node, "X", frame.row(at[0]));
+        set(node, "Y", frame.row(at[1]));
+        set(node, "Size", frame.row(size.max(one_pixel(&frame))));
+    }
+
+    fn drag(&self, handles: &mut Vec<Vec3>, moved: usize, to: Vec3, _ctx: &HandleCtx) {
+        if moved == 0 {
+            let by = to - handles[0];
+            for h in handles.iter_mut() {
+                *h += by;
+            }
+        } else {
+            handles[moved] = to;
+        }
+    }
+
+    fn plane(&self, ctx: &HandleCtx) -> Option<(Vec3, Vec3)> {
+        ctx.page.map(|frame| (Vec3::from_array(frame.origin), Vec3::Z))
+    }
+
+    fn extensible(&self) -> bool {
+        false
+    }
+
+    fn hints(&self) -> &'static str {
+        "drag the anchor to move, the handle under it to size"
+    }
+
+    fn handle_label(&self, i: usize) -> String {
+        if i == 0 { "move".into() } else { "size".into() }
+    }
+}
diff --git a/src/image_tools.rs b/src/image_tools.rs
index 078234f..bafb861 100644
--- a/src/image_tools.rs
+++ b/src/image_tools.rs
@@ -324,6 +324,12 @@ impl State {
             }
         }
         self.show_and_select(slot);
+        // Straight into the node's viewer state: what was added is there to
+        // be placed, and its handles are how.
+        let node = &self.current_dir().children[slot];
+        if let Some(source) = crate::viewer_state::source_for(&node.node_type) {
+            self.viewer_tool = Some(crate::viewer_state::ViewerTool::new(node.id.clone(), source));
+        }
         self.update_status_text(&format!("Added {} to {}.", layer.label().to_lowercase(), new_name));
         Some(slot)
     }
diff --git a/src/main.rs b/src/main.rs
index 05b767a..d1e1584 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -39,6 +39,7 @@ pub mod hull;
 pub mod scatter;
 pub mod page;
 pub mod image_tools;
+pub mod image_handles;
 pub mod expr;
 pub mod thumbnail;
 
@@ -15049,4 +15050,222 @@ mod tests {
             assert_eq!(text_of(&state, all.last().unwrap(), "Shape"), shape);
         }
     }
+
+    // --- Handles on an image ------------------------------------------------
+
+    /// A page's frame is the page without its pixels: the same size, raster,
+    /// unit and place, from any node of the chain, and nothing where the
+    /// chain composes nothing.
+    #[test]
+    fn a_page_frame_is_the_page_without_its_pixels() {
+        use crate::page::{resolve_frame, resolve_page};
+        let root = image_root(vec![
+            image_node("page1", "page", &[("Preset", "Custom"), ("Units", "Millimetres"), ("Width", "120"), ("Height", "80"), ("Resolution", "127"), ("Position", "1.00:2.00:3.00")]),
+            image_node("shape1", "page_shape", &[("Input", "page1")]),
+            image_node("text1", "page_text", &[("Input", "shape1")]),
+            image_node("lost1", "page_text", &[("Input", "nothing")]),
+            image_node("sphere1", "sphere", &[]),
+        ]);
+        let page = resolve_page(&root, &root.children[2], &mut Vec::new()).expect("no page");
+        for slot in 0..3 {
+            let frame = resolve_frame(&root, &root.children[slot]).expect("no frame");
+            assert_eq!((frame.width, frame.height), (page.width, page.height));
+            assert_eq!((frame.size, frame.dpi, frame.unit, frame.origin), (page.size, page.dpi, page.unit, page.origin));
+        }
+        assert!(resolve_frame(&root, &root.children[3]).is_none(), "a frame for a chain with no page under it");
+        assert!(resolve_frame(&root, &root.children[4]).is_none(), "a frame for a sphere");
+
+        // The page's corner and its middle, in a world of millimetres, and
+        // back again.
+        let frame = resolve_frame(&root, &root.children[0]).unwrap();
+        let corner = frame.to_world([0.0, 0.0], 1.0);
+        assert!((corner - Vec3::new(1.0 - 60.0, 2.0 + 40.0, 3.0)).length() < 1e-3, "{corner:?}");
+        let middle = frame.to_world([60.0, 40.0], 1.0);
+        assert!((middle - Vec3::new(1.0, 2.0, 3.0)).length() < 1e-3, "{middle:?}");
+        let back = frame.from_world(frame.to_world([17.0, 63.0], 10.0), 10.0);
+        assert!((back[0] - 17.0).abs() < 1e-3 && (back[1] - 63.0).abs() < 1e-3, "{back:?}");
+        // Off the plane is the place straight behind.
+        let behind = frame.from_world(middle + Vec3::Z * 9.0, 1.0);
+        assert!((behind[0] - 60.0).abs() < 1e-3 && (behind[1] - 40.0).abs() < 1e-3);
+    }
+
+    /// Give the viewer state the camera the stage pass would have cached.
+    fn cache_scene_camera(state: &mut State) {
+        let (pos, rot, pivot) = state.active_camera_pose();
+        let (w, h) = (state.last_viewport_width as f32, state.last_viewport_height as f32);
+        let (proj, view, model) = state.viewport().get_matrices(w / h, Some(pos), Some(rot), Some(pivot));
+        state.last_scene_mvp = Some(proj * view * model);
+        state.last_scene_view_rect = (0.0, 0.0, w, h);
+    }
+
+    /// Press on handle `i`, carry it to a screen point, let go.
+    fn drag_handle_to(state: &mut State, i: usize, to: (f32, f32)) {
+        let handles = state.viewer_tool_handles();
+        let (_, x, y, _) = handles[i];
+        state.cursor_x = x;
+        state.cursor_y = y;
+        assert!(state.viewer_tool_press(), "the press on handle {i} grabbed nothing");
+        assert_eq!(state.viewer_tool.as_ref().unwrap().selected, Some(i), "the press took another handle");
+        // In two motions, as a pointer arrives.
+        state.cursor_x = (x + to.0) * 0.5;
+        state.cursor_y = (y + to.1) * 0.5;
+        assert!(state.viewer_tool_drag_motion());
+        state.cursor_x = to.0;
+        state.cursor_y = to.1;
+        assert!(state.viewer_tool_drag_motion());
+        assert!(state.viewer_tool_release());
+    }
+
+    /// A row of the node the viewer state is editing, as a number.
+    fn edited_row(state: &State, row: &str) -> f32 {
+        let id = state.viewer_tool.as_ref().expect("no viewer state").node_id.clone();
+        let node = crate::viewer_state::find_node_by_id(&state.fs_root, &id).expect("the edited node is gone");
+        crate::geometry::node_param_f32(node, row, f32::NAN)
+    }
+
+    /// A shape on an image is placed by its handles: the middle moves it and
+    /// carries the others, the corner sizes it about its middle, the edge
+    /// turns it and carries the corner round. At one image pixel to the
+    /// screen's, a drag of so many pixels is a change of as many.
+    #[test]
+    fn an_image_shape_is_moved_sized_and_turned_by_its_handles() {
+        let mut state = state_showing_image(400, 200, 100);
+        assert!(state.run_command("add_image_rectangle"));
+        assert_eq!(state.viewer_tool.as_ref().map(|t| t.source.name()), Some("Image Shape"), "adding a shape did not enter its viewer state");
+        state.view_image_pixels();
+        cache_scene_camera(&mut state);
+        let rows = |s: &State| ["X", "Y", "Width", "Height", "Rotation"].map(|r| edited_row(s, r));
+        assert_eq!(rows(&state), [200.0, 100.0, 67.0, 67.0, 0.0]);
+
+        let handles = state.viewer_tool_handles();
+        assert_eq!(handles.len(), 3, "a shape has a middle, a corner and an edge");
+        // The image's middle is the pane's, and the corner is down and right
+        // of it: the page's y runs down the screen.
+        assert!((handles[0].1 - 600.0).abs() < 0.5 && (handles[0].2 - 400.0).abs() < 0.5, "{:?}", handles[0]);
+        assert!((handles[1].1 - 633.5).abs() < 0.5 && (handles[1].2 - 433.5).abs() < 0.5, "{:?}", handles[1]);
+        assert!((handles[2].1 - 633.5).abs() < 0.5 && (handles[2].2 - 400.0).abs() < 0.5, "{:?}", handles[2]);
+        assert_eq!(state.viewer_tool_outline().len(), 4, "no outline of the box");
+
+        drag_handle_to(&mut state, 0, (630.0, 410.0));
+        assert_eq!(rows(&state), [230.0, 110.0, 67.0, 67.0, 0.0], "the middle did not move the shape whole");
+
+        let corner = state.viewer_tool_handles()[1];
+        drag_handle_to(&mut state, 1, (corner.1 + 20.0, corner.2 + 10.0));
+        let sized = rows(&state);
+        assert_eq!(sized[..2], [230.0, 110.0], "sizing moved the shape");
+        assert!((sized[2] - 107.0).abs() <= 1.0 && (sized[3] - 87.0).abs() <= 1.0, "the corner did not size the box about its middle: {sized:?}");
+        assert_eq!(sized[4], 0.0, "sizing turned the shape");
+
+        // The edge, carried from the right of the middle to under it: a
+        // quarter turn clockwise, and the box is the box it was.
+        drag_handle_to(&mut state, 2, (630.0, 410.0 + 40.0));
+        let turned = rows(&state);
+        assert!((turned[4] - 90.0).abs() < 0.5, "the edge did not turn the shape: {turned:?}");
+        assert!((turned[2] - sized[2]).abs() <= 1.0 && (turned[3] - sized[3]).abs() <= 1.0, "turning resized the box: {turned:?}");
+        assert_eq!(turned[..2], [230.0, 110.0]);
+        // Turned, the corner handle is down and LEFT of the middle.
+        let corner = state.viewer_tool_handles()[1];
+        assert!(corner.1 < 630.0 && corner.2 > 410.0, "the corner was not carried round: {corner:?}");
+
+        // Each drag is one step to undo.
+        assert!(state.viewer_tool_undo());
+        assert!((edited_row(&state, "Rotation")).abs() < 0.5, "undo did not take the turn back");
+        assert!(state.viewer_tool_redo());
+        assert!((edited_row(&state, "Rotation") - 90.0).abs() < 0.5);
+
+        // From an orbit the handles are still under the pointer: the middle,
+        // dropped where a place on the image shows, is at that place.
+        state.orbit_camera_by(140.0, -60.0);
+        let py = state.viewport().pending_yaw;
+        let pp = state.viewport().pending_pitch;
+        state.update_active_camera_rotation(py, pp);
+        cache_scene_camera(&mut state);
+        let ctx_frame = {
+            let id = state.viewer_tool.as_ref().unwrap().node_id.clone();
+            let node = crate::viewer_state::find_node_by_id(&state.fs_root, &id).unwrap();
+            crate::page::resolve_frame(&state.fs_root, node).unwrap()
+        };
+        let target = ctx_frame.to_world([120.0, 60.0], state.world_unit_mm());
+        let (sx, sy, _) = crate::viewer_state::project_point(&state.last_scene_mvp.unwrap(), state.last_scene_view_rect, target).expect("the place is behind the camera");
+        let head_on = state.viewer_tool_handles()[0];
+        assert!((head_on.1 - 630.0).abs() > 2.0 || (head_on.2 - 410.0).abs() > 2.0, "the orbit did not move the view");
+        drag_handle_to(&mut state, 0, (sx, sy));
+        let moved = rows(&state);
+        assert!((moved[0] - 120.0).abs() <= 1.0 && (moved[1] - 60.0).abs() <= 1.0, "from an orbit the handle left the image's plane: {moved:?}");
+        assert!((moved[4] - 90.0).abs() < 0.5 && (moved[2] - sized[2]).abs() <= 1.0, "moving from an orbit changed the shape: {moved:?}");
+    }
+
+    /// A line has a middle and an end, and the end sets how long it is and
+    /// which way it runs.
+    #[test]
+    fn an_image_line_is_drawn_by_its_end() {
+        let mut state = state_showing_image(400, 200, 100);
+        assert!(state.run_command("add_image_line"));
+        state.view_image_pixels();
+        cache_scene_camera(&mut state);
+        assert_eq!(state.viewer_tool_handles().len(), 2, "a line has a middle and an end");
+        assert_eq!(state.viewer_tool_outline().len(), 2);
+        let height = edited_row(&state, "Height");
+
+        // Up and to the right of the middle by 30 and 40: fifty long each
+        // way, running up the page.
+        drag_handle_to(&mut state, 1, (630.0, 360.0));
+        assert_eq!(edited_row(&state, "Width"), 100.0);
+        assert!((edited_row(&state, "Rotation") - (-53.1)).abs() < 0.2, "{}", edited_row(&state, "Rotation"));
+        assert_eq!((edited_row(&state, "X"), edited_row(&state, "Y")), (200.0, 100.0));
+        assert_eq!(edited_row(&state, "Height"), height, "a line's handles wrote a row it does not show");
+    }
+
+    /// Text is moved by its anchor and sized by the handle under it, in the
+    /// image's unit — here inches on a sheet shown at half size.
+    #[test]
+    fn image_text_is_moved_and_sized_by_its_handles() {
+        let mut state = state_showing_image(400, 200, 100);
+        let page = state.current_dir().children.iter().position(|n| n.node_type == "page").unwrap();
+        state.current_dir_mut().children[page].params.iter_mut().find(|p| p.name == "Units").unwrap().set_text("Inches");
+        for (row, v) in [("Width", "4"), ("Height", "2")] {
+            state.current_dir_mut().children[page].params.iter_mut().find(|p| p.name == row).unwrap().set_text(v);
+        }
+        state.rebuild_scene_geometry();
+        assert!(state.run_command("add_image_text"));
+        assert_eq!(state.viewer_tool.as_ref().map(|t| t.source.name()), Some("Image Text"));
+        state.view_image_pixels();
+        cache_scene_camera(&mut state);
+        assert_eq!((edited_row(&state, "X"), edited_row(&state, "Y"), edited_row(&state, "Size")), (2.0, 1.0, 0.1));
+
+        let handles = state.viewer_tool_handles();
+        assert_eq!(handles.len(), 2);
+        assert!((handles[1].2 - handles[0].2 - 10.0).abs() < 0.5, "the size handle is not one Size under the anchor");
+
+        // A hundred pixels to the inch.
+        drag_handle_to(&mut state, 0, (650.0, 375.0));
+        assert_eq!((edited_row(&state, "X"), edited_row(&state, "Y"), edited_row(&state, "Size")), (2.5, 0.75, 0.1));
+        drag_handle_to(&mut state, 1, (650.0, 375.0 + 25.0));
+        assert_eq!((edited_row(&state, "X"), edited_row(&state, "Y"), edited_row(&state, "Size")), (2.5, 0.75, 0.25));
+
+        // A node that draws on no image has no handles to grab.
+        let id = state.viewer_tool.as_ref().unwrap().node_id.clone();
+        crate::viewer_state::find_node_by_id_mut(&mut state.fs_root, &id)
+            .unwrap()
+            .params
+            .iter_mut()
+            .find(|p| p.name == "Input")
+            .unwrap()
+            .set_text("");
+        assert!(state.viewer_tool_handles().is_empty());
+    }
+
+    /// Recomposing an image of the same size keeps its GPU image, which a
+    /// drag does on every motion; another size takes another.
+    #[test]
+    fn a_recomposed_image_keeps_its_gpu_image() {
+        let mut state = state_showing_image(64, 32, 100);
+        let first = state.page_image.expect("nothing uploaded");
+        state.rebuild_scene_geometry();
+        assert_eq!(state.page_image, Some(first), "the same picture took a new image");
+        let page = state.current_dir().children.iter().position(|n| n.node_type == "page").unwrap();
+        state.current_dir_mut().children[page].params.iter_mut().find(|p| p.name == "Width").unwrap().set_text("80");
+        state.rebuild_scene_geometry();
+        assert!(state.page_image.is_some_and(|id| id != first), "a picture of another size kept the old image");
+    }
 }
diff --git a/src/page.rs b/src/page.rs
index edba2bf..2379a40 100644
--- a/src/page.rs
+++ b/src/page.rs
@@ -122,19 +122,30 @@ impl PageUnit {
     }
 }
 
-/// The largest page anyone composes by accident: a 1000 DPI A0 sheet is about
-/// 1.4 gigapixels, and the honest failure is a clamped resolution rather than
-/// an allocation that takes the app down. Chosen as roughly 13 × 19 inches (a
-/// large-format print) at 1200 DPI.
-const MAX_PIXELS: u64 = 356_000_000;
+/// A page without its pixels: its size, its raster's size, its unit and
+/// where it stands. What placing something ON a page needs, at the cost of
+/// walking the chain to the `page` node and of nothing else — the viewport's
+/// handles ask for it on every frame they are drawn, and composing the
+/// raster to learn how big it is would be a sheet a frame.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub struct PageFrame {
+    /// Physical size in inches.
+    pub size: [f32; 2],
+    pub dpi: u32,
+    pub width: u32,
+    pub height: u32,
+    pub unit: PageUnit,
+    /// The page's centre in the scene, world units.
+    pub origin: [f32; 3],
+}
 
-impl Page {
-    /// A blank sheet filled with `color`.
+impl PageFrame {
+    /// The frame of a sheet `size` inches at `dpi`, clamped as a page is.
     ///
     /// The size is clamped to something a printer could accept rather than
     /// rejected: a page node whose size parameter is being dragged passes
     /// through zero, and a context that returns an error there flickers.
-    pub fn new(size: [f32; 2], dpi: u32, color: [f32; 4]) -> Page {
+    pub fn new(size: [f32; 2], dpi: u32) -> PageFrame {
         let size = [size[0].max(0.01), size[1].max(0.01)];
         let dpi = dpi.clamp(1, 2400);
         let mut width = (size[0] * dpi as f32).round().max(1.0) as u32;
@@ -146,14 +157,77 @@ impl Page {
             width = ((width as f64 * scale) as u32).max(1);
             height = ((height as f64 * scale) as u32).max(1);
         }
+        PageFrame { size, dpi, width, height, unit: PageUnit::Inches, origin: [0.0; 3] }
+    }
+
+    /// Pixels per inch, measured from the raster.
+    pub fn scale(&self) -> f32 {
+        self.width as f32 / self.size[0]
+    }
+
+    /// World units to the inch, in a world of `world_unit_mm` millimetres.
+    fn world_per_inch(world_unit_mm: f32) -> f32 {
+        25.4 / world_unit_mm.max(1e-6)
+    }
+
+    /// A place on the page, in the page's unit from its top-left corner, as
+    /// a place in the scene. The page's y runs down and the world's up.
+    pub fn to_world(&self, at: [f32; 2], world_unit_mm: f32) -> glam::Vec3 {
+        let k = Self::world_per_inch(world_unit_mm);
+        let s = self.scale();
+        let x = self.unit.to_inches(at[0], s) - self.size[0] * 0.5;
+        let y = self.size[1] * 0.5 - self.unit.to_inches(at[1], s);
+        glam::Vec3::new(self.origin[0] + x * k, self.origin[1] + y * k, self.origin[2])
+    }
+
+    /// The other way. A place off the page's plane is the place on it
+    /// straight behind: the page faces +Z, and its depth says nothing.
+    pub fn from_world(&self, p: glam::Vec3, world_unit_mm: f32) -> [f32; 2] {
+        let k = Self::world_per_inch(world_unit_mm);
+        let s = self.scale();
+        let x = (p.x - self.origin[0]) / k + self.size[0] * 0.5;
+        let y = self.size[1] * 0.5 - (p.y - self.origin[1]) / k;
+        [self.unit.from_inches(x, s), self.unit.from_inches(y, s)]
+    }
+
+    /// A number for a row in the page's unit: whole pixels, and thousandths
+    /// of anything longer.
+    pub fn row(&self, value: f32) -> String {
+        if self.unit == PageUnit::Pixels {
+            format!("{}", value.round())
+        } else {
+            let s = format!("{value:.3}");
+            let s = s.trim_end_matches('0');
+            // Two decimals at the least, as the templates write them.
+            let decimals = s.len() - s.find('.').map_or(s.len(), |i| i + 1);
+            format!("{s}{}", "0".repeat(2usize.saturating_sub(decimals)))
+        }
+    }
+}
+
+/// The largest page anyone composes by accident: a 1000 DPI A0 sheet is about
+/// 1.4 gigapixels, and the honest failure is a clamped resolution rather than
+/// an allocation that takes the app down. Chosen as roughly 13 × 19 inches (a
+/// large-format print) at 1200 DPI.
+const MAX_PIXELS: u64 = 356_000_000;
+
+impl Page {
+    /// A blank sheet filled with `color`, clamped as [`PageFrame::new`]
+    /// clamps it.
+    pub fn new(size: [f32; 2], dpi: u32, color: [f32; 4]) -> Page {
+        Page::blank(PageFrame::new(size, dpi), color)
+    }
+
+    /// The blank sheet a frame describes.
+    pub fn blank(frame: PageFrame, color: [f32; 4]) -> Page {
         Page {
-            size,
-            dpi,
-            width,
-            height,
-            pixels: vec![color; (width * height) as usize],
-            unit: PageUnit::Inches,
-            origin: [0.0; 3],
+            size: frame.size,
+            dpi: frame.dpi,
+            width: frame.width,
+            height: frame.height,
+            pixels: vec![color; (frame.width * frame.height) as usize],
+            unit: frame.unit,
+            origin: frame.origin,
         }
     }
 
@@ -707,6 +781,55 @@ fn toggle_of(node: &FsNode, name: &str) -> bool {
     crate::geometry::node_param_bool(node, name, false)
 }
 
+/// The frame a `page` node describes.
+fn page_node_frame(target: &FsNode) -> PageFrame {
+    let preset = node_param_str(target, "Preset", "Letter");
+    let unit = PageUnit::parse(&node_param_str(target, "Units", "Inches"));
+    let dpi = node_param_f32(target, "Resolution", 300.0).round().clamp(1.0, 2400.0);
+    // A named size is what it is whatever the Units row says; the row
+    // is what Width and Height — and every node downstream — are in.
+    let size = preset_size(&preset)
+        .or_else(|| preset_pixels(&preset).map(|[w, h]| [w / dpi, h / dpi]))
+        .unwrap_or_else(|| {
+            [
+                unit.to_inches(node_param_f32(target, "Width", 8.5), dpi),
+                unit.to_inches(node_param_f32(target, "Height", 11.0), dpi),
+            ]
+        });
+    // Landscape is the same sheet turned, not a different sheet: swap the
+    // axes rather than asking for a second pair of numbers. A raster
+    // preset is named as it lies, and Custom says its own two numbers.
+    let turned = preset_size(&preset).is_some()
+        && node_param_str(target, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape");
+    let size = if turned { [size[1], size[0]] } else { size };
+    let mut frame = PageFrame::new(size, dpi as u32);
+    frame.unit = unit;
+    frame.origin = node_param_vec3(target, "Position", Vec3::ZERO).to_array();
+    frame
+}
+
+/// The frame of the page `target` draws on: [`resolve_page`]'s walk up the
+/// chain, without the drawing. None where that would compose nothing — no
+/// page at the bottom, or a wire that comes back to itself.
+pub fn resolve_frame(root: &FsNode, target: &FsNode) -> Option<PageFrame> {
+    let mut visited: Vec<&str> = Vec::new();
+    let mut node = target;
+    loop {
+        if visited.contains(&node.id.as_str()) {
+            return None;
+        }
+        visited.push(&node.id);
+        let kind = node.node_type.to_ascii_lowercase();
+        if kind == "page" && !crate::geometry::is_bypassed(node) {
+            return Some(page_node_frame(node));
+        }
+        if !is_page_node(&kind) && kind != "export" {
+            return None;
+        }
+        node = crate::geometry::param_node(root, node, "Input")?;
+    }
+}
+
 /// Compose the page `target` describes, resolving its input chain.
 ///
 /// `visited` guards cycles by node id exactly as the geometry resolvers do —
@@ -726,29 +849,10 @@ pub fn resolve_page(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -
 
     let kind = target.node_type.to_ascii_lowercase();
     if kind == "page" {
-        let preset = node_param_str(target, "Preset", "Letter");
-        let unit = PageUnit::parse(&node_param_str(target, "Units", "Inches"));
-        let dpi = node_param_f32(target, "Resolution", 300.0).round().clamp(1.0, 2400.0);
-        // A named size is what it is whatever the Units row says; the row
-        // is what Width and Height — and every node downstream — are in.
-        let size = preset_size(&preset)
-            .or_else(|| preset_pixels(&preset).map(|[w, h]| [w / dpi, h / dpi]))
-            .unwrap_or_else(|| {
-                [
-                    unit.to_inches(node_param_f32(target, "Width", 8.5), dpi),
-                    unit.to_inches(node_param_f32(target, "Height", 11.0), dpi),
-                ]
-            });
-        // Landscape is the same sheet turned, not a different sheet: swap the
-        // axes rather than asking for a second pair of numbers. A raster
-        // preset is named as it lies, and Custom says its own two numbers.
-        let turned = preset_size(&preset).is_some()
-            && node_param_str(target, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape");
-        let size = if turned { [size[1], size[0]] } else { size };
-        let mut page = Page::new(size, dpi as u32, color_with(target, "Color", Vec3::ONE, "Opacity"));
-        page.unit = unit;
-        page.origin = node_param_vec3(target, "Position", Vec3::ZERO).to_array();
-        return Some(page);
+        return Some(Page::blank(
+            page_node_frame(target),
+            color_with(target, "Color", Vec3::ONE, "Opacity"),
+        ));
     }
 
     // Everything else composites onto its input, so a chain with no page at
diff --git a/src/render.rs b/src/render.rs
index bc62bf2..c38cf7f 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1057,8 +1057,18 @@ impl State {
         if vw <= 0.0 || vh <= 0.0 {
             return;
         }
+        let outline = self.viewer_tool_outline();
+        let cage = tool.source.cage();
         pc.clip(rect(vx, vy, vw, vh), |pc| {
-            for pair in handles.windows(2) {
+            // What is being edited, under its handles: dark then light, so
+            // the line reads over a white sheet and over a black one.
+            for i in 0..outline.len() {
+                let (x0, y0) = outline[i];
+                let (x1, y1) = outline[(i + 1) % outline.len()];
+                pc.vector(x0, y0, x1, y1, 2.5, [0.0, 0.0, 0.0, 0.45], cce_ui::scene::paint::Cap::Round);
+                pc.vector(x0, y0, x1, y1, 1.0, [1.0, 0.78, 0.20, 0.9], cce_ui::scene::paint::Cap::Round);
+            }
+            for pair in handles.windows(2).filter(|_| cage) {
                 let (_, x0, y0, _) = pair[0];
                 let (_, x1, y1, _) = pair[1];
                 pc.vector(x0, y0, x1, y1, 1.0, [1.0, 1.0, 1.0, 0.25], cce_ui::scene::paint::Cap::Round);
@@ -1074,7 +1084,14 @@ impl State {
                     [1.0, 0.78, 0.20, 1.0]
                 };
                 pc.circle(*sx, *sy, r, col);
-                pc.text(tool.source.handle_label(*i), sx + 8.0, sy - 6.0, 10.0, [0xff, 0xe6, 0xa0]);
+                // On a dark tab of its own, as the HUD is: a handle can
+                // stand over a white image, where light text is no text.
+                let label = tool.source.handle_label(*i);
+                if !label.is_empty() {
+                    let width = label.chars().count() as f32 * 10.0 * 0.52 + 6.0;
+                    pc.quad(rect(sx + 5.0, sy - 8.0, width, 14.0), [0.0, 0.0, 0.0, 0.55]);
+                    pc.text(label, sx + 8.0, sy - 6.0, 10.0, [0xff, 0xe6, 0xa0]);
+                }
             }
         });
 
@@ -1114,13 +1131,28 @@ impl State {
         let level = self.viewport_editor_dir();
         let node_id = crate::page::displayed_page_node(level).map(|n| n.id.clone());
         let page = crate::page::displayed_page(&self.fs_root, level);
-        if let Some(old) = self.page_image.take() {
+        // The same picture with new contents keeps its image: a handle
+        // being dragged recomposes the page on every motion, and freeing an
+        // image waits for the device to go idle.
+        let before = self.page_shown.take();
+        let had_page = before.is_some();
+        let same_size = |page: &crate::page::Page| {
+            before.as_ref().is_some_and(|b| b.pixels == (page.width, page.height))
+        };
+        let old = self.page_image.take();
+        let kept = old.filter(|_| page.as_ref().is_some_and(same_size));
+        if let (Some(old), None) = (old, kept) {
             cce_ui::vk::free_image(old);
         }
-        let had_page = self.page_shown.take().is_some();
         if let (Some(page), Some(node_id)) = (page, node_id) {
             let (w, h) = (page.width, page.height);
-            self.page_image = Some(cce_ui::vk::upload_rgba(page.to_rgba8(), w, h));
+            self.page_image = Some(match kept {
+                Some(id) => {
+                    cce_ui::vk::update_pixels(id, page.to_rgba8(), w, h, cce_ui::vk::PixelFormat::Rgba);
+                    id
+                }
+                None => cce_ui::vk::upload_rgba(page.to_rgba8(), w, h),
+            });
             self.update_status_text(&format!(
                 "Image: {} x {} {} at {} DPI ({}x{} px)",
                 trim_number(page.in_unit(page.size[0])),
diff --git a/src/soft_transform_tool.rs b/src/soft_transform_tool.rs
index c850706..3a92fae 100644
--- a/src/soft_transform_tool.rs
+++ b/src/soft_transform_tool.rs
@@ -33,7 +33,7 @@
 
 use crate::app::FsNode;
 use crate::geometry::node_param_str;
-use crate::viewer_state::HandleSource;
+use crate::viewer_state::{HandleCtx, HandleSource};
 use glam::Vec3;
 
 pub struct SoftTransformHandles;
@@ -75,12 +75,12 @@ impl HandleSource for SoftTransformHandles {
         node_type.eq_ignore_ascii_case("soft_transform")
     }
 
-    fn read(&self, node: &FsNode) -> Vec<Vec3> {
+    fn read(&self, node: &FsNode, _ctx: &HandleCtx) -> Vec<Vec3> {
         let centre = triple(node, "Center");
         vec![centre, centre + triple(node, "Translation")]
     }
 
-    fn write(&self, node: &mut FsNode, handles: &[Vec3]) {
+    fn write(&self, node: &mut FsNode, handles: &[Vec3], _ctx: &HandleCtx) {
         let [centre, offset] = handles else { return };
         set_triple(node, "Center", *centre);
         set_triple(node, "Translation", *offset - *centre);
diff --git a/src/viewer_state.rs b/src/viewer_state.rs
index 74e6444..1cc3309 100644
--- a/src/viewer_state.rs
+++ b/src/viewer_state.rs
@@ -44,6 +44,21 @@ pub const HANDLE_HIT_RADIUS: f32 = 10.0;
 /// world positions — a soft transform's translation is an offset — converts in
 /// [`read`](Self::read) and [`write`](Self::write), so the framework never has
 /// to know the difference and the drag maths stays one implementation.
+/// What a source may need to know beyond its own node.
+///
+/// A curve's points are world positions and need nothing. A shape on an
+/// image is written in the image's unit from the image's corner, so where it
+/// stands in the scene depends on a node further up its chain and on what a
+/// world unit is — neither of which `write` can look up, holding the node
+/// mutably as it does. The framework gathers both before it calls.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct HandleCtx {
+    /// The page the node draws on, when it draws on one.
+    pub page: Option<crate::page::PageFrame>,
+    /// One world unit in millimetres.
+    pub world_unit_mm: f32,
+}
+
 pub trait HandleSource {
     /// Shown in the HUD, so it says what mode the viewport is in.
     fn name(&self) -> &'static str;
@@ -54,11 +69,43 @@ pub trait HandleSource {
     fn accepts(&self, node_type: &str) -> bool;
 
     /// The node's handles, in world space.
-    fn read(&self, node: &FsNode) -> Vec<Vec3>;
+    fn read(&self, node: &FsNode, ctx: &HandleCtx) -> Vec<Vec3>;
 
     /// Write handles back into the node's parameters. The framework runs the
     /// resync afterwards.
-    fn write(&self, node: &mut FsNode, handles: &[Vec3]);
+    fn write(&self, node: &mut FsNode, handles: &[Vec3], ctx: &HandleCtx);
+
+    /// Handle `moved` was dragged to `to`: what the whole set is now.
+    ///
+    /// Moving the one handle is the default, and is all a source of
+    /// independent handles wants. A source whose handles hang off one
+    /// another carries them here — a shape's corner goes with its centre —
+    /// so that `write` is still handed a whole set that means one thing,
+    /// whether it came from a drag or from an undo snapshot.
+    fn drag(&self, handles: &mut Vec<Vec3>, moved: usize, to: Vec3, _ctx: &HandleCtx) {
+        handles[moved] = to;
+    }
+
+    /// The plane the handles live in, as a point on it and its normal, for
+    /// a source that has one. A drag then follows the cursor's ray to that
+    /// plane, where without one it follows it to the camera-facing plane at
+    /// the depth the handle was grabbed — which leaves a flat thing's plane
+    /// as soon as the view is not square to it.
+    fn plane(&self, _ctx: &HandleCtx) -> Option<(Vec3, Vec3)> {
+        None
+    }
+
+    /// A closed outline to draw with the handles, in world space: what is
+    /// being edited, where the handles alone do not show it.
+    fn outline(&self, _node: &FsNode, _ctx: &HandleCtx) -> Vec<Vec3> {
+        Vec::new()
+    }
+
+    /// Whether the handles are joined in order by a faint line — a curve's
+    /// control cage. False for handles that are not a sequence.
+    fn cage(&self) -> bool {
+        true
+    }
 
     /// Whether a press on empty space appends a handle and a right press
     /// deletes one. False for a source with a fixed set — a soft transform has
@@ -202,9 +249,11 @@ pub fn find_node_by_id_mut<'a>(root: &'a mut FsNode, id: &str) -> Option<&'a mut
 /// One place that maps node types to tools, so the node context menu, the
 /// command and any future entry point agree about what is editable.
 pub fn source_for(node_type: &str) -> Option<Box<dyn HandleSource>> {
-    let sources: [Box<dyn HandleSource>; 2] = [
+    let sources: [Box<dyn HandleSource>; 4] = [
         Box::new(crate::curve_tool::CurveHandles),
         Box::new(crate::soft_transform_tool::SoftTransformHandles),
+        Box::new(crate::image_handles::ShapeHandles),
+        Box::new(crate::image_handles::TextHandles),
     ];
     sources.into_iter().find(|s| s.accepts(node_type))
 }
@@ -237,6 +286,15 @@ impl State {
         true
     }
 
+    /// What the source of the node's handles may need to know: gathered
+    /// here, once, ahead of every read and write.
+    fn viewer_handle_ctx(&self, node: &FsNode) -> HandleCtx {
+        let page = crate::page::is_page_node(&node.node_type)
+            .then(|| crate::page::resolve_frame(&self.fs_root, node))
+            .flatten();
+        HandleCtx { page, world_unit_mm: self.world_unit_mm() }
+    }
+
     /// The edited node's handles, or None if the node is gone or is no longer
     /// a type this source accepts.
     fn viewer_handles_of(&self, node_id: &str) -> Option<Vec<Vec3>> {
@@ -245,14 +303,63 @@ impl State {
         if !tool.source.accepts(&node.node_type) {
             return None;
         }
-        Some(tool.source.read(node))
+        Some(tool.source.read(node, &self.viewer_handle_ctx(node)))
+    }
+
+    /// The active tool's outline, projected: screen points of a closed
+    /// loop, empty for a source that draws none.
+    pub(crate) fn viewer_tool_outline(&self) -> Vec<(f32, f32)> {
+        let Some(tool) = &self.viewer_tool else { return Vec::new() };
+        let Some(mvp) = self.last_scene_mvp else { return Vec::new() };
+        let Some(node) = find_node_by_id(&self.fs_root, &tool.node_id) else { return Vec::new() };
+        if !tool.source.accepts(&node.node_type) {
+            return Vec::new();
+        }
+        let loop_ = tool.source.outline(node, &self.viewer_handle_ctx(node));
+        let projected: Vec<(f32, f32)> = loop_
+            .iter()
+            .filter_map(|p| project_point(&mvp, self.last_scene_view_rect, *p))
+            .map(|(x, y, _)| (x, y))
+            .collect();
+        // A corner behind the camera has no place on screen, and a loop
+        // missing one is another shape.
+        if projected.len() == loop_.len() { projected } else { Vec::new() }
+    }
+
+    /// Where a drag puts the grabbed handle: on the source's plane under
+    /// the cursor when it has one, else on the camera-facing plane at the
+    /// depth the handle was grabbed.
+    fn viewer_drag_target(&self, node_id: &str, ndc_z: f32) -> Option<Vec3> {
+        let mvp = self.last_scene_mvp?;
+        let view = self.last_scene_view_rect;
+        let (cx, cy) = (self.cursor_x, self.cursor_y);
+        let plane = self.viewer_tool.as_ref().and_then(|tool| {
+            let node = find_node_by_id(&self.fs_root, node_id)?;
+            tool.source.plane(&self.viewer_handle_ctx(node))
+        });
+        if let Some((point, normal)) = plane {
+            let near = unproject_point(&mvp, view, cx, cy, 0.0)?;
+            let far = unproject_point(&mvp, view, cx, cy, 1.0)?;
+            let ray = far - near;
+            let along = ray.dot(normal);
+            // A plane seen edge-on is met nowhere the cursor can name.
+            if along.abs() > 1e-6 * ray.length().max(1e-6) {
+                let t = (point - near).dot(normal) / along;
+                return Some(near + ray * t);
+            }
+        }
+        unproject_point(&mvp, view, cx, cy, ndc_z)
     }
 
     /// Write handles back and run the same resync sequence as SetParam.
     fn set_viewer_handles(&mut self, node_id: &str, handles: &[Vec3]) {
+        let ctx = match find_node_by_id(&self.fs_root, node_id) {
+            Some(node) => self.viewer_handle_ctx(node),
+            None => return,
+        };
         let Some(tool) = self.viewer_tool.take() else { return };
         if let Some(node) = find_node_by_id_mut(&mut self.fs_root, node_id) {
-            tool.source.write(node, handles);
+            tool.source.write(node, handles, &ctx);
         }
         self.viewer_tool = Some(tool);
         self.sync_nodes();
@@ -342,7 +449,9 @@ impl State {
     /// camera-facing plane at its grab depth.
     pub(crate) fn viewer_tool_drag_motion(&mut self) -> bool {
         let Some(drag) = self.viewer_tool.as_ref().and_then(|t| t.drag) else { return false };
-        let Some(mvp) = self.last_scene_mvp else { return false };
+        if self.last_scene_mvp.is_none() {
+            return false;
+        }
         let node_id = self.viewer_tool.as_ref().expect("drag implies tool").node_id.clone();
         let Some(mut pts) = self.viewer_handles_of(&node_id) else {
             self.viewer_tool = None;
@@ -351,17 +460,17 @@ impl State {
         if drag.handle >= pts.len() {
             return false;
         }
-        let Some(world) = unproject_point(
-            &mvp,
-            self.last_scene_view_rect,
-            self.cursor_x,
-            self.cursor_y,
-            drag.ndc_z,
-        ) else {
+        let Some(world) = self.viewer_drag_target(&node_id, drag.ndc_z) else {
             return false;
         };
         let snap = self.viewer_tool.as_ref().and_then(|t| t.snap);
-        pts[drag.handle] = snapped(world, snap);
+        let ctx = match find_node_by_id(&self.fs_root, &node_id) {
+            Some(node) => self.viewer_handle_ctx(node),
+            None => return false,
+        };
+        if let Some(tool) = self.viewer_tool.as_ref() {
+            tool.source.drag(&mut pts, drag.handle, snapped(world, snap), &ctx);
+        }
         if let Some(tool) = self.viewer_tool.as_mut() {
             tool.history.commit_gesture();
         }