From 4d249aed9dd644bf0f98015fb043382c97b81045 Mon Sep 17 00:00:00 2001 From: allan sargeant <8385907+stoatworks-labs@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:03:41 +0100 Subject: [PATCH] Design a panel's surface in the app, instead of by hand in the XML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces have existed since the day panels stopped being flat, but the only way to shape one was to type control points into the stage file. The inspector now picks the kind — Flat, Arc or Lattice — and the Previz view drags a lattice's points about. Three decisions worth keeping: Conversions sample the old shape (Surface::bake_lattice) rather than rebuilding it from parameters, so switching an arc to a lattice mid-edit does not move the picture, and resizing the grid keeps the shape it had. Re-baking a lattice at its own size is the identity, which matters because the columns and rows spinners re-bake on every change. Handles are picked from press_origin, not from where the pointer is when egui decides a press has become a drag — by that frame the pointer has already left the handle, and hit-testing the live position picks nothing at all. A handle drags in the plane through it facing the camera, so depth is the one thing a pull cannot change; any other plane lets a point run away at a glancing view and pushes the wall through the set. Also fixes the viewport being rendered at the *window's* size and squashed into the rect that shows it: previz ran at the wrong aspect and the emulation view's zoom did not mean what it said. It had to go before handles could be dragged, because an overlay computed from the true camera lands nowhere near a stretched image. And the widgets turn out to be clickable after all: egui::Context::run_ui takes a RawInput, so pointer events drive the real widget code with no window, no GPU and no NDI. The whole pick-drag-orbit path is tested that way, and it caught the press_origin bug before the app was ever run. 153 tests. The formatting churn in the files touched is rustfmt catching up on lines that were already drifting. Co-Authored-By: Claude Opus 5 --- crates/unmapper-core/src/geom.rs | 70 +++- crates/unmapper-core/src/lib.rs | 5 +- crates/unmapper-core/src/stage.rs | 368 ++++++++++++++++- crates/unmapper-gui/src/main.rs | 23 +- crates/unmapper-gui/src/state.rs | 388 +++++++++++++++++- crates/unmapper-gui/src/ui.rs | 640 +++++++++++++++++++++++++++++- docs/NOTES.md | 55 ++- docs/USER-GUIDE.md | 31 ++ 8 files changed, 1526 insertions(+), 54 deletions(-) diff --git a/crates/unmapper-core/src/geom.rs b/crates/unmapper-core/src/geom.rs index 7589869..dc3ad8c 100644 --- a/crates/unmapper-core/src/geom.rs +++ b/crates/unmapper-core/src/geom.rs @@ -294,10 +294,7 @@ impl Quad { let d = (p1.y - p0.y) + g * p1.y; let e = (p3.y - p0.y) + h * p3.y; - Vec2::new( - (a * u + b * v + p0.x) / w, - (d * u + e * v + p0.y) / w, - ) + Vec2::new((a * u + b * v + p0.x) / w, (d * u + e * v + p0.y) / w) } /// The sub-quad covering `[u0,u1] x [v0,v1]` of this one, projectively. @@ -323,10 +320,70 @@ impl Quad { } } +/// A ray, in whatever space the holder says. +/// +/// Exists so a click in the previz view can become a point in the stage: the +/// camera turns a screen position into one of these, and the thing being dragged +/// says what surface to meet it on. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Ray { + pub origin: Vec3, + /// Unit length by construction in [`Ray::new`], so `t` is metres. + pub direction: Vec3, +} + +impl Ray { + pub fn new(origin: Vec3, direction: Vec3) -> Self { + Self { + origin, + direction: direction.normalize_or_zero(), + } + } + + pub fn at(&self, t: f32) -> Vec3 { + self.origin + self.direction * t + } + + /// Where this ray meets the plane through `point` with `normal`. + /// + /// `None` when the ray runs along the plane, or meets it *behind* the origin + /// — dragging a handle must never teleport it to a mirrored point somewhere + /// behind the camera, which is what an unsigned intersection does the moment + /// the pointer crosses the horizon. + pub fn intersect_plane(&self, point: Vec3, normal: Vec3) -> Option { + let denom = normal.dot(self.direction); + if denom.abs() < 1e-6 { + return None; + } + let t = normal.dot(point - self.origin) / denom; + if !(t.is_finite() && t > 0.0) { + return None; + } + Some(self.at(t)) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_ray_meets_a_plane_in_front_of_it_and_never_one_behind() { + let r = Ray::new(Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 0.0, -1.0)); + let hit = r + .intersect_plane(Vec3::ZERO, Vec3::Z) + .expect("meets the plane"); + assert!((hit - Vec3::ZERO).length() < 1e-5, "got {hit:?}"); + + // The same plane, now behind the ray: no hit, rather than one at -t. + let away = Ray::new(Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 0.0, 1.0)); + assert_eq!(away.intersect_plane(Vec3::ZERO, Vec3::Z), None); + + // And a ray running along the plane misses it rather than dividing by zero. + let along = Ray::new(Vec3::new(0.0, 0.0, 10.0), Vec3::X); + assert_eq!(along.intersect_plane(Vec3::ZERO, Vec3::Z), None); + } + #[test] fn quad_from_rect_is_axis_aligned() { let q = Quad::from_rect(Rect::new(10.0, 20.0, 100.0, 50.0)); @@ -481,10 +538,7 @@ mod tests { // A point in the middle of the cell, found two ways. for (su, sv) in [(0.5, 0.5), (0.25, 0.8)] { let via_cell = cell.project(su, sv); - let via_whole = keystone.project( - u0 + (u1 - u0) * su, - v0 + (v1 - v0) * sv, - ); + let via_whole = keystone.project(u0 + (u1 - u0) * su, v0 + (v1 - v0) * sv); assert!( (via_cell - via_whole).length() < 1e-2, "cell ({col},{row}) at ({su},{sv}): {via_cell:?} vs {via_whole:?}" diff --git a/crates/unmapper-core/src/lib.rs b/crates/unmapper-core/src/lib.rs index 2de1337..147561b 100644 --- a/crates/unmapper-core/src/lib.rs +++ b/crates/unmapper-core/src/lib.rs @@ -23,13 +23,14 @@ pub mod slicemap; pub mod stage; pub mod warp; -pub use geom::{Quad, Rect, Vec2, Vec3}; +pub use geom::{Quad, Ray, Rect, Vec2, Vec3}; pub use show::{ Binding, Output, OutputTarget, OutputView, Problem, ReapplyReport, Severity, Show, ShowError, Source, SourceKind, SourceSpace, SHOW_FORMAT, }; pub use slicemap::{RasterSource, Screen, Size, Slice, SliceMap}; pub use stage::{ - Backdrop, Camera, Model3d, Panel, Placement3d, StageGeometry, Surface, DEFAULT_PITCH_MM, + ArcMetrics, Backdrop, Camera, Model3d, Panel, Placement3d, StageGeometry, Surface, + DEFAULT_PITCH_MM, }; pub use warp::{WarpCell, WarpMesh, WarpMode}; diff --git a/crates/unmapper-core/src/stage.rs b/crates/unmapper-core/src/stage.rs index 7721f99..54634a3 100644 --- a/crates/unmapper-core/src/stage.rs +++ b/crates/unmapper-core/src/stage.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; -use crate::geom::{Rect, Vec2, Vec3}; +use crate::geom::{Ray, Rect, Vec2, Vec3}; use crate::slicemap::Size; pub use glam::Quat; @@ -104,6 +104,23 @@ pub enum Surface { /// 180-degree wrap still costs only 36 quads. const ARC_DEGREES_PER_SEGMENT: f32 = 5.0; +/// What an arc actually measures, once it is bent. +/// +/// The operator types a sweep because that is what the shape *is*, but the +/// numbers they can check against a drawing or a tape measure are these — a +/// radius to compare with the truss circle, the straight-line span between the +/// two ends, and how far the middle stands proud of that line. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ArcMetrics { + /// Radius of the circle the panel lies on, in metres. + pub radius: f32, + /// Straight-line distance between the two ends, in metres. Always shorter + /// than the panel's width, which is preserved as arc length. + pub chord: f32, + /// How far the centre of the panel sits from that chord, in metres. + pub depth: f32, +} + impl Surface { /// A lattice, or `None` if it does not describe a grid. pub fn lattice(columns: u32, rows: u32, points: Vec) -> Option { @@ -131,16 +148,89 @@ impl Surface { for col in 0..columns { let u = col as f32 / (columns - 1) as f32; let v = row as f32 / (rows - 1) as f32; - points.push(Vec3::new( - (u - 0.5) * size.x, - (0.5 - v) * size.y, - 0.0, - )); + points.push(Vec3::new((u - 0.5) * size.x, (0.5 - v) * size.y, 0.0)); } } Self::lattice(columns, rows, points) } + /// Resample this surface onto a `columns` x `rows` lattice of the same shape. + /// + /// This is how the editor gets from a shape a parameter describes to one it + /// can drag: bake the arc, then pull the points. Sampling — rather than + /// converting the parameters — is what keeps it honest, because the lattice + /// is built from the very function the renderer walks, so the picture does + /// not move on the frame the conversion happens. + /// + /// Re-baking a lattice at its own size is the identity: the samples land + /// exactly on the existing control points. That matters, because the columns + /// and rows spinners re-bake on every change, and a resample that drifted + /// would erode a measured surface a nudge at a time. + pub fn bake_lattice(&self, size: Vec2, columns: u32, rows: u32) -> Option { + if columns < 2 || rows < 2 { + return None; + } + let mut points = Vec::with_capacity((columns as usize) * (rows as usize)); + for row in 0..rows { + for col in 0..columns { + let u = col as f32 / (columns - 1) as f32; + let v = row as f32 / (rows - 1) as f32; + points.push(self.local_point(u, v, size)); + } + } + Self::lattice(columns, rows, points) + } + + /// The lattice's size, or `None` for a surface that is not one. + pub fn lattice_dims(&self) -> Option<(u32, u32)> { + match self { + Surface::Lattice { columns, rows, .. } => Some((*columns, *rows)), + _ => None, + } + } + + /// The control points, or an empty slice for a surface that has none. + pub fn points(&self) -> &[Vec3] { + match self { + Surface::Lattice { points, .. } => points, + _ => &[], + } + } + + /// Move one control point, in panel-local metres. `false` if there is no such + /// point — a stale index from a surface that changed under the selection. + pub fn set_point(&mut self, index: usize, to: Vec3) -> bool { + match self { + Surface::Lattice { points, .. } => match points.get_mut(index) { + Some(p) => { + *p = to; + true + } + None => false, + }, + _ => false, + } + } + + /// The radius, chord and depth of an arc of `size`, or `None` for any other + /// surface — and for an arc so shallow it is a flat panel written the long + /// way round, whose radius is an infinity nobody wants to read. + pub fn arc_metrics(&self, size: Vec2) -> Option { + let Surface::Arc { sweep_deg } = self else { + return None; + }; + let theta = sweep_deg.to_radians(); + if theta.abs() < 1e-4 { + return None; + } + let radius = (size.x / theta).abs(); + Some(ArcMetrics { + radius, + chord: 2.0 * radius * (theta / 2.0).sin().abs(), + depth: radius * (1.0 - (theta / 2.0).cos()), + }) + } + pub fn is_flat(&self) -> bool { matches!(self, Surface::Flat) } @@ -157,9 +247,10 @@ impl Surface { // A zero sweep is a flat panel written the long way round. ((n as u32).clamp(1, 128), 1) } - Surface::Lattice { columns, rows, .. } => { - (columns.saturating_sub(1).max(1), rows.saturating_sub(1).max(1)) - } + Surface::Lattice { columns, rows, .. } => ( + columns.saturating_sub(1).max(1), + rows.saturating_sub(1).max(1), + ), } } @@ -260,8 +351,12 @@ impl Panel { /// the only place the surface shape and the panel's pose come together. For a /// flat panel it agrees exactly with [`Placement3d::corners`] at the corners. pub fn surface_point(&self, u: f32, v: f32) -> Vec3 { - self.placement.translation - + self.placement.rotation * self.surface.local_point(u, v, self.placement.size) + self.stage_of(self.surface.local_point(u, v, self.placement.size)) + } + + /// The stage-space point that a panel-local point sits at. + pub fn stage_of(&self, local: Vec3) -> Vec3 { + self.placement.translation + self.placement.rotation * local } /// How many cells across and down this panel's surface needs. @@ -269,6 +364,16 @@ impl Panel { self.surface.subdivisions() } + /// The panel-local point that a stage-space point sits at. + /// + /// The inverse of the pose half of [`Panel::surface_point`], and the step + /// that turns a dragged handle back into something the surface can store: the + /// pointer moves in the stage, but a lattice is measured in the panel's own + /// frame, so a panel that is yawed later carries its shape round with it. + pub fn local_of(&self, stage: Vec3) -> Vec3 { + self.placement.rotation.inverse() * (stage - self.placement.translation) + } + /// Pixel pitch in millimetres implied by the current physical size. /// /// Returns `None` for a zero-width panel rather than an infinity. @@ -360,6 +465,47 @@ impl Camera { pub fn view_projection(&self, aspect: f32) -> glam::Mat4 { self.projection_matrix(aspect) * self.view_matrix() } + + /// Where `point` lands in the frame, as a fraction of it from the **top + /// left**, or `None` if it is behind the camera. + /// + /// Y is flipped on the way out, because clip space has Y up and every screen + /// this ends up on has Y down. Getting that wrong does not look like a bug — + /// it looks like a rig that is upside down only while you drag it. + pub fn project(&self, point: Vec3, aspect: f32) -> Option { + let clip = self.view_projection(aspect) * point.extend(1.0); + // Behind the eye, w goes to zero and then negative; dividing through it + // puts the point back on screen, mirrored, which is worse than losing it. + if clip.w <= 1e-6 { + return None; + } + let ndc = clip.truncate() / clip.w; + Some(Vec2::new((ndc.x + 1.0) / 2.0, (1.0 - ndc.y) / 2.0)) + } + + /// The ray through the frame at `uv`, a fraction of it from the top left. + /// + /// The exact inverse of [`Camera::project`], so a handle picked at a pixel + /// and dragged from it does not jump on the first frame of the drag. + pub fn ray(&self, uv: Vec2, aspect: f32) -> Ray { + let inverse = self.view_projection(aspect).inverse(); + let ndc = Vec2::new(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0); + // Depth 0 is the near plane and 1 the far one: this is wgpu's clip space, + // which is what `perspective_rh` builds. + let unproject = |depth: f32| { + let p = inverse * glam::Vec4::new(ndc.x, ndc.y, depth, 1.0); + p.truncate() / p.w + }; + let near = unproject(0.0); + Ray::new(near, unproject(1.0) - near) + } + + /// The direction the camera looks, which is also the normal of the plane a + /// handle drags in — the one plane through a handle that never foreshortens + /// under the pointer. + pub fn forward(&self) -> Vec3 { + (self.target - self.position).normalize_or_zero() + } } #[cfg(test)] @@ -441,7 +587,13 @@ mod tests { fn a_flat_surface_agrees_with_the_four_corners() { // The compatibility guarantee: every rig in existence is flat, and the // surface path must not move any of them by a millimetre. - let panel = Panel::from_layout("p", "P", Size::new(400, 200), Rect::new(0.0, 0.0, 400.0, 200.0), 2.6); + let panel = Panel::from_layout( + "p", + "P", + Size::new(400, 200), + Rect::new(0.0, 0.0, 400.0, 200.0), + 2.6, + ); let corners = panel.placement.corners(); let at = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]; for (i, (u, v)) in at.iter().enumerate() { @@ -457,8 +609,13 @@ mod tests { #[test] fn an_arc_keeps_its_width_as_arc_length_and_sweeps_its_ends_away() { - let mut panel = - Panel::from_layout("p", "P", Size::new(1000, 200), Rect::new(0.0, 0.0, 1000.0, 200.0), 2.6); + let mut panel = Panel::from_layout( + "p", + "P", + Size::new(1000, 200), + Rect::new(0.0, 0.0, 1000.0, 200.0), + 2.6, + ); panel.placement.translation = Vec3::ZERO; panel.placement.rotation = Quat::IDENTITY; let width = panel.placement.size.x; @@ -486,7 +643,12 @@ mod tests { assert!(panel.surface_point(0.0, 0.5).z < -0.01); assert!(panel.surface_point(1.0, 0.5).z < -0.01); assert!((panel.surface_point(0.0, 0.5).z - panel.surface_point(1.0, 0.5).z).abs() < 1e-4); - assert!(Surface::Arc { sweep_deg: -90.0 }.local_point(0.0, 0.5, panel.placement.size).z > 0.01); + assert!( + Surface::Arc { sweep_deg: -90.0 } + .local_point(0.0, 0.5, panel.placement.size) + .z + > 0.01 + ); // 90 degrees at 5 per segment. assert_eq!(panel.subdivisions(), (18, 1)); @@ -514,7 +676,12 @@ mod tests { } // Pull the centre point towards the audience. - let Surface::Lattice { columns, rows, mut points } = flat else { + let Surface::Lattice { + columns, + rows, + mut points, + } = flat + else { panic!("flat_lattice should build a lattice"); }; points[4].z += 1.0; @@ -533,13 +700,178 @@ mod tests { // A hand-edited stage file can still carry a broken lattice. It must fall // back to flat, not panic once per frame inside the renderer. - let broken = Surface::Lattice { columns: 4, rows: 4, points: vec![Vec3::ZERO; 3] }; + let broken = Surface::Lattice { + columns: 4, + rows: 4, + points: vec![Vec3::ZERO; 3], + }; let size = Vec2::new(4.0, 2.0); for (u, v) in [(0.0, 0.0), (1.0, 1.0), (0.5, 0.5)] { - assert_eq!(broken.local_point(u, v, size), Surface::Flat.local_point(u, v, size)); + assert_eq!( + broken.local_point(u, v, size), + Surface::Flat.local_point(u, v, size) + ); + } + } + + #[test] + fn baking_a_lattice_at_its_own_size_changes_nothing() { + // The inspector re-bakes on every spinner change. If this drifted, a + // measured surface would erode one nudge at a time. + let size = Vec2::new(4.0, 2.0); + let mut points = Surface::flat_lattice(size, 4, 3).unwrap().points().to_vec(); + points[5] += Vec3::new(0.1, -0.2, 0.7); + let measured = Surface::lattice(4, 3, points).unwrap(); + + let again = measured.bake_lattice(size, 4, 3).unwrap(); + assert_eq!(again.lattice_dims(), Some((4, 3))); + for (a, b) in again.points().iter().zip(measured.points()) { + assert!((*a - *b).length() < 1e-5, "{a:?} drifted from {b:?}"); + } + } + + #[test] + fn baking_an_arc_keeps_the_shape_the_renderer_was_drawing() { + // Converting to a lattice must not move the picture: this is the moment + // an operator switches from a parameter to handles, mid-edit. + let size = Vec2::new(6.0, 2.5); + let arc = Surface::Arc { sweep_deg: 60.0 }; + let baked = arc.bake_lattice(size, 17, 3).expect("a lattice"); + + for i in 0..=16 { + let u = i as f32 / 16.0; + let a = arc.local_point(u, 0.5, size); + let b = baked.local_point(u, 0.5, size); + // Exact on the control columns; between them the lattice chords the + // arc, and 60 degrees over 16 cells is well under a millimetre. + assert!((a - b).length() < 1e-3, "u={u}: arc {a:?} vs baked {b:?}"); + } + assert!(baked.points().iter().all(|p| p.is_finite())); + } + + #[test] + fn baking_refuses_a_degenerate_grid_rather_than_making_an_unusable_surface() { + let size = Vec2::new(4.0, 2.0); + assert!(Surface::Flat.bake_lattice(size, 1, 4).is_none()); + assert!(Surface::Flat.bake_lattice(size, 4, 0).is_none()); + } + + #[test] + fn moving_a_control_point_only_answers_for_a_lattice() { + let size = Vec2::new(4.0, 2.0); + let mut lattice = Surface::flat_lattice(size, 3, 3).unwrap(); + assert!(lattice.set_point(4, Vec3::new(0.0, 0.0, 1.0))); + assert!((lattice.local_point(0.5, 0.5, size).z - 1.0).abs() < 1e-5); + + // A stale index, and a surface with no points at all: both refused, and + // neither panics — the selection outlives the surface it was made against. + assert!(!lattice.set_point(99, Vec3::ZERO)); + assert!(!Surface::Arc { sweep_deg: 30.0 }.set_point(0, Vec3::ZERO)); + assert!(Surface::Flat.points().is_empty()); + assert_eq!(Surface::Flat.lattice_dims(), None); + } + + #[test] + fn arc_metrics_match_the_geometry_and_a_flat_arc_has_none() { + let size = Vec2::new(6.0, 2.0); + let arc = Surface::Arc { sweep_deg: 180.0 }; + let m = arc.arc_metrics(size).expect("a real arc"); + + // A half circle of arc length 6: radius 6/pi, chord = the diameter, and + // the depth is that same radius. + assert!( + (m.radius - 6.0 / std::f32::consts::PI).abs() < 1e-4, + "{m:?}" + ); + assert!((m.chord - 2.0 * m.radius).abs() < 1e-4, "{m:?}"); + assert!((m.depth - m.radius).abs() < 1e-4, "{m:?}"); + + // The chord agrees with where the ends actually are. + let ends = (arc.local_point(1.0, 0.5, size) - arc.local_point(0.0, 0.5, size)).length(); + assert!( + (m.chord - ends).abs() < 1e-3, + "chord {} vs ends {ends}", + m.chord + ); + + // Sign does not change any of it: a wall bulging forwards is the same + // circle seen from the other side. + let back = Surface::Arc { sweep_deg: -180.0 } + .arc_metrics(size) + .unwrap(); + assert!((back.radius - m.radius).abs() < 1e-4); + assert!((back.chord - m.chord).abs() < 1e-4); + + assert_eq!(Surface::Arc { sweep_deg: 0.0 }.arc_metrics(size), None); + assert_eq!(Surface::Flat.arc_metrics(size), None); + } + + #[test] + fn local_of_is_the_inverse_of_the_panels_pose() { + let mut panel = Panel::from_layout( + "p", + "P", + Size::new(400, 200), + Rect::new(0.0, 0.0, 400.0, 200.0), + 2.6, + ); + panel.placement.translation = Vec3::new(-3.0, 4.5, 1.25); + panel.placement.rotation = Quat::from_rotation_y(0.7); + panel.surface = Surface::Arc { sweep_deg: 40.0 }; + + for (u, v) in [(0.0, 0.0), (0.5, 0.5), (1.0, 0.25), (0.75, 1.0)] { + let stage = panel.surface_point(u, v); + let local = panel.local_of(stage); + let expected = panel.surface.local_point(u, v, panel.placement.size); + assert!( + (local - expected).length() < 1e-4, + "({u},{v}): {local:?} vs {expected:?}" + ); + } + } + + #[test] + fn projecting_and_unprojecting_are_the_same_map_read_both_ways() { + // What makes a dragged handle stay under the pointer: pick a point, and + // the ray through where it lands must go straight back through it. + let cam = Camera::default(); + let aspect = 16.0 / 9.0; + for point in [ + Vec3::new(0.0, 4.0, 0.0), + Vec3::new(-2.5, 1.0, -3.0), + Vec3::new(3.0, 6.0, 2.0), + ] { + let uv = cam.project(point, aspect).expect("in front of the camera"); + let ray = cam.ray(uv, aspect); + let hit = ray + .intersect_plane(point, cam.forward()) + .expect("the plane through the point faces the camera"); + assert!( + (hit - point).length() < 1e-3, + "{point:?} came back as {hit:?}" + ); } } + #[test] + fn the_centre_of_the_frame_is_the_camera_target_and_up_is_up() { + let cam = Camera::default(); + let centre = cam.project(cam.target, 16.0 / 9.0).unwrap(); + assert!((centre - Vec2::new(0.5, 0.5)).length() < 1e-4, "{centre:?}"); + + // Higher in the stage must mean *smaller* v: the flip from clip space to + // a screen, which is invisible until you drag something. + let above = cam.project(cam.target + Vec3::Y, 16.0 / 9.0).unwrap(); + assert!(above.y < centre.y, "{above:?} should sit above {centre:?}"); + } + + #[test] + fn a_point_behind_the_camera_has_no_place_on_screen() { + let cam = Camera::default(); + // The camera sits at z = 12 looking back at the origin, so this is behind it. + assert_eq!(cam.project(Vec3::new(0.0, 1.7, 20.0), 16.0 / 9.0), None); + } + #[test] fn surface_uv_edges_stay_inside_the_lattice() { // v = 1.0 exactly lands on the last row's boundary; an off-by-one in the diff --git a/crates/unmapper-gui/src/main.rs b/crates/unmapper-gui/src/main.rs index 8edf42a..81b6e0d 100644 --- a/crates/unmapper-gui/src/main.rs +++ b/crates/unmapper-gui/src/main.rs @@ -314,7 +314,7 @@ impl Host { .handle_platform_output(&live.window, full_output.platform_output); // --- stage pass ----------------------------------------------------- - live.ensure_target(live.viewport_size_hint()); + live.ensure_target(live.viewport_size_hint(self.app.viewport_px)); if let Err(e) = live.sync_backdrop(&self.app.show) { self.app.error(format!("{e:#}")); @@ -681,10 +681,27 @@ impl Live { } /// The size the offscreen stage target should be. - fn viewport_size_hint(&self) -> Size { - Size::new( + /// The size to render the viewport at: the rect egui actually paints it into, + /// in physical pixels. + /// + /// Not the window's size, which is what this used to be. The image is + /// stretched into that rect, so rendering at window size squashes it by + /// whatever the side panels take up — a previz camera at the wrong aspect, + /// and an emulation view whose zoom does not mean what it says. Neither + /// looks like an error; both are just geometry that is quietly wrong, and a + /// handle dragged in a stretched view lands somewhere else again. + fn viewport_size_hint(&self, painted_px: unmapper_core::Vec2) -> Size { + let (w, h) = ( self.surface_config.width.max(1), self.surface_config.height.max(1), + ); + if !painted_px.is_finite() || painted_px.x < 1.0 || painted_px.y < 1.0 { + // Before the first layout there is no painted rect to go on. + return Size::new(w, h); + } + Size::new( + (painted_px.x.round() as u32).clamp(1, w), + (painted_px.y.round() as u32).clamp(1, h), ) } diff --git a/crates/unmapper-gui/src/state.rs b/crates/unmapper-gui/src/state.rs index e4f7447..a907f3c 100644 --- a/crates/unmapper-gui/src/state.rs +++ b/crates/unmapper-gui/src/state.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::time::{Duration, Instant}; -use unmapper_core::{Rect, Show, SourceKind, Vec2}; +use unmapper_core::{Camera, Panel, Rect, Show, SourceKind, Surface, Vec2, Vec3}; use crate::outputs::MonitorInfo; use unmapper_ndi::{Ndi, ReceiverHandle, SourceName}; @@ -43,6 +43,94 @@ pub enum Drag { Panel { id: String, grab: Vec2 }, /// Panning the view. Pan, + /// Pulling one of a surface's control points about in the previz view. + /// + /// `grab` is the offset from the pointer's position on the drag plane to the + /// handle, in metres, so a handle picked slightly off-centre does not snap + /// itself under the cursor the moment the drag starts. + SurfacePoint { + panel: String, + index: usize, + grab: Vec3, + }, +} + +/// Which shape a panel's surface is, without the shape itself. +/// +/// The inspector picks a kind and the surface is converted to it. Keeping the +/// choice apart from the data is what lets a conversion *sample* the old shape +/// instead of discarding it — switching an arc to a lattice mid-edit must not +/// move the picture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SurfaceKind { + Flat, + Arc, + Lattice, +} + +impl SurfaceKind { + pub fn of(surface: &Surface) -> Self { + match surface { + Surface::Flat => SurfaceKind::Flat, + Surface::Arc { .. } => SurfaceKind::Arc, + Surface::Lattice { .. } => SurfaceKind::Lattice, + } + } + + pub fn label(self) -> &'static str { + match self { + SurfaceKind::Flat => "Flat", + SurfaceKind::Arc => "Arc", + SurfaceKind::Lattice => "Lattice", + } + } +} + +/// The sweep a panel gets the first time it becomes an arc. +/// +/// Deliberately not zero: a zero-sweep arc *is* a flat panel, and an operator +/// who picks "Arc", sees nothing happen and concludes the control is broken is +/// not wrong to. +const NEW_ARC_SWEEP_DEG: f32 = 15.0; + +/// The lattice a panel gets the first time it becomes one — wide enough to shape +/// a wall, few enough handles to see which one you are dragging. +const NEW_LATTICE: (u32, u32) = (5, 3); + +/// The most control points a lattice may be given from the UI. +/// +/// 33 x 33 is over a thousand handles, which is already past the point where +/// dragging one is a sensible way to describe a shape; beyond it the overlay +/// costs more than the render underneath it. +pub const MAX_LATTICE: u32 = 33; + +/// Where each of `panel`'s control points lands in the frame, as a fraction of +/// it from the top left, paired with the index it came from. +/// +/// Points behind the camera are dropped rather than clamped: a handle that is +/// not on screen must not be pickable, and a clamped one sits on the edge of the +/// viewport looking exactly like one that is. +pub fn handle_positions(panel: &Panel, camera: &Camera, aspect: f32) -> Vec<(usize, Vec2)> { + panel + .surface + .points() + .iter() + .enumerate() + .filter_map(|(i, local)| Some((i, camera.project(panel.stage_of(*local), aspect)?))) + .collect() +} + +/// The handle nearest `at` within `radius`, all three in the same units. +/// +/// Nearest rather than first: handles overlap in a 3D view, and the one whose +/// centre is closest to the pointer is the one being aimed at. +pub fn nearest_handle(handles: &[(usize, Vec2)], at: Vec2, radius: f32) -> Option { + handles + .iter() + .map(|(i, p)| (*i, (*p - at).length())) + .filter(|(_, d)| *d <= radius) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) } pub struct App { @@ -54,6 +142,10 @@ pub struct App { pub mode: ViewMode, pub selected: Option, + /// Which control point of the selected panel's surface is being edited. + /// Always an index into *that* panel's surface, and cleared whenever the + /// surface it indexes into could have changed shape. + pub selected_point: Option, pub drag: Option, /// The About window, from the Help menu. See about_window.rs, vendored from @@ -107,6 +199,7 @@ impl Default for App { dirty: false, mode: ViewMode::Canvas, selected: None, + selected_point: None, drag: None, show_about: false, zoom: 0.25, @@ -128,6 +221,17 @@ impl Default for App { } impl App { + /// An app with no NDI runtime, for tests — the same state a machine with no + /// runtime installed lands in, which is deliberately a usable one. + #[cfg(test)] + pub fn headless() -> Self { + Self { + ndi: None, + ndi_error: None, + ..Default::default() + } + } + pub fn toast(&mut self, text: impl Into) { self.toasts.push(Toast { text: text.into(), @@ -160,6 +264,7 @@ impl App { pub fn replace_show(&mut self, show: Show, path: Option) { self.receivers.clear(); self.selected = None; + self.selected_point = None; self.drag = None; self.show = show; self.path = path; @@ -274,6 +379,113 @@ impl App { } } + /// The panel the inspector is editing. + pub fn selected_panel(&self) -> Option<&Panel> { + self.selected.as_ref().and_then(|id| self.show.panel(id)) + } + + pub fn panel_index(&self, id: &str) -> Option { + self.show.panels.iter().position(|p| p.id == id) + } + + /// Select a panel, dropping any control point selected on the last one. + /// + /// A point index only means anything against the surface it was picked on, + /// and index 7 of the panel you just left is index 7 of a different shape. + pub fn select_panel(&mut self, id: Option) { + if id != self.selected { + self.selected_point = None; + } + self.selected = id; + } + + /// Convert a panel's surface to `kind`, keeping the shape where the new kind + /// can hold it. `false` if there is no such panel, or nothing to do. + pub fn set_surface_kind(&mut self, panel_id: &str, kind: SurfaceKind) -> bool { + let Some(index) = self.panel_index(panel_id) else { + return false; + }; + let panel = &mut self.show.panels[index]; + if SurfaceKind::of(&panel.surface) == kind { + return false; + } + let size = panel.placement.size; + let surface = match kind { + // Flattening throws the shape away — but so does every other reading + // of "make this panel flat", and the operator asked for it. + SurfaceKind::Flat => Surface::Flat, + SurfaceKind::Arc => Surface::Arc { + sweep_deg: NEW_ARC_SWEEP_DEG, + }, + SurfaceKind::Lattice => { + let (columns, rows) = panel.surface.lattice_dims().unwrap_or(NEW_LATTICE); + match panel.surface.bake_lattice(size, columns, rows) { + Some(s) => s, + // A panel too small to bake against is not a reason to leave + // the operator on a kind they did not choose. + None => Surface::flat_lattice(size, NEW_LATTICE.0, NEW_LATTICE.1) + .unwrap_or(Surface::Flat), + } + } + }; + panel.surface = surface; + self.selected_point = None; + self.dirty = true; + true + } + + /// Resample the panel's lattice to `columns` x `rows`, keeping its shape. + pub fn resize_lattice(&mut self, panel_id: &str, columns: u32, rows: u32) -> bool { + let Some(index) = self.panel_index(panel_id) else { + return false; + }; + let panel = &mut self.show.panels[index]; + let columns = columns.clamp(2, MAX_LATTICE); + let rows = rows.clamp(2, MAX_LATTICE); + if panel.surface.lattice_dims() == Some((columns, rows)) { + return false; + } + let Some(resampled) = panel + .surface + .bake_lattice(panel.placement.size, columns, rows) + else { + return false; + }; + panel.surface = resampled; + // The grid the index counted along is gone. + self.selected_point = None; + self.dirty = true; + true + } + + /// Move one control point to a point in **stage** space — where the pointer + /// is — storing it in the panel's own frame, where the surface lives. + pub fn set_surface_point(&mut self, panel_id: &str, index: usize, stage: Vec3) -> bool { + if !stage.is_finite() { + return false; + } + let Some(i) = self.panel_index(panel_id) else { + return false; + }; + let panel = &mut self.show.panels[i]; + let local = panel.local_of(stage); + if !panel.surface.set_point(index, local) { + return false; + } + self.dirty = true; + true + } + + /// The stage-space position of one of a panel's control points. + pub fn surface_handle(&self, panel_id: &str, index: usize) -> Option { + let panel = self.show.panel(panel_id)?; + panel + .surface + .points() + .get(index) + .map(|p| panel.stage_of(*p)) + } + /// Connect, disconnect and reconnect receivers so they match the show. /// /// Called every frame. Cheap when nothing changed, which is the common case — @@ -442,6 +654,180 @@ mod tests { assert!(app.needs_frame); } + #[test] + fn becoming_an_arc_actually_bends_the_panel() { + let mut app = app_with_panels(); + assert!(app.set_surface_kind("a", SurfaceKind::Arc)); + let panel = app.show.panel("a").unwrap(); + assert_eq!(SurfaceKind::of(&panel.surface), SurfaceKind::Arc); + // A zero-sweep arc would look exactly like the flat panel it replaced. + let ends_z = panel.surface_point(0.0, 0.5).z; + let middle_z = panel.surface_point(0.5, 0.5).z; + assert!( + ends_z < middle_z - 1e-3, + "the arc is flat: {ends_z} vs {middle_z}" + ); + assert!(app.dirty); + + // Asking for the kind it already is changes nothing. + assert!(!app.set_surface_kind("a", SurfaceKind::Arc)); + assert!(!app.set_surface_kind("nonexistent", SurfaceKind::Flat)); + } + + #[test] + fn becoming_a_lattice_keeps_the_arc_it_came_from() { + // The conversion happens mid-edit, in front of the operator: the picture + // must not move on the frame they pick "Lattice". + let mut app = app_with_panels(); + app.set_surface_kind("a", SurfaceKind::Arc); + let before: Vec<_> = (0..=8) + .map(|i| { + app.show + .panel("a") + .unwrap() + .surface_point(i as f32 / 8.0, 0.5) + }) + .collect(); + + assert!(app.set_surface_kind("a", SurfaceKind::Lattice)); + let panel = app.show.panel("a").unwrap(); + assert_eq!(panel.surface.lattice_dims(), Some(NEW_LATTICE)); + for (i, was) in before.iter().enumerate() { + let now = panel.surface_point(i as f32 / 8.0, 0.5); + assert!((now - *was).length() < 5e-3, "u={i}/8: {now:?} vs {was:?}"); + } + } + + #[test] + fn resizing_a_lattice_keeps_its_shape_and_clears_the_stale_selection() { + let mut app = app_with_panels(); + app.set_surface_kind("a", SurfaceKind::Lattice); + let handle = app.surface_handle("a", 7).unwrap(); + app.set_surface_point("a", 7, handle + Vec3::new(0.0, 0.0, 0.4)); + app.selected_point = Some(7); + let pulled = app.show.panel("a").unwrap().surface_point(0.5, 0.5); + + assert!(app.resize_lattice("a", 9, 5)); + let panel = app.show.panel("a").unwrap(); + assert_eq!(panel.surface.lattice_dims(), Some((9, 5))); + assert!( + (panel.surface_point(0.5, 0.5) - pulled).length() < 1e-3, + "the pulled centre moved" + ); + // Index 7 counted along the old grid and means something else on the new one. + assert_eq!(app.selected_point, None); + + // Out-of-range sizes are clamped, not refused into a broken lattice. + app.resize_lattice("a", 0, 9999); + let dims = app.show.panel("a").unwrap().surface.lattice_dims().unwrap(); + assert_eq!(dims, (2, MAX_LATTICE)); + } + + #[test] + fn a_dragged_handle_is_stored_in_the_panels_own_frame() { + // The pointer moves in the stage; the surface is measured in the panel. + // A panel yawed 90 degrees is where that difference stops being invisible. + let mut app = app_with_panels(); + app.show.panels[0].placement.translation = Vec3::new(2.0, 3.0, -1.0); + app.show.panels[0].placement.rotation = + glam::Quat::from_rotation_y(std::f32::consts::FRAC_PI_2); + app.set_surface_kind("a", SurfaceKind::Lattice); + + let target = app.surface_handle("a", 4).unwrap() + Vec3::new(0.3, -0.2, 0.5); + assert!(app.set_surface_point("a", 4, target)); + let back = app.surface_handle("a", 4).unwrap(); + assert!((back - target).length() < 1e-4, "{back:?} vs {target:?}"); + assert!(app.dirty); + + // And the stored point is not simply the stage point written down. + let local = app.show.panel("a").unwrap().surface.points()[4]; + assert!((local - target).length() > 0.1, "stored in the wrong space"); + } + + #[test] + fn a_handle_that_cannot_be_placed_is_refused_rather_than_poisoning_the_surface() { + let mut app = app_with_panels(); + app.set_surface_kind("a", SurfaceKind::Lattice); + // A ray that missed its plane can hand back a NaN; one of those in the + // lattice takes the whole panel out of the render, silently. + assert!(!app.set_surface_point("a", 0, Vec3::new(f32::NAN, 0.0, 0.0))); + assert!(!app.set_surface_point("a", 999, Vec3::ZERO)); + assert!( + !app.set_surface_point("b", 0, Vec3::ZERO), + "b is still flat" + ); + assert!(app + .show + .panel("a") + .unwrap() + .surface + .points() + .iter() + .all(|p| p.is_finite())); + } + + #[test] + fn handles_project_where_the_camera_sees_them_and_never_behind_it() { + let mut app = app_with_panels(); + app.show.panels[0].placement.translation = Vec3::new(0.0, 2.0, 0.0); + app.set_surface_kind("a", SurfaceKind::Lattice); + let panel = app.show.panel("a").unwrap(); + + let camera = unmapper_core::Camera { + position: Vec3::new(0.0, 2.0, 6.0), + target: Vec3::new(0.0, 2.0, 0.0), + ..Default::default() + }; + let handles = handle_positions(panel, &camera, 16.0 / 9.0); + assert_eq!(handles.len(), panel.surface.points().len()); + // The centre control point of a centred panel sits in the middle of frame. + let centre = handles.iter().find(|(i, _)| *i == 7).unwrap().1; + assert!((centre - Vec2::new(0.5, 0.5)).length() < 1e-3, "{centre:?}"); + + // Standing inside the wall, looking away: nothing is pickable. + let behind = unmapper_core::Camera { + position: Vec3::new(0.0, 2.0, -1.0), + target: Vec3::new(0.0, 2.0, -9.0), + ..Default::default() + }; + assert!(handle_positions(panel, &behind, 16.0 / 9.0).is_empty()); + } + + #[test] + fn picking_takes_the_nearest_handle_inside_the_radius_and_none_outside_it() { + let handles = vec![ + (3, Vec2::new(0.50, 0.50)), + (4, Vec2::new(0.52, 0.50)), + (5, Vec2::new(0.90, 0.90)), + ]; + // Overlapping handles: the one whose centre is closest is the one aimed at. + assert_eq!( + nearest_handle(&handles, Vec2::new(0.515, 0.50), 0.05), + Some(4) + ); + assert_eq!( + nearest_handle(&handles, Vec2::new(0.495, 0.50), 0.05), + Some(3) + ); + // Empty space starts an orbit instead, which is what None means here. + assert_eq!(nearest_handle(&handles, Vec2::new(0.10, 0.10), 0.05), None); + assert_eq!(nearest_handle(&[], Vec2::new(0.5, 0.5), 0.05), None); + } + + #[test] + fn changing_panel_drops_the_point_selected_on_the_last_one() { + let mut app = app_with_panels(); + app.select_panel(Some("a".into())); + app.selected_point = Some(4); + // Re-selecting the same panel is not a change and must not fight the drag. + app.select_panel(Some("a".into())); + assert_eq!(app.selected_point, Some(4)); + + app.select_panel(Some("b".into())); + assert_eq!(app.selected_point, None); + assert_eq!(app.selected.as_deref(), Some("b")); + } + #[test] fn the_title_marks_unsaved_changes() { let mut app = app_with_panels(); diff --git a/crates/unmapper-gui/src/ui.rs b/crates/unmapper-gui/src/ui.rs index 9a44b4c..1a24b41 100644 --- a/crates/unmapper-gui/src/ui.rs +++ b/crates/unmapper-gui/src/ui.rs @@ -4,12 +4,26 @@ use std::path::PathBuf; use egui::{Color32, RichText}; use unmapper_core::{ - Output, OutputTarget, OutputView, Rect, Severity, Show, Size, SourceKind, Vec2, + Camera, Output, OutputTarget, OutputView, Panel, Rect, Severity, Show, Size, SourceKind, + Surface, Vec2, Vec3, }; use crate::outputs::MonitorInfo; -use crate::state::{App, Drag, ViewMode}; +use crate::state::{ + handle_positions, nearest_handle, App, Drag, SurfaceKind, ViewMode, MAX_LATTICE, +}; + +/// How close the pointer must come to a control point to grab it, in points. +/// +/// Generous next to the 4-point dot it picks: handles are small on purpose, and +/// a wall seen edge-on stacks a whole column of them within a few pixels. +const HANDLE_PICK_RADIUS: f32 = 10.0; + +/// The selected panel's surface, drawn over the previz image. +const WIRE: Color32 = Color32::from_rgb(110, 190, 255); +const HANDLE: Color32 = Color32::from_rgb(230, 240, 255); +const HANDLE_SELECTED: Color32 = Color32::from_rgb(255, 170, 60); /// What the UI is asking the host to do, when it cannot do it itself. #[derive(Default)] @@ -836,7 +850,7 @@ pub fn inspector_panel(ui: &mut egui::Ui, app: &mut App) { return; }; let Some(index) = app.show.panels.iter().position(|p| p.id == id) else { - app.selected = None; + app.select_panel(None); return; }; @@ -941,6 +955,8 @@ pub fn inspector_panel(ui: &mut egui::Ui, app: &mut App) { } } + changed |= surface_section(ui, app, &id); + ui.separator(); if ui .button("Re-derive stage position from canvas layout") @@ -962,6 +978,295 @@ pub fn inspector_panel(ui: &mut egui::Ui, app: &mut App) { }); } +/// The surface designer: what shape this panel's LED surface is, and the +/// controls for that shape. +/// +/// A panel is flat until someone says otherwise, and most stay that way — so +/// this section is the one place in the inspector that is usually a single row. +/// Everything below the kind picker belongs to the kind that is chosen. +fn surface_section(ui: &mut egui::Ui, app: &mut App, id: &str) -> bool { + let Some(index) = app.panel_index(id) else { + return false; + }; + let mut changed = false; + + ui.separator(); + ui.label(RichText::new("Surface shape").strong()); + + let kind = SurfaceKind::of(&app.show.panels[index].surface); + let mut wanted = kind; + ui.horizontal(|ui| { + for k in [SurfaceKind::Flat, SurfaceKind::Arc, SurfaceKind::Lattice] { + ui.selectable_value(&mut wanted, k, k.label()); + } + }); + if wanted != kind { + changed |= app.set_surface_kind(id, wanted); + } + + match SurfaceKind::of(&app.show.panels[index].surface) { + SurfaceKind::Flat => { + ui.label( + RichText::new("A rigid flat tile — what one physical panel is.") + .weak() + .small(), + ); + } + SurfaceKind::Arc => changed |= arc_controls(ui, app, index), + SurfaceKind::Lattice => changed |= lattice_controls(ui, app, id, index), + } + + changed +} + +fn arc_controls(ui: &mut egui::Ui, app: &mut App, index: usize) -> bool { + let size = app.show.panels[index].placement.size; + let Surface::Arc { sweep_deg } = app.show.panels[index].surface else { + return false; + }; + + let mut sweep = sweep_deg; + let mut changed = false; + ui.horizontal(|ui| { + ui.label("Sweep°"); + changed |= ui + .add( + egui::DragValue::new(&mut sweep) + .speed(0.5) + .range(-180.0..=180.0), + ) + .on_hover_text("Positive sweeps both ends away from the audience") + .changed(); + }); + + // The sweep is the shape; these are the numbers that can be checked against + // a drawing, which is how anyone finds out the sweep is wrong. + match (Surface::Arc { sweep_deg: sweep }).arc_metrics(size) { + Some(m) => { + ui.label( + RichText::new(format!( + "radius {:.2} m · chord {:.2} m · depth {:.2} m", + m.radius, m.chord, m.depth + )) + .weak() + .small(), + ); + } + None => { + ui.label( + RichText::new("straight — the ends have not been swept yet") + .weak() + .small(), + ); + } + } + + if changed { + app.show.panels[index].surface = Surface::Arc { sweep_deg: sweep }; + app.dirty = true; + } + changed +} + +fn lattice_controls(ui: &mut egui::Ui, app: &mut App, id: &str, index: usize) -> bool { + let Some((columns, rows)) = app.show.panels[index].surface.lattice_dims() else { + return false; + }; + let size = app.show.panels[index].placement.size; + let mut changed = false; + + let (mut c, mut r) = (columns, rows); + egui::Grid::new("lattice").num_columns(2).show(ui, |ui| { + ui.label("Columns"); + ui.add( + egui::DragValue::new(&mut c) + .speed(0.1) + .range(2..=MAX_LATTICE), + ); + ui.end_row(); + ui.label("Rows"); + ui.add( + egui::DragValue::new(&mut r) + .speed(0.1) + .range(2..=MAX_LATTICE), + ); + ui.end_row(); + }); + if (c, r) != (columns, rows) { + // Resampled, not rebuilt: changing the grid keeps the shape it already has. + changed |= app.resize_lattice(id, c, r); + } + + ui.label( + RichText::new("Drag the points in the Previz view.") + .weak() + .small(), + ); + + let (columns, rows) = app.show.panels[index] + .surface + .lattice_dims() + .unwrap_or((columns, rows)); + + match app.selected_point { + Some(i) if i < app.show.panels[index].surface.points().len() => { + let local = app.show.panels[index].surface.points()[i]; + let (col, row) = (i as u32 % columns, i as u32 / columns); + ui.label( + RichText::new(format!("Point — column {}, row {}", col + 1, row + 1)).strong(), + ); + + let mut p = local; + egui::Grid::new("surface point") + .num_columns(2) + .show(ui, |ui| { + for (axis, value) in [("X", &mut p.x), ("Y", &mut p.y), ("Z", &mut p.z)] { + ui.label(axis); + changed |= ui.add(egui::DragValue::new(value).speed(0.01)).changed(); + ui.end_row(); + } + }); + ui.label( + RichText::new("panel-local metres · +Z towards the audience") + .weak() + .small(), + ); + + if changed && p != local { + app.show.panels[index].surface.set_point(i, p); + app.dirty = true; + } + + if ui.button("Reset this point").clicked() { + let u = col as f32 / (columns - 1).max(1) as f32; + let v = row as f32 / (rows - 1).max(1) as f32; + let flat = Surface::Flat.local_point(u, v, size); + app.show.panels[index].surface.set_point(i, flat); + app.dirty = true; + changed = true; + } + } + _ => { + // Including a stale index: the surface can be resampled from under a + // selection made against the old grid. + app.selected_point = None; + ui.label( + RichText::new("No point selected — click one in the Previz view.") + .weak() + .small(), + ); + } + } + + if ui + .button("Flatten") + .on_hover_text("Put every point back on the panel's plane, keeping the grid") + .clicked() + { + if let Some(flat) = Surface::flat_lattice(size, columns, rows) { + app.show.panels[index].surface = flat; + app.dirty = true; + changed = true; + } + } + + changed +} + +/// Where each of the selected panel's control points lands on screen, in egui +/// points — what both the painter and the pointer are measured in. +fn screen_handles( + panel: &Panel, + camera: &Camera, + aspect: f32, + rect: egui::Rect, +) -> Vec<(usize, Vec2)> { + handle_positions(panel, camera, aspect) + .into_iter() + .map(|(i, uv)| { + ( + i, + Vec2::new( + rect.left() + uv.x * rect.width(), + rect.top() + uv.y * rect.height(), + ), + ) + }) + .collect() +} + +/// Draw the selected panel's surface over the previz image: the shape as a +/// wireframe, its control points as handles. +/// +/// Deliberately depth-less. The overlay is painted flat over the finished image, +/// so a handle behind the set model still shows — hiding those would look more +/// correct and be unusable, because the point you most need to pull is routinely +/// the one tucked behind a truss. +fn paint_surface_overlay( + painter: &egui::Painter, + panel: &Panel, + camera: &Camera, + aspect: f32, + rect: egui::Rect, + handles: &[(usize, Vec2)], + selected_point: Option, +) { + let at = |u: f32, v: f32| { + camera.project(panel.surface_point(u, v), aspect).map(|uv| { + egui::pos2( + rect.left() + uv.x * rect.width(), + rect.top() + uv.y * rect.height(), + ) + }) + }; + + // The shape's own subdivision, so the wireframe is the geometry the renderer + // draws rather than a smooth guess laid over a faceted panel. Capped: an arc + // can ask for 128 segments and this is a hint, not a second render. + let (cols, rows) = panel.subdivisions(); + let (cols, rows) = (cols.clamp(1, 64), rows.clamp(1, 64)); + let stroke = egui::Stroke::new(1.0, WIRE.gamma_multiply(0.7)); + + let line = |a: Option, b: Option| { + // A segment with an end behind the camera is dropped whole: interpolating + // to the near plane is a lot of work to draw a line nobody can act on. + if let (Some(a), Some(b)) = (a, b) { + painter.line_segment([a, b], stroke); + } + }; + for r in 0..=rows { + let v = r as f32 / rows as f32; + for c in 0..cols { + line( + at(c as f32 / cols as f32, v), + at((c + 1) as f32 / cols as f32, v), + ); + } + } + for c in 0..=cols { + let u = c as f32 / cols as f32; + for r in 0..rows { + line( + at(u, r as f32 / rows as f32), + at(u, (r + 1) as f32 / rows as f32), + ); + } + } + + for (i, pos) in handles { + let pos = egui::pos2(pos.x, pos.y); + let selected = selected_point == Some(*i); + let radius = if selected { 6.0 } else { 4.0 }; + painter.circle_filled(pos, radius, if selected { HANDLE_SELECTED } else { HANDLE }); + // A dark ring, because a white dot on a white panel is not a handle. + painter.circle_stroke( + pos, + radius, + egui::Stroke::new(1.0, Color32::from_black_alpha(180)), + ); + } +} + /// The central viewport. Returns the rect the render target should be drawn into. pub fn viewport( ui: &mut egui::Ui, @@ -997,7 +1302,7 @@ pub fn viewport( match app.mode { ViewMode::Canvas => canvas_interaction(ui, app, rect, &response, target_size), - ViewMode::Previz => previz_interaction(app, &response, ui), + ViewMode::Previz => previz_interaction(app, &response, ui, rect, target_size), } }); @@ -1044,7 +1349,7 @@ fn canvas_interaction( .panel(&id) .map(|p| Vec2::new(p.layout.x, p.layout.y)) .unwrap_or(Vec2::ZERO); - app.selected = Some(id.clone()); + app.select_panel(Some(id.clone())); app.drag = Some(Drag::Panel { id, grab: canvas - origin, @@ -1068,7 +1373,8 @@ fn canvas_interaction( let d = response.drag_delta(); app.pan -= Vec2::new(d.x, d.y) / app.zoom; } - None => {} + // A surface handle belongs to the previz view; nothing to do here. + _ => {} } } @@ -1080,26 +1386,134 @@ fn canvas_interaction( if response.clicked() { if let Some(pointer) = response.interact_pointer_pos() { let canvas = to_canvas(app, rect, pointer); - app.selected = app.panel_at(canvas); + let hit = app.panel_at(canvas); + app.select_panel(hit); } } } -fn previz_interaction(app: &mut App, response: &egui::Response, ui: &egui::Ui) { +fn previz_interaction( + app: &mut App, + response: &egui::Response, + ui: &egui::Ui, + rect: egui::Rect, + target: (u32, u32), +) { + let camera = app.previz_camera(); + // The aspect the image was rendered at, not the rect's — they agree, and + // reading it from the target is what keeps the overlay honest if they ever + // stop agreeing again. + let aspect = target.0.max(1) as f32 / target.1.max(1) as f32; + + let handles = match app.selected_panel() { + Some(panel) => screen_handles(panel, &camera, aspect, rect), + None => Vec::new(), + }; + let uv_at = |p: egui::Pos2| { + Vec2::new( + (p.x - rect.left()) / rect.width().max(1.0), + (p.y - rect.top()) / rect.height().max(1.0), + ) + }; + + if response.drag_started() { + // Where the button went *down*, not where the pointer is now: by the + // frame egui calls this a drag the pointer has already left the handle, + // and hit-testing the current position picks nothing at all. + let origin = ui + .input(|i| i.pointer.press_origin()) + .or_else(|| response.interact_pointer_pos()); + // A drag that starts on a handle pulls it; anywhere else orbits, so the + // view stays navigable with a panel selected. + let picked = + origin.and_then(|p| nearest_handle(&handles, Vec2::new(p.x, p.y), HANDLE_PICK_RADIUS)); + match (picked, app.selected.clone(), origin) { + (Some(index), Some(panel), Some(pointer)) => { + // Grab where it was taken hold of, not by its centre: a handle + // that jumps under the cursor on the first frame has already + // moved the wall before the operator has done anything. + let grab = app + .surface_handle(&panel, index) + .and_then(|handle| { + camera + .ray(uv_at(pointer), aspect) + .intersect_plane(handle, camera.forward()) + .map(|hit| handle - hit) + }) + .unwrap_or(Vec3::ZERO); + app.selected_point = Some(index); + app.drag = Some(Drag::SurfacePoint { panel, index, grab }); + } + _ => app.drag = None, + } + } + if response.dragged() { - let d = response.drag_delta(); - app.orbit_yaw -= d.x * 0.01; - // Stop just short of straight up: at exactly vertical the view matrix's - // up vector becomes parallel to the view direction and the image flips. - const LIMIT: f32 = std::f32::consts::FRAC_PI_2 - 0.02; - app.orbit_pitch = (app.orbit_pitch + d.y * 0.01).clamp(-LIMIT, LIMIT); + match &app.drag { + Some(Drag::SurfacePoint { panel, index, grab }) => { + let (panel, index, grab) = (panel.clone(), *index, *grab); + if let (Some(pointer), Some(handle)) = ( + response.interact_pointer_pos(), + app.surface_handle(&panel, index), + ) { + // Drag in the plane through the handle that faces the camera: + // the one plane where the pointer and the handle move + // together at every angle, so nothing runs away at a + // glancing view. + if let Some(hit) = camera + .ray(uv_at(pointer), aspect) + .intersect_plane(handle, camera.forward()) + { + app.set_surface_point(&panel, index, hit + grab); + } + } + } + _ => { + let d = response.drag_delta(); + app.orbit_yaw -= d.x * 0.01; + // Stop just short of straight up: at exactly vertical the view + // matrix's up vector becomes parallel to the view direction and + // the image flips. + const LIMIT: f32 = std::f32::consts::FRAC_PI_2 - 0.02; + app.orbit_pitch = (app.orbit_pitch + d.y * 0.01).clamp(-LIMIT, LIMIT); + } + } + } + + if response.drag_stopped() { + app.drag = None; } + + // A click selects a handle, or clears the selection by missing every one. + if response.clicked() { + if let Some(pointer) = response.interact_pointer_pos() { + app.selected_point = nearest_handle( + &handles, + Vec2::new(pointer.x, pointer.y), + HANDLE_PICK_RADIUS, + ); + } + } + if response.hovered() { let scroll = ui.input(|i| i.smooth_scroll_delta.y); if scroll.abs() > 0.01 { app.orbit_distance = (app.orbit_distance * (1.0 - scroll * 0.002)).clamp(0.5, 500.0); } } + + // Painted last, and after the image the viewport drew, so it lands on top. + if let Some(panel) = app.selected_panel() { + paint_surface_overlay( + ui.painter(), + panel, + &camera, + aspect, + rect, + &handles, + app.selected_point, + ); + } } /// Turn an imported slice map into a show, keeping placement work if one is @@ -1126,3 +1540,201 @@ pub fn apply_import(app: &mut App, map: unmapper_core::SliceMap, path: PathBuf, app.error(w); } } +#[cfg(test)] +mod tests { + use super::*; + use unmapper_core::Panel; + + /// The previz image's size in pixels, and — since the viewport is the only + /// thing in these frames — the window's. + const VIEW: (u32, u32) = (800, 600); + + /// Drive the widgets with no window, no GPU and no NDI. + /// + /// egui needs neither: a `Context` fed `RawInput` runs the same interaction + /// code a real pointer does. This is the only way anything in this file gets + /// *clicked* on this machine, and the drag it exercises — pick a control + /// point, pull it, watch the surface change — is the whole feature. + fn frame(ctx: &egui::Context, app: &mut App, events: Vec) -> egui::Rect { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::pos2(0.0, 0.0), + egui::vec2(VIEW.0 as f32, VIEW.1 as f32), + )), + events, + ..Default::default() + }; + let mut painted = egui::Rect::NOTHING; + let _ = ctx.run_ui(input, |ui| { + painted = viewport(ui, app, egui::TextureId::Managed(0), VIEW); + }); + painted + } + + fn press(pos: egui::Pos2) -> Vec { + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }, + ] + } + + fn release(pos: egui::Pos2) -> Vec { + vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }] + } + + /// One 2.6 x 1.3 m panel, face on, six metres away, its surface a lattice — + /// a wall filling enough of the frame that its handles are metres apart. + fn previz_app() -> App { + let mut app = App::headless(); + app.show.panels.push(Panel::from_layout( + "a", + "A", + Size::new(1000, 500), + Rect::new(0.0, 0.0, 1000.0, 500.0), + 2.6, + )); + app.mode = ViewMode::Previz; + app.select_panel(Some("a".into())); + assert!(app.set_surface_kind("a", SurfaceKind::Lattice)); + app.orbit_yaw = 0.0; + app.orbit_pitch = 0.0; + app.orbit_distance = 6.0; + app.dirty = false; + app + } + + /// Where a control point is on screen, by the same route the overlay draws it. + fn handle_on_screen(app: &App, rect: egui::Rect, index: usize) -> egui::Pos2 { + let camera = app.previz_camera(); + let aspect = VIEW.0 as f32 / VIEW.1 as f32; + let handles = screen_handles(app.selected_panel().unwrap(), &camera, aspect, rect); + let at = handles + .iter() + .find(|(i, _)| *i == index) + .unwrap_or_else(|| panic!("point {index} is not on screen")) + .1; + egui::pos2(at.x, at.y) + } + + #[test] + fn dragging_a_control_point_in_previz_moves_the_surface_under_the_pointer() { + let ctx = egui::Context::default(); + let mut app = previz_app(); + + // A first pass to lay the viewport out, then find the middle handle. + let rect = frame(&ctx, &mut app, Vec::new()); + const CENTRE: usize = 7; // 5 x 3 lattice, middle row, middle column. + let start = handle_on_screen(&app, rect, CENTRE); + let before = app.surface_handle("a", CENTRE).unwrap(); + + // Grab it a couple of points off centre — the pick radius is generous, + // and a handle that snaps itself under the cursor has already moved the + // wall before the operator has done anything. + let grabbed = start + egui::vec2(3.0, -2.0); + frame(&ctx, &mut app, press(grabbed)); + let dragged_to = grabbed + egui::vec2(40.0, 20.0); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(dragged_to)]); + + let after = app.surface_handle("a", CENTRE).unwrap(); + assert_eq!(app.selected_point, Some(CENTRE)); + assert!(app.dirty, "dragging a point is an edit"); + + // Screen right is +X and screen down is -Y for a camera looking along -Z. + assert!(after.x > before.x + 0.05, "{before:?} -> {after:?}"); + assert!(after.y < before.y - 0.02, "{before:?} -> {after:?}"); + // The drag plane faces the camera, so depth is the one thing it must not + // change: a point that wandered in Z would push the wall through the set. + assert!((after.z - before.z).abs() < 1e-3, "{before:?} -> {after:?}"); + + // It went where it was put, not merely somewhere: the handle ends up + // under the pointer, offset by exactly the grab it was taken hold of by. + frame(&ctx, &mut app, release(dragged_to)); + let landed = handle_on_screen(&app, rect, CENTRE); + let wanted = start + (dragged_to - grabbed); + assert!( + (landed - wanted).length() < 2.0, + "handle landed at {landed:?}, wanted {wanted:?}" + ); + assert!(app.drag.is_none(), "the drag should end with the button"); + + // And only that point moved. + let corner = app.surface_handle("a", 0).unwrap(); + let flat = app.show.panel("a").unwrap().placement.corners()[0]; + assert!((corner - flat).length() < 1e-4, "the corner moved too"); + } + + #[test] + fn dragging_off_a_handle_orbits_the_camera_and_leaves_the_surface_alone() { + let ctx = egui::Context::default(); + let mut app = previz_app(); + let rect = frame(&ctx, &mut app, Vec::new()); + let before = app.show.panel("a").unwrap().surface.points().to_vec(); + let yaw = app.orbit_yaw; + + // The top-left corner of the viewport: a long way from any handle. + let empty = rect.min + egui::vec2(12.0, 12.0); + frame(&ctx, &mut app, press(empty)); + frame( + &ctx, + &mut app, + vec![egui::Event::PointerMoved(empty + egui::vec2(60.0, 0.0))], + ); + + assert!( + (app.orbit_yaw - yaw).abs() > 0.1, + "the view should have orbited" + ); + assert_eq!(app.show.panel("a").unwrap().surface.points(), before); + assert!(!app.dirty, "orbiting is not an edit"); + } + + #[test] + fn clicking_selects_a_handle_and_clicking_away_clears_it() { + let ctx = egui::Context::default(); + let mut app = previz_app(); + let rect = frame(&ctx, &mut app, Vec::new()); + let at = handle_on_screen(&app, rect, 2); + + frame(&ctx, &mut app, press(at)); + frame(&ctx, &mut app, release(at)); + assert_eq!(app.selected_point, Some(2)); + + let empty = rect.min + egui::vec2(12.0, 12.0); + frame(&ctx, &mut app, press(empty)); + frame(&ctx, &mut app, release(empty)); + assert_eq!(app.selected_point, None); + assert!(!app.dirty, "clicking about is not an edit"); + } + + #[test] + fn a_flat_panel_has_no_handles_to_grab() { + // Every rig in existence is flat, and the previz view has to stay an + // orbit-and-look view for all of them. + let ctx = egui::Context::default(); + let mut app = previz_app(); + assert!(app.set_surface_kind("a", SurfaceKind::Flat)); + app.dirty = false; + let rect = frame(&ctx, &mut app, Vec::new()); + + let middle = rect.center(); + frame(&ctx, &mut app, press(middle)); + frame( + &ctx, + &mut app, + vec![egui::Event::PointerMoved(middle + egui::vec2(40.0, 0.0))], + ); + assert_eq!(app.selected_point, None); + assert!(!app.dirty); + assert!(app.orbit_yaw.abs() > 0.1, "it should have orbited instead"); + } +} diff --git a/docs/NOTES.md b/docs/NOTES.md index 1722d04..b992e7e 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -40,8 +40,9 @@ Lossless round trip, byte-identical on re-save. 7.27 files; NDI receive from a live 6.3.2 sender at 1920x1080 RGBA / 50 fps / 0 drops; both render paths by GPU pixel readback; the whole chain end to end via the CLI; the **GUI running with live NDI in its viewport**; and **display output** -— two output windows each showing the correct half of the canvas, live. 90 tests, -clippy clean. +— two output windows each showing the correct half of the canvas, live. 153 tests; +clippy clean but for a few pre-existing warnings in `unmapper-render` and the +vendored About window. The canvas is rendered **once** per frame and everything else (viewport, every output window) is a *crop* of it — so two monitors structurally cannot show @@ -49,10 +50,18 @@ different frames. Output blits sample **NEAREST** on purpose: one canvas pixel i one LED, and a linear filter would hide a region/monitor size mismatch behind a plausible blur. -**Partly verified:** the GUI's Previz tab, drag-to-place, file dialogs and rescan -button have never been *clicked* — osascript has no assistive access on this Mac. -The logic under them is unit-tested in `unmapper-gui/src/state.rs`; the widgets -are not. See **screenshot capture** (working-practice note, kept in Claude memory). +**The widgets can be clicked after all — headlessly.** `egui::Context::run_ui` +takes a `RawInput`, so feeding it `PointerMoved` / `PointerButton` events drives +the real widget code with no window, no GPU and no NDI. The surface designer's +whole pointer path — pick a control point, pull it, watch the surface change, and +orbit when the drag starts anywhere else — is tested that way in +`unmapper-gui/src/ui.rs`, and it caught the `press_origin` bug above on its first +run. Worth reaching for in any egui app in the fleet. + +**Still not clicked:** drag-to-place on the emulation canvas, the file dialogs and +the rescan button — osascript has no assistive access on this Mac, and the dialogs +are OS windows rather than egui widgets, so the trick above does not reach them. +See **screenshot capture** (working-practice note, kept in Claude memory). **Geometry, both built:** a **2D backdrop** mockup (viewport only — it is an editing aid and must never reach an output; `build_viewport_scene` vs @@ -69,10 +78,40 @@ layout Advanced Output and LED processors want to see. Previs only — it never talks to live hardware. Built in two phases, **both done 2026-08-03**: the Resolume warp lattice, then non-planar panel surfaces (`Surface::Flat` / `Arc` / `Lattice`) — the latter is where 3D topology comes from, since a 2D lattice can -only deform in-plane. **No GUI for editing a surface yet** — hand-write it into -the stage XML. The two halves meet: the lattice removes Resolume's +only deform in-plane. Shapes are edited in the GUI's **surface +designer**, built 2026-08-31 — see below. The two halves meet: the lattice removes Resolume's pre-distortion, the surface puts back the shape it was compensating for. +**Surface designer, built 2026-08-31.** The inspector picks the kind — Flat, Arc +or Lattice — and the Previz view drags a lattice's control points about. Three +decisions in it are worth keeping: + +- **Conversions sample the old shape** (`Surface::bake_lattice`) rather than + rebuilding it from parameters, so switching an arc to a lattice mid-edit does + not move the picture, and changing the grid size keeps the shape it had. + Re-baking a lattice at its own size is the identity, which matters because the + columns and rows spinners re-bake on every change. +- **Handles are picked from `press_origin`**, not from where the pointer is when + egui decides a press has become a drag. By that frame the pointer has already + left the handle, and hit-testing the live position picks nothing at all — the + handles were effectively ungrabbable until this was fixed. Found by the + headless test below, before the app was ever run. +- **A handle drags in the plane through it that faces the camera**, so depth is + the one thing a pull cannot change. Any other plane lets a point run away at a + glancing view, and pushes the wall through the set. + +The arc controls report **radius, chord and depth** beside the sweep, because a +sweep is what the shape *is* but those are what can be checked against a drawing. + +**The viewport was rendered at the window's size and squashed into the rect that +shows it.** `viewport_size_hint` returned the whole surface size while egui paints +that texture into the central panel — narrower by both side panels — so previz ran +at the wrong aspect and the emulation view's zoom did not mean what it said. +Neither reads as an error; it is just geometry that is quietly wrong. It had to go +before handles could be dragged, because an overlay computed from the true camera +lands nowhere near a stretched image. Now sized from the rect egui actually +painted, which the app already publishes as `App::viewport_px`. + **Two traps found doing it.** `Quad::projective_weights` is only valid quad→texture (what the shader does); reusing it to get a *position* from (u,v) is **not projective at all** and lands whole pixels out on a keystone — diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 60d9850..d7317f1 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -125,6 +125,37 @@ rotated inside a rig group arrives where the file says it is. --- +## Panel shapes + +A panel is a **flat** rectangle unless you say otherwise, and most are — one physical LED tile is +rigid and flat. But UnMapper imports **one panel per slice**, and a slice routinely covers a whole +run of tiles: a curved upstage wall, a wrapped column, a folded corner. Those are exactly the rigs +the packed Advanced Output layout hides, and showing them flat is the thing previz is meant to fix. + +Select a panel and pick its shape in the inspector. + +**Arc** bends the panel about its vertical centre line. `Sweep°` is the total angle it subtends — +**positive sweeps both ends away from the audience**, which is the common concave wrap; negative +bulges towards them. The panel's **width is preserved as arc length**, so curving a wall does not +silently make it narrower, and the radius, chord and depth are reported beside the sweep so you +can check the shape against a drawing. + +**Lattice** is the escape hatch for a shape no parameter describes — a fold, a stepped run, +something someone measured. Pick the number of columns and rows, then **drag the points in the +Previz view**: click one to select it, drag it to move it. A dragged point moves in the plane +facing the camera, so **orbit first, then drag** — the view you pull from is the view you pull in. +Fine values go in the inspector's X/Y/Z boxes, in panel-local metres with +Z towards the audience. + +> **Changing shape keeps the shape.** Switching an arc to a lattice samples the arc, so the picture +> does not move; changing a lattice's columns or rows resamples it rather than starting again. +> Only **Flatten** and **Flat** throw the shape away. + +> **A curved surface never reaches the emulation canvas.** Emulation stays flat and pixel-exact — +> one canvas pixel per LED — because that canvas is what stands in for the wall on a bank of +> monitors. Shape is previz's business, and only previz's. + +--- + ## Outputs The canvas is rendered **once** per frame at full resolution; every output then blits the region