graphic design tool
git clone https://git.lucas.co/cce-designer.git
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();
}