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