diff --git a/Cargo.lock b/Cargo.lock index 84e6a07..202b18f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11418,7 +11418,7 @@ dependencies = [ [[package]] name = "varda" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "arc-swap", diff --git a/docs/08-projection.md b/docs/08-projection.md index bf6a663..3e45cde 100644 --- a/docs/08-projection.md +++ b/docs/08-projection.md @@ -22,6 +22,7 @@ Surfaces let you place content in specific regions of the output. Use them when - **▭ Rectangle** (R) — drag out a rectangle - **⬠ Polygon** (P) — click to place vertices, double-click to finish - **⬤ Circle** (C) — circle / N-gon with an interactive radius handle + - **✒ Bezier** — curve or straighten a surface edge, and drag bezier anchors/handles 3. Set each surface's **content source**: - **Master** — the full mixer output - **Channel** — a single channel's output @@ -41,20 +42,99 @@ Surfaces let you place content in specific regions of the output. Use them when - **H / V** — Flip horizontal / vertical - **🔗 Combine** (G) — merge selected surfaces into one -### Combining Surfaces (Multi-Contour) +Hit-testing uses the raw cursor position, so vertices and edges stay grabbable even after they have been moved off the grid (e.g. by a gizmo scale or rotate). Snap-to-grid still applies to where a dragged vertex is placed. -Select two or more surfaces and click **🔗 Combine** (G) to merge them into a single surface. Varda computes a polygon union: overlapping regions fuse into one outline, while disjoint regions are kept as **extra contours** on the combined surface. All contours share the same content source, color, and warp — useful for treating several separate shapes (e.g. a row of panels) as one routing target. +### Transform Gizmo + +Selecting one or more surfaces in **Select** mode shows a transform gizmo around the selection: + +- **Corner / edge handles** — scale the selection (the opposite handle acts as the pivot) +- **Rotation knob** (above the top edge) — rotate the selection around its centre + +The gizmo box is inset from the surface's own corner vertices, so scaling/rotating and vertex editing don't fight over the same clicks. -### Corner-Pin Warp +### Bezier Curve Editing -Each surface assigned to an output gets independent corner-pin warp calibration: +Switch to the **✒ Bezier** tool to author curved surface edges: -1. On the output, click **🔧 Calibrate** to enter calibration mode (the output shows test cards instead of live content — one color per surface) -2. Drag the 4 corner handles (TL, TR, BR, BL) in the warp canvas to align the projected image with the physical surface -3. Varda computes a perspective-correct homography (DLT 3×3) for accurate UV mapping -4. Click **🔧 Done** to exit. Use **↺** to reset a surface's warp to identity. +- **Click an edge** to toggle it between a straight line and a cubic bezier (click again to straighten) +- **Drag an anchor** (an edge endpoint) to reshape the outline; adjacent handles follow to preserve curvature +- **Drag a control handle** (the small dots on the connector lines of a curved edge) for fine curve control + +Bezier edits work in raw, un-snapped coordinates so handles move with full sub-grid precision. The curve is flattened back into the surface's polygon vertices, which remain the single source of truth for routing and warp. Once a surface has an active curve path, edit its geometry with the Bezier tool rather than by dragging flattened vertices in Select mode. + +### Combining Surfaces (Multi-Contour) + +Select two or more surfaces and click **🔗 Combine** (G) to merge them into a single surface. Varda computes a polygon union: overlapping regions fuse into one outline, while disjoint regions are kept as **extra contours** on the combined surface. All contours share the same content source, color, and warp — useful for treating several separate shapes (e.g. a row of panels) as one routing target. -Test cards include colored grids, crosshairs, and corner/edge markers for alignment. +### Warp Calibration + +Warp is a property of the **surface** — one surface owns one warp. Edit it in the +**stage editor's bottom detail bar**: open the Stage Editor and select a single +surface; the bottom bar shows that surface's warp editor. + +By default the warp is **🔗 bound to the surface shape** (auto-warp): the grid +conforms to the surface's polygon or circle outline and follows it as you edit +the shape. Uncheck **🔗 Bind to shape** to unbind — this freezes the current +shape-conforming grid as an editable starting point and unlocks the manual +controls below for fine-tuning. Re-checking it re-derives the grid from the +shape (discarding manual edits). + +1. Uncheck **🔗 Bind to shape** to enable manual editing +2. Select a surface on the stage; the bottom bar shows its warp grid +3. Drag the 4 corner handles (TL, TR, BR, BL) to align the projected image with + the physical surface +4. Varda computes a perspective-correct homography (DLT 3×3) for accurate UV mapping +5. Click **↺ Reset** to clear the surface's warp (back to native position) + +To align the projectors themselves, use the per-output **🔧 Calibrate** selector: + +- **Off** — normal content +- **Projector** — one full-frame test card fills the whole output, bypassing + surface geometry, for physical projector alignment (focus, lens, keystone) +- **Surfaces** — each surface shows a colored test card through its own warp, for + verifying surface mapping. Test cards include colored grids, crosshairs, and + corner/edge markers. + +#### Mesh warp (interior control points) + +A 4-corner pin is linear — it can keystone a flat surface but cannot correct a +bulge in the middle (a cylinder, a bowed wall, a draped cloth). For that, raise +the surface's grid resolution above 2×2 using the **grid − / +** steppers in the +bottom-bar warp editor. This promotes the corner-pin into an N×M mesh, preserving +the current shape, and adds draggable interior points: + +1. Set the grid columns and rows with the steppers (each starts at 2×2; the UI + allows up to 16×16, the engine up to 64×64) +2. Drag any grid point — corner, edge, or interior — to warp the image locally +3. Interior points let you pull the texture to match non-flat geometry +4. Use **↺ Reset** to clear the warp + +#### Bezier (curved) warp + +A straight mesh grid approximates a curve with many small facets. For genuinely +smooth deformation (a cylinder, a bowed wall, a draped cloth) click **〰 Curve** +in the bottom-bar warp editor (available while unbound). This converts the warp +into a **bezier patch grid** — the same control layout, but each cell edge is a +cubic bezier with tangent handles: + +1. Click **〰 Curve** — the current warp becomes a bezier cage of the same shape +2. Drag an **anchor** (cyan dot) to move a grid point; its tangent handles follow + so the local curvature is preserved +3. Drag a **handle** (yellow square) to bend the adjacent edges smoothly +4. Use the **cage − / +** steppers to add or remove control points (anchor rows + and columns); the surface resamples onto the new cage +5. The faint grid shows the tessellated result the projector actually renders +6. Click **⊞ Grid** to convert back to a straight mesh warp, or **↺ Reset** to clear + +Under the hood the bezier cage is tessellated into a dense warp mesh, so the GPU +pipeline is unchanged. Fine handles let a few control points describe a curve +that would otherwise need a very dense mesh. + +Because warp is per-surface, to correct the same content differently on two +projectors, author two surfaces (the dome slicer already creates one surface per +projector). Legacy `.varda` files that stored warp per assignment are migrated to +the surface automatically on load. --- @@ -91,7 +171,7 @@ This enables independent visual zones — a club might have a main screen, side For complex surface geometry beyond 4-point corner-pin, surfaces support **arbitrary UV mesh warp** — a dense grid of XY+UV control points with GPU hardware interpolation. Mesh warp is a strict superset of corner-pin (corner-pin is a 2×2 mesh). -Mesh warp is used automatically by the dome slicer and can be loaded from external calibration tools. +Mesh warp can be hand-edited on-canvas in the stage editor's bottom-bar warp editor (see [Mesh warp](#mesh-warp-interior-control-points) above), is used automatically by the dome slicer, and can be loaded from external calibration tools. --- diff --git a/docs/13-api.md b/docs/13-api.md index f132bcf..6e13cae 100644 --- a/docs/13-api.md +++ b/docs/13-api.md @@ -149,6 +149,122 @@ curl -X POST http://localhost:8080/api/scene/save curl -X POST http://localhost:8080/api/shutdown ``` +### Curve a surface edge (Bezier) + +Toggle an edge between a straight line and a cubic bezier (`to_cubic: false` straightens it again): + +```sh +curl -X PUT http://localhost:8080/api/surfaces//edge/convert \ + -H "Content-Type: application/json" \ + -d '{"edge_idx": 0, "to_cubic": true}' +``` + +### Move a curve-path anchor + +```sh +curl -X PUT http://localhost:8080/api/surfaces//path/anchor \ + -H "Content-Type: application/json" \ + -d '{"anchor_idx": 1, "pos": [0.3, 0.4]}' +``` + +### Move a cubic control handle + +`handle` is `C1` or `C2` (the two control points of the cubic segment): + +```sh +curl -X PUT http://localhost:8080/api/surfaces//path/handle \ + -H "Content-Type: application/json" \ + -d '{"segment_idx": 0, "handle": "C1", "pos": [0.6, 0.7]}' +``` + +### Warp a surface (per-surface) + +Warp is a property of the surface, keyed by its UUID. Move a corner-pin corner: + +```sh +curl -X PUT http://localhost:8080/api/surfaces/{uuid}/warp/corner \ + -H "Content-Type: application/json" \ + -d '{"corner_idx": 0, "position": [0.1, 0.1]}' +``` + +Clear a surface's warp (back to native position): + +```sh +curl -X POST http://localhost:8080/api/surfaces/{uuid}/warp/reset +``` + +### Subdivide a surface's warp into a mesh + +Converts the surface's warp to a `cols` × `rows` grid, preserving the current +deformation (a corner-pin becomes a bilinear grid). Dimensions clamp to `[2, 64]`. + +```sh +curl -X PUT http://localhost:8080/api/surfaces/{uuid}/warp/subdivisions \ + -H "Content-Type: application/json" \ + -d '{"cols": 3, "rows": 3}' +``` + +### Move a mesh warp point + +Moves a single grid point (row-major) of the surface's mesh warp. No-op if the +surface's warp is not currently a mesh. + +```sh +curl -X PUT http://localhost:8080/api/surfaces/{uuid}/warp/mesh-point \ + -H "Content-Type: application/json" \ + -d '{"row": 1, "col": 1, "position": [0.6, 0.4]}' +``` + +### Bind/unbind the warp to the surface shape (auto-warp) + +When `bound` is `true` the warp auto-conforms to the surface outline; setting it +`false` unbinds and materialises the conforming warp for manual fine-tuning. + +```sh +curl -X POST http://localhost:8080/api/surfaces/{uuid}/warp/bind \ + -H "Content-Type: application/json" \ + -d '{"bound": false}' +``` + +### Bezier (curved) warp + +Convert the surface's warp into a smooth bezier patch grid (seeded from the +current warp so the shape is preserved), then edit anchors and tangent handles or +resize the control cage. Bezier editing is meaningful only while the warp is +unbound. + +```sh +# Convert to a bezier patch grid +curl -X POST http://localhost:8080/api/surfaces/{uuid}/warp/bezier + +# Move a control anchor (row-major grid coords) +curl -X PUT http://localhost:8080/api/surfaces/{uuid}/warp/anchor \ + -H "Content-Type: application/json" \ + -d '{"row": 0, "col": 0, "position": [0.15, 0.25]}' + +# Move a tangent handle. horizontal=true → edge (r,c)→(r,c+1); false → (r,c)→(r+1,c). +# which=0 near the start anchor, 1 near the end anchor. +curl -X PUT http://localhost:8080/api/surfaces/{uuid}/warp/handle \ + -H "Content-Type: application/json" \ + -d '{"horizontal": true, "row": 0, "col": 0, "which": 0, "position": [0.33, 0.05]}' + +# Resize the anchor cage (adds/removes control points; dims clamp to [2, 64]) +curl -X PUT http://localhost:8080/api/surfaces/{uuid}/warp/cage \ + -H "Content-Type: application/json" \ + -d '{"cols": 3, "rows": 3}' +``` + +### Set an output's calibration mode + +Switches an output between `Off`, `Projector` (full-frame test card), and +`Surfaces` (per-surface test cards through each warp). + +```sh +curl -X PUT http://localhost:8080/api/outputs/0/calibration \ + -H "Content-Type: application/json" \ + -d '{"mode": "Projector"}' +``` + ## Route Groups The API is organized into 15 OpenAPI tags: @@ -163,8 +279,8 @@ The API is organized into 15 OpenAPI tags: | **Effects** | `POST /api/effects`, `POST /api/effects/toggle` | | **Modulation** | `POST /api/modulation/lfo`, `POST /api/modulation/assign` | | **Params** | `PUT /api/params` (set any parameter by path) | -| **Surfaces** | `POST /api/surfaces/rect`, `PUT /api/surfaces/:uuid/source` | -| **Outputs** | `POST /api/outputs/windowed`, `POST /api/outputs/headless` | +| **Surfaces** | `POST /api/surfaces/rect`, `PUT /api/surfaces/:uuid/source`, `PUT /api/surfaces/:uuid/path/handle`, `PUT /api/surfaces/:uuid/warp/corner`, `POST /api/surfaces/:uuid/warp/reset`, `PUT /api/surfaces/:uuid/warp/subdivisions`, `PUT /api/surfaces/:uuid/warp/mesh-point`, `POST /api/surfaces/:uuid/warp/bind`, `POST /api/surfaces/:uuid/warp/bezier`, `PUT /api/surfaces/:uuid/warp/anchor`, `PUT /api/surfaces/:uuid/warp/handle`, `PUT /api/surfaces/:uuid/warp/cage` | +| **Outputs** | `POST /api/outputs/windowed`, `POST /api/outputs/headless`, `PUT /api/outputs/:idx/calibration` | | **Sequences** | `POST /api/sequences`, `POST /api/sequences/:idx/play` | | **Audio** | `POST /api/audio/scan`, `POST /api/audio/open` | | **Streams** | `POST /api/streams/library` | diff --git a/src/app/engine_impl.rs b/src/app/engine_impl.rs index 756b588..e0ba872 100644 --- a/src/app/engine_impl.rs +++ b/src/app/engine_impl.rs @@ -828,7 +828,6 @@ impl OutputQueries for VardaApp { SurfaceAssignmentSnapshot { surface_uuid: a.surface_uuid.clone(), surface_name, - warp_mode: a.warp_mode.clone(), enabled: a.enabled, } }) @@ -859,7 +858,13 @@ impl OutputQueries for VardaApp { .dropped .load(std::sync::atomic::Ordering::Relaxed), }); - (h.target.clone(), false, h.active, false, audio) + ( + h.target.clone(), + false, + h.active, + crate::renderer::context::CalibrationMode::Off, + audio, + ) } }; OutputWindowSnapshot { @@ -889,7 +894,9 @@ impl OutputQueries for VardaApp { content_mapping: s.content_mapping, output_type: s.output_type, circle_hint: s.circle_hint, - default_warp: s.default_warp.clone(), + warp: s.effective_warp(), + warp_bound: s.warp_bound, + path: s.path.clone(), }) .collect(), monitors: self @@ -1010,22 +1017,19 @@ impl SurfaceCommands for VardaApp { .find(|o| o.uuid() == output_uuid) { let assignments = output.surface_assignments_mut(); - if !assignments.iter().any(|a| a.surface_uuid == surface_uuid) { - if let Some((_, surface)) = self.output.surface_manager.find_by_uuid(surface_uuid) { - // Use pre-computed warp mesh if available (dome surfaces), otherwise identity corners - let warp_mode = surface.default_warp.clone().unwrap_or_else(|| { - let bb = surface.bounding_box(); - crate::renderer::warp::WarpMode::identity_corners([ - bb.x, bb.y, bb.width, bb.height, - ]) - }); - assignments.push(crate::renderer::context::SurfaceAssignment { - surface_uuid: surface_uuid.to_string(), - warp_mode, - enabled: true, - overlap_zones: Default::default(), - }); - } + // Warp lives on the surface now — the assignment is membership only. + if !assignments.iter().any(|a| a.surface_uuid == surface_uuid) + && self + .output + .surface_manager + .find_by_uuid(surface_uuid) + .is_some() + { + assignments.push(crate::renderer::context::SurfaceAssignment { + surface_uuid: surface_uuid.to_string(), + enabled: true, + overlap_zones: Default::default(), + }); } } } @@ -1137,6 +1141,13 @@ impl DetectCommands for VardaApp { OutputSource::Master, ) } + } else if let Some(path) = contour.path.as_ref().filter(|p| p.has_cubic()) { + // SVG import captured curvature: create an editable curve surface. + self.output.surface_manager.add_path_surface( + contour.suggested_name.clone(), + path.clone(), + OutputSource::Master, + ) } else { self.output.surface_manager.add_polygon_surface( contour.suggested_name.clone(), @@ -1170,7 +1181,9 @@ impl SurfaceQueries for VardaApp { content_mapping: s.content_mapping, output_type: s.output_type, circle_hint: s.circle_hint, - default_warp: s.default_warp.clone(), + warp: s.effective_warp(), + warp_bound: s.warp_bound, + path: s.path.clone(), }) .collect() } diff --git a/src/app/mod.rs b/src/app/mod.rs index 31135ba..39929de 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -818,6 +818,13 @@ impl VardaApp { CommandResult::Ok } EngineCommand::AddAudioBand { preset, source_id } => { + // A band bound to a specific device must open it for capture; + // `None` uses the auto-opened default (primary) source. + if let Some(id) = source_id { + if let Err(e) = self.audio_manager.open_source(id) { + log::warn!("Failed to open audio source {} for new band: {}", id, e); + } + } self.add_audio_band(preset, source_id); CommandResult::Ok } @@ -935,11 +942,36 @@ impl VardaApp { } EngineCommand::CombineSurfaces { uuids } => self.cmd_combine_surfaces(&uuids), EngineCommand::MoveSurface { uuid, dx, dy } => self.cmd_move_surface(&uuid, dx, dy), + EngineCommand::RotateSurface { uuid, angle, pivot } => { + self.cmd_rotate_surface(&uuid, angle, pivot) + } + EngineCommand::ScaleSurface { + uuid, + sx, + sy, + pivot, + } => self.cmd_scale_surface(&uuid, sx, sy, pivot), EngineCommand::UpdateSurfaceContourVertices { uuid, contour, vertices, } => self.cmd_update_surface_contour_vertices(&uuid, contour, vertices), + EngineCommand::ConvertSurfaceEdge { + uuid, + edge_idx, + to_cubic, + } => self.cmd_convert_surface_edge(&uuid, edge_idx, to_cubic), + EngineCommand::MovePathAnchor { + uuid, + anchor_idx, + pos, + } => self.cmd_move_path_anchor(&uuid, anchor_idx, pos), + EngineCommand::MovePathHandle { + uuid, + segment_idx, + handle, + pos, + } => self.cmd_move_path_handle(&uuid, segment_idx, handle, pos), EngineCommand::AssignSurfaceToOutput { output_uuid, surface_uuid, @@ -1394,17 +1426,52 @@ impl VardaApp { } EngineCommand::StartOutput { idx } => self.cmd_start_output(idx), EngineCommand::StopOutput { idx } => self.cmd_stop_output(idx), - EngineCommand::ToggleCalibration { idx } => self.cmd_toggle_calibration(idx), + EngineCommand::SetCalibrationMode { idx, mode } => { + self.cmd_set_calibration_mode(idx, mode) + } EngineCommand::SetWarpCorner { - output_idx, - assignment_idx, + surface_uuid, corner_idx, position, - } => self.cmd_set_warp_corner(output_idx, assignment_idx, corner_idx, position), - EngineCommand::ResetWarp { - output_idx, - assignment_idx, - } => self.cmd_reset_warp(output_idx, assignment_idx), + } => self.cmd_set_warp_corner(&surface_uuid, corner_idx, position), + EngineCommand::ResetWarp { surface_uuid } => self.cmd_reset_warp(&surface_uuid), + EngineCommand::SetWarpSubdivisions { + surface_uuid, + cols, + rows, + } => self.cmd_set_warp_subdivisions(&surface_uuid, cols, rows), + EngineCommand::SetWarpMeshPoint { + surface_uuid, + row, + col, + position, + } => self.cmd_set_warp_mesh_point(&surface_uuid, row, col, position), + EngineCommand::SetWarpBound { + surface_uuid, + bound, + } => self.cmd_set_warp_bound(&surface_uuid, bound), + EngineCommand::ConvertWarpToBezier { surface_uuid } => { + self.cmd_convert_warp_to_bezier(&surface_uuid) + } + EngineCommand::MoveWarpAnchor { + surface_uuid, + row, + col, + position, + } => self.cmd_move_warp_anchor(&surface_uuid, row, col, position), + EngineCommand::MoveWarpHandle { + surface_uuid, + horizontal, + row, + col, + which, + position, + } => self.cmd_move_warp_handle(&surface_uuid, horizontal, row, col, which, position), + EngineCommand::SetBezierCageSubdivisions { + surface_uuid, + cols, + rows, + } => self.cmd_set_bezier_cage_subdivisions(&surface_uuid, cols, rows), EngineCommand::SetEdgeBlend { output_idx, config } => { self.cmd_set_edge_blend(output_idx, config) } @@ -1662,6 +1729,18 @@ impl VardaApp { }) } EngineCommand::UpdateAudioSource { uuid, source_id } => { + // Selecting a specific device must actually open it for capture; + // otherwise the modulator reads a source that was never started. + if let Some(id) = source_id { + if let Err(e) = self.audio_manager.open_source(id) { + log::warn!( + "Failed to open audio source {} for modulator {}: {}", + id, + uuid, + e + ); + } + } self.exec_modulation_update(&uuid, |s| { if let ModulationSource::AudioBand { source_id: ref mut sid, diff --git a/src/app/outputs.rs b/src/app/outputs.rs index 113bb3e..e017f4f 100644 --- a/src/app/outputs.rs +++ b/src/app/outputs.rs @@ -39,27 +39,12 @@ impl VardaApp { output_idx: *output_idx, assignment_idx: *assignment_idx, }, - ui::OutputAction::ToggleCalibration { idx } => { - EngineCommand::ToggleCalibration { idx: *idx } + ui::OutputAction::SetCalibrationMode { idx, mode } => { + EngineCommand::SetCalibrationMode { + idx: *idx, + mode: *mode, + } } - ui::OutputAction::SetWarpCorner { - output_idx, - assignment_idx, - corner_idx, - position, - } => EngineCommand::SetWarpCorner { - output_idx: *output_idx, - assignment_idx: *assignment_idx, - corner_idx: *corner_idx, - position: *position, - }, - ui::OutputAction::ResetWarp { - output_idx, - assignment_idx, - } => EngineCommand::ResetWarp { - output_idx: *output_idx, - assignment_idx: *assignment_idx, - }, ui::OutputAction::SetEdgeBlend { output_idx, config } => { EngineCommand::SetEdgeBlend { output_idx: *output_idx, @@ -91,6 +76,22 @@ impl VardaApp { let pending: Vec = self.output.pending_output_creates.drain(..).collect(); for config in pending { + // One-time migration: pre-8i.5 `.varda` files stored warp on the + // assignment. Move it onto the surface (first assignment wins; an + // existing surface warp — e.g. dome mesh — takes precedence). + for a in &config.surface_assignments { + if let Some(warp) = &a.legacy_warp_mode { + if let Some((_, surface)) = self + .output + .surface_manager + .find_by_uuid_mut(&a.surface_uuid) + { + if surface.warp.is_none() { + surface.warp = Some(warp.clone()); + } + } + } + } let idx = self.output.outputs.len() + 1; let name = if config.name.is_empty() { format!("Output {}", idx) @@ -145,7 +146,6 @@ impl VardaApp { .iter() .map(|a| SurfaceAssignment { surface_uuid: a.surface_uuid.clone(), - warp_mode: a.warp_mode.clone(), enabled: a.enabled, overlap_zones: Default::default(), }) @@ -207,7 +207,6 @@ impl VardaApp { .iter() .map(|a| SurfaceAssignment { surface_uuid: a.surface_uuid.clone(), - warp_mode: a.warp_mode.clone(), enabled: a.enabled, overlap_zones: Default::default(), }) diff --git a/src/app/render.rs b/src/app/render.rs index 9708b23..9a75f21 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -456,12 +456,18 @@ impl VardaApp { calibration_textures: &[(wgpu::Texture, wgpu::TextureView)], domemaster_view: Option<&wgpu::TextureView>, ) { - if output.calibration_mode - && !calibration_textures.is_empty() - && surface_manager.surfaces.is_empty() + use crate::renderer::context::CalibrationMode; + // Projector calibration: one full-frame test card over the whole output, + // bypassing surface geometry/warp (physical projector alignment). + if output.calibration_mode == CalibrationMode::Projector && !calibration_textures.is_empty() { output.render(context, &calibration_textures[0].1); - } else if surface_manager.surfaces.is_empty() { + output.window.request_redraw(); + return; + } + // Surfaces calibration: each surface shows a colored test card through its warp. + let surfaces_cal = output.calibration_mode == CalibrationMode::Surfaces; + if surface_manager.surfaces.is_empty() { output.render(context, mixer.composite_view()); } else if !output.surface_assignments.is_empty() { let render_infos: Vec> = output @@ -472,13 +478,12 @@ impl VardaApp { .filter_map(|(ai, assignment)| { let (_, surface) = surface_manager.find_by_uuid(&assignment.surface_uuid)?; let bb = surface.bounding_box(); - let content_view = - if output.calibration_mode && !calibration_textures.is_empty() { - &calibration_textures[ai % calibration_textures.len()].1 - } else { - Self::resolve_source(mixer, &surface.source, domemaster_view)? - }; - let (uv_scale, uv_offset) = if output.calibration_mode { + let content_view = if surfaces_cal && !calibration_textures.is_empty() { + &calibration_textures[ai % calibration_textures.len()].1 + } else { + Self::resolve_source(mixer, &surface.source, domemaster_view)? + }; + let (uv_scale, uv_offset) = if surfaces_cal { ([1.0, 1.0], [0.0, 0.0]) } else { Self::compute_uv(surface.content_mapping, &bb) @@ -489,7 +494,7 @@ impl VardaApp { bounding_box: [bb.x, bb.y, bb.width, bb.height], uv_scale, uv_offset, - warp_mode: Some(assignment.warp_mode.clone()), + warp_mode: surface.effective_warp(), overlap_zones: assignment.overlap_zones.clone(), }) }) @@ -502,13 +507,12 @@ impl VardaApp { .enumerate() .filter_map(|(si, surface)| { let bb = surface.bounding_box(); - let content_view = - if output.calibration_mode && !calibration_textures.is_empty() { - &calibration_textures[si % calibration_textures.len()].1 - } else { - Self::resolve_source(mixer, &surface.source, domemaster_view)? - }; - let (uv_scale, uv_offset) = if output.calibration_mode { + let content_view = if surfaces_cal && !calibration_textures.is_empty() { + &calibration_textures[si % calibration_textures.len()].1 + } else { + Self::resolve_source(mixer, &surface.source, domemaster_view)? + }; + let (uv_scale, uv_offset) = if surfaces_cal { ([1.0, 1.0], [0.0, 0.0]) } else { Self::compute_uv(surface.content_mapping, &bb) @@ -519,7 +523,7 @@ impl VardaApp { bounding_box: [bb.x, bb.y, bb.width, bb.height], uv_scale, uv_offset, - warp_mode: None, + warp_mode: surface.effective_warp(), overlap_zones: Default::default(), }) }) @@ -623,8 +627,11 @@ impl VardaApp { let content_view = Self::resolve_source(mixer, &surface.source, domemaster_view)?; let (uv_scale, uv_offset) = Self::compute_uv(surface.content_mapping, &bb); - let (homography, vertices) = match &assignment.warp_mode { - crate::renderer::warp::WarpMode::CornerPin { corners } => { + // Warp is per-surface now; `None` = no warp (native + // position). `effective_warp` applies auto-warp binding. + let eff_warp = surface.effective_warp(); + let (homography, vertices) = match eff_warp.as_ref() { + Some(crate::renderer::warp::WarpMode::CornerPin { corners }) => { let src_corners = [ [bb.x, bb.y], [bb.x + bb.width, bb.y], @@ -645,10 +652,28 @@ impl VardaApp { ); (Some(homography), verts) } - crate::renderer::warp::WarpMode::Mesh(mesh) => ( + Some(crate::renderer::warp::WarpMode::Mesh(mesh)) => ( None, crate::renderer::blit::PolygonBlitPipeline::mesh_verts(mesh), ), + // Bezier: tessellate the control cage into a mesh, + // then bake to verts (identity homography). + Some(crate::renderer::warp::WarpMode::Bezier(b)) => ( + None, + crate::renderer::blit::PolygonBlitPipeline::mesh_verts( + &b.tessellate(), + ), + ), + None => ( + None, + crate::renderer::blit::PolygonBlitPipeline::triangulate_verts( + &surface.vertices, + bb.x, + bb.y, + bb.width, + bb.height, + ), + ), }; Some(crate::renderer::blit::PolygonDrawDesc { content_view, diff --git a/src/app/snapshot.rs b/src/app/snapshot.rs index 6a72e14..0d8c560 100644 --- a/src/app/snapshot.rs +++ b/src/app/snapshot.rs @@ -756,7 +756,6 @@ pub(crate) fn build_ui_data( SurfaceAssignmentUI { surface_uuid: a.surface_uuid.clone(), surface_name, - warp_mode: a.warp_mode.clone(), enabled: a.enabled, overlap_zones: a.overlap_zones.clone(), } @@ -784,7 +783,6 @@ pub(crate) fn build_ui_data( .find_by_uuid(&a.surface_uuid) .map(|(_, s)| s.name.clone()) .unwrap_or_else(|| format!("Surface {}", a.surface_uuid)), - warp_mode: a.warp_mode.clone(), enabled: a.enabled, overlap_zones: a.overlap_zones.clone(), }) @@ -796,7 +794,7 @@ pub(crate) fn build_ui_data( h.active, o.active_duration(), sa, - false, + crate::renderer::context::CalibrationMode::Off, ) } }; @@ -847,6 +845,9 @@ pub(crate) fn build_ui_data( content_mapping: s.content_mapping, output_type: s.output_type, circle_hint: s.circle_hint, + warp: s.warp.clone(), + warp_bound: s.warp_bound, + path: s.path.clone(), }) .collect(); diff --git a/src/app/state/outputs.rs b/src/app/state/outputs.rs index 49e1398..431093a 100644 --- a/src/app/state/outputs.rs +++ b/src/app/state/outputs.rs @@ -3,10 +3,9 @@ use super::super::VardaApp; use crate::engine::{CommandResult, ErrorCode}; use crate::renderer::context::{ - AudioPassthrough, HeadlessOutput, OutputSource, OutputTarget, UnifiedOutput, + AudioPassthrough, CalibrationMode, HeadlessOutput, OutputSource, OutputTarget, UnifiedOutput, }; use crate::renderer::edge_blend::EdgeBlendMode; -use crate::renderer::warp::WarpMode; impl VardaApp { /// Set the output target for a windowed or headless output. @@ -259,10 +258,10 @@ impl VardaApp { } } - /// Toggle calibration mode on a windowed output. - pub fn cmd_toggle_calibration(&mut self, idx: usize) -> CommandResult { + /// Set the calibration display mode on a windowed output. + pub fn cmd_set_calibration_mode(&mut self, idx: usize, mode: CalibrationMode) -> CommandResult { if let Some(UnifiedOutput::Window(w)) = self.output.outputs.get_mut(idx) { - w.calibration_mode = !w.calibration_mode; + w.calibration_mode = mode; CommandResult::Ok } else { CommandResult::Err { @@ -272,58 +271,162 @@ impl VardaApp { } } - /// Set a warp corner position for a surface assignment on a windowed output. + /// Move one corner-pin corner of a surface's warp (per-surface). pub fn cmd_set_warp_corner( &mut self, - output_idx: usize, - assignment_idx: usize, + surface_uuid: &str, corner_idx: usize, position: [f32; 2], ) -> CommandResult { - if let Some(UnifiedOutput::Window(w)) = self.output.outputs.get_mut(output_idx) { - if let Some(a) = w.surface_assignments.get_mut(assignment_idx) { - if let Some(corners) = a.warp_mode.corners_mut() { - if corner_idx < 4 { - corners[corner_idx] = position; - } - } - CommandResult::Ok - } else { - CommandResult::Err { - code: ErrorCode::NotFound, - message: "Assignment not found".into(), - } + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(surface_uuid) { + surface.set_warp_corner(corner_idx, position); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: "Surface not found".into(), } + } + } + + /// Clear a surface's warp (back to no-warp / native position). + pub fn cmd_reset_warp(&mut self, surface_uuid: &str) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(surface_uuid) { + surface.reset_warp(); + CommandResult::Ok } else { CommandResult::Err { code: ErrorCode::NotFound, - message: "Output not found".into(), + message: "Surface not found".into(), } } } - /// Reset warp to identity corners based on the surface bounding box. - pub fn cmd_reset_warp(&mut self, output_idx: usize, assignment_idx: usize) -> CommandResult { - if let Some(output) = self.output.outputs.get_mut(output_idx) { - let assignments = output.surface_assignments_mut(); - if let Some(a) = assignments.get_mut(assignment_idx) { - if let Some((_, surface)) = - self.output.surface_manager.find_by_uuid(&a.surface_uuid) - { - let bb = surface.bounding_box(); - a.warp_mode = WarpMode::identity_corners([bb.x, bb.y, bb.width, bb.height]); - } - CommandResult::Ok - } else { - CommandResult::Err { - code: ErrorCode::NotFound, - message: "Assignment not found".into(), - } + /// Set the warp grid resolution for a surface, converting its warp into a + /// `cols` × `rows` mesh while preserving the current deformation. Dimensions + /// are clamped to `[2, MAX_WARP_SUBDIVISIONS]` in the domain method. + pub fn cmd_set_warp_subdivisions( + &mut self, + surface_uuid: &str, + cols: u32, + rows: u32, + ) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(surface_uuid) { + surface.set_warp_subdivisions(cols, rows); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: "Surface not found".into(), } + } + } + + /// Move a single mesh grid point (row-major) of a surface's mesh warp. + /// No-op on the geometry if the surface's warp is not a mesh; still returns + /// `Ok` so callers can treat it uniformly. + pub fn cmd_set_warp_mesh_point( + &mut self, + surface_uuid: &str, + row: usize, + col: usize, + position: [f32; 2], + ) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(surface_uuid) { + surface.set_warp_mesh_point(row, col, position); + CommandResult::Ok } else { CommandResult::Err { code: ErrorCode::NotFound, - message: "Output not found".into(), + message: "Surface not found".into(), + } + } + } + + /// Bind/unbind a surface's warp from its shape (auto-warp). Unbinding + /// materialises the conforming warp for manual editing. + pub fn cmd_set_warp_bound(&mut self, surface_uuid: &str, bound: bool) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(surface_uuid) { + surface.set_warp_bound(bound); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: "Surface not found".into(), + } + } + } + + /// Convert a surface's warp into a smooth bezier patch grid (8i.6). + pub fn cmd_convert_warp_to_bezier(&mut self, surface_uuid: &str) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(surface_uuid) { + surface.convert_warp_to_bezier(); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: "Surface not found".into(), + } + } + } + + /// Move a bezier-warp control anchor. No-op on the geometry if the warp is + /// not bezier; still returns `Ok` so callers can treat it uniformly. + pub fn cmd_move_warp_anchor( + &mut self, + surface_uuid: &str, + row: usize, + col: usize, + position: [f32; 2], + ) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(surface_uuid) { + surface.set_warp_bezier_anchor(row, col, position); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: "Surface not found".into(), + } + } + } + + /// Move a bezier-warp tangent handle. No-op on the geometry if the warp is + /// not bezier; still returns `Ok`. + pub fn cmd_move_warp_handle( + &mut self, + surface_uuid: &str, + horizontal: bool, + row: usize, + col: usize, + which: usize, + position: [f32; 2], + ) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(surface_uuid) { + surface.set_warp_bezier_handle(horizontal, row, col, which, position); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: "Surface not found".into(), + } + } + } + + /// Set the bezier-warp control-cage resolution. No-op on the geometry if the + /// warp is not bezier; still returns `Ok`. + pub fn cmd_set_bezier_cage_subdivisions( + &mut self, + surface_uuid: &str, + cols: u32, + rows: u32, + ) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(surface_uuid) { + surface.set_bezier_cage_subdivisions(cols, rows); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: "Surface not found".into(), } } } diff --git a/src/app/state/surfaces.rs b/src/app/state/surfaces.rs index 51663c6..4403db7 100644 --- a/src/app/state/surfaces.rs +++ b/src/app/state/surfaces.rs @@ -3,6 +3,7 @@ use super::super::VardaApp; use crate::engine::traits::SurfaceCommands; use crate::engine::{CommandResult, ErrorCode}; +use crate::surface::CubicHandle; impl VardaApp { pub fn cmd_remove_surface(&mut self, uuid: &str) -> CommandResult { @@ -174,6 +175,101 @@ impl VardaApp { } } + /// Rotate a surface by `angle` radians around `pivot` (normalized coords). + /// Vertices, extra contours, curve path and circle hint are rotated in step. + pub fn cmd_rotate_surface(&mut self, uuid: &str, angle: f32, pivot: [f32; 2]) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(uuid) { + surface.rotate(angle, pivot); + self.recompute_auto_edge_blend(); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: format!("Surface {} not found", uuid), + } + } + } + + /// Scale a surface by `(sx, sy)` around `pivot` (normalized coords). + /// Vertices, extra contours, curve path and circle hint are scaled in step. + pub fn cmd_scale_surface( + &mut self, + uuid: &str, + sx: f32, + sy: f32, + pivot: [f32; 2], + ) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(uuid) { + surface.scale(sx, sy, pivot); + self.recompute_auto_edge_blend(); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: format!("Surface {} not found", uuid), + } + } + } + + /// Convert edge `edge_idx` of a surface's curve path to a cubic bezier or + /// back to a straight line. Lazily builds a path from the polygon if needed. + pub fn cmd_convert_surface_edge( + &mut self, + uuid: &str, + edge_idx: usize, + to_cubic: bool, + ) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(uuid) { + surface.convert_edge(edge_idx, to_cubic); + self.recompute_auto_edge_blend(); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: format!("Surface {} not found", uuid), + } + } + } + + /// Move a curve-path anchor to `pos` (normalized coords), regenerating verts. + pub fn cmd_move_path_anchor( + &mut self, + uuid: &str, + anchor_idx: usize, + pos: [f32; 2], + ) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(uuid) { + surface.move_path_anchor(anchor_idx, pos); + self.recompute_auto_edge_blend(); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: format!("Surface {} not found", uuid), + } + } + } + + /// Move a cubic control handle of segment `segment_idx` to `pos`. + pub fn cmd_move_path_handle( + &mut self, + uuid: &str, + segment_idx: usize, + handle: CubicHandle, + pos: [f32; 2], + ) -> CommandResult { + if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(uuid) { + surface.move_path_handle(segment_idx, handle, pos); + self.recompute_auto_edge_blend(); + CommandResult::Ok + } else { + CommandResult::Err { + code: ErrorCode::NotFound, + message: format!("Surface {} not found", uuid), + } + } + } + pub fn cmd_update_surface_contour_vertices( &mut self, uuid: &str, @@ -209,19 +305,18 @@ impl VardaApp { ) -> CommandResult { if let Some(output) = self.output.outputs.get_mut(output_idx) { let assignments = output.surface_assignments_mut(); - if !assignments.iter().any(|a| a.surface_uuid == surface_uuid) { - if let Some((_, surface)) = self.output.surface_manager.find_by_uuid(surface_uuid) { - let bb = surface.bounding_box(); - let assignment = crate::renderer::context::SurfaceAssignment { - surface_uuid: surface_uuid.to_string(), - warp_mode: crate::renderer::warp::WarpMode::identity_corners([ - bb.x, bb.y, bb.width, bb.height, - ]), - enabled: true, - overlap_zones: Default::default(), - }; - assignments.push(assignment); - } + if !assignments.iter().any(|a| a.surface_uuid == surface_uuid) + && self + .output + .surface_manager + .find_by_uuid(surface_uuid) + .is_some() + { + assignments.push(crate::renderer::context::SurfaceAssignment { + surface_uuid: surface_uuid.to_string(), + enabled: true, + overlap_zones: Default::default(), + }); } self.recompute_auto_edge_blend(); CommandResult::Ok diff --git a/src/app/surfaces.rs b/src/app/surfaces.rs index 0882e6c..d31a2e4 100644 --- a/src/app/surfaces.rs +++ b/src/app/surfaces.rs @@ -61,6 +61,61 @@ impl VardaApp { dy: *dy, }); } + ui::SurfaceAction::Rotate { uuid, angle, pivot } => { + self.execute_command(EngineCommand::RotateSurface { + uuid: uuid.clone(), + angle: *angle, + pivot: *pivot, + }); + } + ui::SurfaceAction::Scale { + uuid, + sx, + sy, + pivot, + } => { + self.execute_command(EngineCommand::ScaleSurface { + uuid: uuid.clone(), + sx: *sx, + sy: *sy, + pivot: *pivot, + }); + } + ui::SurfaceAction::ConvertEdge { + uuid, + edge_idx, + to_cubic, + } => { + self.execute_command(EngineCommand::ConvertSurfaceEdge { + uuid: uuid.clone(), + edge_idx: *edge_idx, + to_cubic: *to_cubic, + }); + } + ui::SurfaceAction::MoveAnchor { + uuid, + anchor_idx, + pos, + } => { + self.execute_command(EngineCommand::MovePathAnchor { + uuid: uuid.clone(), + anchor_idx: *anchor_idx, + pos: *pos, + }); + } + ui::SurfaceAction::MoveHandle { + uuid, + segment_idx, + handle, + pos, + } => { + self.execute_command(EngineCommand::MovePathHandle { + uuid: uuid.clone(), + segment_idx: *segment_idx, + handle: *handle, + pos: *pos, + }); + } ui::SurfaceAction::SetSource { uuid, source } => { self.execute_command(EngineCommand::SetSurfaceSource { uuid: uuid.clone(), @@ -108,6 +163,90 @@ impl VardaApp { ui::SurfaceAction::FlipVertical { uuid } => { self.execute_command(EngineCommand::FlipSurfaceVertical { uuid: uuid.clone() }); } + ui::SurfaceAction::SetWarpCorner { + uuid, + corner_idx, + position, + } => { + self.execute_command(EngineCommand::SetWarpCorner { + surface_uuid: uuid.clone(), + corner_idx: *corner_idx, + position: *position, + }); + } + ui::SurfaceAction::ResetWarp { uuid } => { + self.execute_command(EngineCommand::ResetWarp { + surface_uuid: uuid.clone(), + }); + } + ui::SurfaceAction::SetWarpSubdivisions { uuid, cols, rows } => { + self.execute_command(EngineCommand::SetWarpSubdivisions { + surface_uuid: uuid.clone(), + cols: *cols, + rows: *rows, + }); + } + ui::SurfaceAction::SetWarpMeshPoint { + uuid, + row, + col, + position, + } => { + self.execute_command(EngineCommand::SetWarpMeshPoint { + surface_uuid: uuid.clone(), + row: *row, + col: *col, + position: *position, + }); + } + ui::SurfaceAction::SetWarpBound { uuid, bound } => { + self.execute_command(EngineCommand::SetWarpBound { + surface_uuid: uuid.clone(), + bound: *bound, + }); + } + ui::SurfaceAction::ConvertWarpToBezier { uuid } => { + self.execute_command(EngineCommand::ConvertWarpToBezier { + surface_uuid: uuid.clone(), + }); + } + ui::SurfaceAction::MoveWarpAnchor { + uuid, + row, + col, + position, + } => { + self.execute_command(EngineCommand::MoveWarpAnchor { + surface_uuid: uuid.clone(), + row: *row, + col: *col, + position: *position, + }); + } + ui::SurfaceAction::MoveWarpHandle { + uuid, + horizontal, + row, + col, + which, + position, + } => { + self.execute_command(EngineCommand::MoveWarpHandle { + surface_uuid: uuid.clone(), + horizontal: *horizontal, + row: *row, + col: *col, + which: *which, + position: *position, + }); + } + ui::SurfaceAction::SetBezierCageSubdivisions { uuid, cols, rows } => { + self.execute_command(EngineCommand::SetBezierCageSubdivisions { + surface_uuid: uuid.clone(), + cols: *cols, + rows: *rows, + }); + } ui::SurfaceAction::InsertVertex { uuid, after_vert_idx, @@ -195,9 +334,11 @@ impl VardaApp { vertices, OutputSource::Domemaster, ); - // Store the default warp mesh on the surface for auto-assignment + // Store the pre-computed warp mesh on the surface (per-surface warp). + // Unbind from auto-warp so the dome's mesh is authoritative. if let Some((_, surface)) = self.output.surface_manager.find_by_uuid_mut(&uuid) { - surface.default_warp = Some(WarpMode::Mesh(mesh.clone())); + surface.warp = Some(WarpMode::Mesh(mesh.clone())); + surface.warp_bound = false; } log::info!( "Created dome surface '{}' (uuid {}) with {}x{} warp mesh", diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 493107f..e76fc49 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -472,18 +472,70 @@ pub enum EngineCommand { StopOutput { idx: usize, }, - ToggleCalibration { + /// Set the calibration display mode for an output (Off / Projector / Surfaces). + SetCalibrationMode { idx: usize, + mode: crate::renderer::context::CalibrationMode, }, + /// Move one corner-pin corner of a surface's warp (per-surface). SetWarpCorner { - output_idx: usize, - assignment_idx: usize, + surface_uuid: String, corner_idx: usize, position: [f32; 2], }, + /// Clear a surface's warp (back to no-warp / native position). ResetWarp { - output_idx: usize, - assignment_idx: usize, + surface_uuid: String, + }, + /// Set the warp grid resolution for a surface, converting its warp to a + /// `cols` × `rows` mesh (preserving the current deformation). Dimensions ≥2. + SetWarpSubdivisions { + surface_uuid: String, + cols: u32, + rows: u32, + }, + /// Move a single mesh grid point (row-major) of a surface's mesh warp. + /// No-op if the surface's warp is not currently a mesh. + SetWarpMeshPoint { + surface_uuid: String, + row: usize, + col: usize, + position: [f32; 2], + }, + /// Bind or unbind a surface's warp from its shape (auto-warp). Binding + /// re-derives the warp from the outline; unbinding materialises it for + /// manual fine-tuning. + SetWarpBound { + surface_uuid: String, + bound: bool, + }, + /// Convert a surface's warp into a smooth bezier patch grid (8i.6), seeding + /// the control cage from the current warp so the shape is preserved. + ConvertWarpToBezier { + surface_uuid: String, + }, + /// Move a bezier-warp control anchor (row-major grid coords). + MoveWarpAnchor { + surface_uuid: String, + row: usize, + col: usize, + position: [f32; 2], + }, + /// Move a bezier-warp tangent handle. `horizontal` selects a horizontal edge + /// (`(r,c)→(r,c+1)`) vs a vertical edge (`(r,c)→(r+1,c)`); `which` is 0/1. + MoveWarpHandle { + surface_uuid: String, + horizontal: bool, + row: usize, + col: usize, + which: usize, + position: [f32; 2], + }, + /// Set the bezier-warp control-cage resolution (anchor `cols` × `rows`). + SetBezierCageSubdivisions { + surface_uuid: String, + cols: u32, + rows: u32, }, SetEdgeBlend { output_idx: usize, @@ -572,11 +624,45 @@ pub enum EngineCommand { dx: f32, dy: f32, }, + RotateSurface { + uuid: String, + /// Rotation in radians (clockwise in canvas space, y-down). + angle: f32, + /// Pivot the rotation is applied around, in normalized canvas coords. + pivot: [f32; 2], + }, + ScaleSurface { + uuid: String, + sx: f32, + sy: f32, + /// Pivot the scale is applied around, in normalized canvas coords. + pivot: [f32; 2], + }, UpdateSurfaceContourVertices { uuid: String, contour: usize, vertices: Vec<[f32; 2]>, }, + /// Convert a curve-path edge to a cubic bezier (`to_cubic`) or back to a + /// straight line. Lazily builds a path from the polygon if absent. + ConvertSurfaceEdge { + uuid: String, + edge_idx: usize, + to_cubic: bool, + }, + /// Move a curve-path anchor to `pos` (normalized coords). + MovePathAnchor { + uuid: String, + anchor_idx: usize, + pos: [f32; 2], + }, + /// Move a cubic control handle of a curve-path segment to `pos`. + MovePathHandle { + uuid: String, + segment_idx: usize, + handle: CubicHandle, + pos: [f32; 2], + }, AssignSurfaceToOutput { output_uuid: String, surface_uuid: String, diff --git a/src/engine/types.rs b/src/engine/types.rs index 6a5ef2e..5d576e3 100644 --- a/src/engine/types.rs +++ b/src/engine/types.rs @@ -16,7 +16,7 @@ pub use crate::modulation::{ }; pub use crate::params::ParamValue; pub use crate::renderer::context::OutputSource; -pub use crate::surface::{CircleHint, ContentMapping, SurfaceOutputType}; +pub use crate::surface::{CircleHint, ContentMapping, CubicHandle, SurfaceOutputType, SurfacePath}; pub use crate::video::LoopMode; /// Identifies where to apply an effect in the signal chain. @@ -373,7 +373,7 @@ pub struct OutputWindowSnapshot { /// Whether a headless output is actively recording/streaming. pub is_active: bool, pub surface_assignments: Vec, - pub calibration_mode: bool, + pub calibration_mode: crate::renderer::context::CalibrationMode, /// Live audio passthrough health for an active ffmpeg output (None = video-only). pub audio_passthrough: Option, } @@ -392,7 +392,6 @@ pub struct AudioPassthroughSnapshot { pub struct SurfaceAssignmentSnapshot { pub surface_uuid: String, pub surface_name: String, - pub warp_mode: crate::renderer::warp::WarpMode, pub enabled: bool, } @@ -406,7 +405,12 @@ pub struct SurfaceSnapshot { pub content_mapping: ContentMapping, pub output_type: SurfaceOutputType, pub circle_hint: Option, - pub default_warp: Option, + /// Effective warp (auto-conforming to the shape while `warp_bound`). + pub warp: Option, + /// Whether the warp auto-conforms to the surface shape (auto-warp). + pub warp_bound: bool, + /// Curve authoring path, when the surface is bezier-edited. + pub path: Option, } #[derive(Clone, Serialize)] diff --git a/src/internal/audio/mod.rs b/src/internal/audio/mod.rs index 403b2f9..18f700a 100644 --- a/src/internal/audio/mod.rs +++ b/src/internal/audio/mod.rs @@ -276,9 +276,13 @@ impl AudioManager { active: HashMap::new(), }; mgr.scan_devices(); - // Auto-open the default device if available - if let Some(dev) = mgr.devices.first() { - let id = dev.id; + // Auto-open the OS default input (matched by name) so we capture the + // user's chosen mic/interface rather than whichever device merely + // enumerates first (e.g. a silent BlackHole loopback). + let default_name = cpal::default_host() + .default_input_device() + .and_then(|d| d.description().ok().map(|desc| desc.name().to_string())); + if let Some(id) = Self::pick_default_input(default_name.as_deref(), &mgr.devices) { if let Err(e) = mgr.open_source(id) { log::warn!("Failed to auto-open default audio device: {}", e); } @@ -286,6 +290,22 @@ impl AudioManager { mgr } + /// Choose which enumerated input to auto-open: the OS default input matched + /// by name if it is present in the scanned list, otherwise the first + /// enumerated device. Returns `None` when no inputs exist. Pure so it can be + /// unit-tested without audio hardware. + fn pick_default_input( + default_name: Option<&str>, + devices: &[AudioDeviceInfo], + ) -> Option { + if let Some(name) = default_name { + if let Some(dev) = devices.iter().find(|d| d.name == name) { + return Some(dev.id); + } + } + devices.first().map(|d| d.id) + } + /// Scan for available audio input devices. pub fn scan_devices(&mut self) { let host = cpal::default_host(); @@ -1115,6 +1135,48 @@ mod tests { fan_out_pcm(&subs, &[0.0; 4]); // must not panic } + fn dev(id: AudioSourceId, name: &str) -> AudioDeviceInfo { + AudioDeviceInfo { + id, + name: name.to_string(), + } + } + + #[test] + fn pick_default_input_prefers_os_default_by_name() { + let devices = vec![dev(0, "BlackHole 2ch"), dev(1, "MacBook Pro Microphone")]; + // OS default is the mic (id 1), even though BlackHole enumerates first. + assert_eq!( + AudioManager::pick_default_input(Some("MacBook Pro Microphone"), &devices), + Some(1) + ); + } + + #[test] + fn pick_default_input_falls_back_to_first_when_no_default() { + let devices = vec![dev(0, "BlackHole 2ch"), dev(1, "MacBook Pro Microphone")]; + assert_eq!(AudioManager::pick_default_input(None, &devices), Some(0)); + } + + #[test] + fn pick_default_input_falls_back_to_first_when_default_absent() { + let devices = vec![dev(0, "BlackHole 2ch"), dev(1, "MacBook Pro Microphone")]; + // Default reported by the OS isn't in the scanned list → first device. + assert_eq!( + AudioManager::pick_default_input(Some("USB Interface"), &devices), + Some(0) + ); + } + + #[test] + fn pick_default_input_none_when_no_devices() { + assert_eq!( + AudioManager::pick_default_input(Some("MacBook Pro Microphone"), &[]), + None + ); + assert_eq!(AudioManager::pick_default_input(None, &[]), None); + } + #[test] fn audio_format_carries_native_layout() { let fmt = AudioFormat { diff --git a/src/internal/persistence/mod.rs b/src/internal/persistence/mod.rs index 31d3eb4..44a953b 100644 --- a/src/internal/persistence/mod.rs +++ b/src/internal/persistence/mod.rs @@ -102,16 +102,17 @@ impl StagePrefs { if output.name.trim().is_empty() { errors.push(format!("{}: name is empty", prefix)); } - for (j, sa) in output.surface_assignments.iter().enumerate() { - if let crate::renderer::warp::WarpMode::CornerPin { corners } = &sa.warp_mode { - for (c, corner) in corners.iter().enumerate() { - for (k, v) in corner.iter().enumerate() { - if !v.is_finite() { - errors.push(format!( - "{}/surface_assignments[{}]: warp corner[{}][{}] is not finite", - prefix, j, c, k - )); - } + } + // Warp now lives on surfaces — validate their corner-pin finiteness. + for (i, surface) in self.surfaces.surfaces.iter().enumerate() { + if let Some(crate::renderer::warp::WarpMode::CornerPin { corners }) = &surface.warp { + for (c, corner) in corners.iter().enumerate() { + for (k, v) in corner.iter().enumerate() { + if !v.is_finite() { + errors.push(format!( + "surfaces[{}]: warp corner[{}][{}] is not finite", + i, c, k + )); } } } @@ -760,7 +761,7 @@ pub fn snapshot_stage( .iter() .map(|a| SurfaceAssignmentConfig { surface_uuid: a.surface_uuid.clone(), - warp_mode: a.warp_mode.clone(), + legacy_warp_mode: None, enabled: a.enabled, }) .collect(), @@ -775,7 +776,7 @@ pub fn snapshot_stage( .iter() .map(|a| SurfaceAssignmentConfig { surface_uuid: a.surface_uuid.clone(), - warp_mode: a.warp_mode.clone(), + legacy_warp_mode: None, enabled: a.enabled, }) .collect(), @@ -1580,25 +1581,17 @@ mod tests { #[test] fn validate_stage_prefs_warp_corners_non_finite() { + // Warp is per-surface now — a non-finite corner on a surface's warp + // must be reported by validation. let mut prefs = StagePrefs::default(); - prefs.outputs.push(crate::scene::OutputConfig { - uuid: "test0001".into(), - name: "test".into(), - target: crate::scene::OutputTargetConfig::Windowed, - target_display: None, - surface_assignments: vec![crate::scene::SurfaceAssignmentConfig { - surface_uuid: "abcd1234".into(), - warp_mode: crate::renderer::warp::WarpMode::CornerPin { - corners: [[0.0, 0.0], [1.0, 0.0], [f32::INFINITY, 1.0], [0.0, 1.0]], - }, - enabled: true, - }], - window_position: None, - window_size: None, - edge_blend_mode: crate::renderer::edge_blend::EdgeBlendMode::default(), - edge_blend: crate::renderer::edge_blend::EdgeBlendConfig::default(), - rotation: crate::renderer::context::OutputRotation::default(), - }); + let uuid = prefs + .surfaces + .add_surface("s".into(), crate::renderer::context::OutputSource::Master); + if let Some((_, s)) = prefs.surfaces.find_by_uuid_mut(&uuid) { + s.warp = Some(crate::renderer::warp::WarpMode::CornerPin { + corners: [[0.0, 0.0], [1.0, 0.0], [f32::INFINITY, 1.0], [0.0, 1.0]], + }); + } let errors = prefs.validate(); assert!(errors.iter().any(|e| e.contains("warp corner"))); } diff --git a/src/internal/renderer/blit.rs b/src/internal/renderer/blit.rs index 35df548..1abe28a 100644 --- a/src/internal/renderer/blit.rs +++ b/src/internal/renderer/blit.rs @@ -1234,23 +1234,25 @@ impl PolygonBlitPipeline { let bl = &mesh.points[(r + 1) * cols + c]; let br = &mesh.points[(r + 1) * cols + c + 1]; - // Convert positions from [0..1] to NDC [-1..1] for the vertex shader - let to_ndc = |p: &super::warp::MeshPoint| -> PolygonVertex { + // Positions stay in output space [0..1]; the vertex shader + // (with identity homography for mesh warp) converts to NDC, + // matching the corner-pin path in `triangulate_verts`. + let to_vert = |p: &super::warp::MeshPoint| -> PolygonVertex { PolygonVertex { - position: [p.position[0] * 2.0 - 1.0, p.position[1] * 2.0 - 1.0], + position: p.position, uv: p.uv, } }; // Triangle 1: TL, TR, BL - verts.push(to_ndc(tl)); - verts.push(to_ndc(tr)); - verts.push(to_ndc(bl)); + verts.push(to_vert(tl)); + verts.push(to_vert(tr)); + verts.push(to_vert(bl)); // Triangle 2: TR, BR, BL - verts.push(to_ndc(tr)); - verts.push(to_ndc(br)); - verts.push(to_ndc(bl)); + verts.push(to_vert(tr)); + verts.push(to_vert(br)); + verts.push(to_vert(bl)); } } verts @@ -1380,6 +1382,27 @@ mod tests { assert!(PolygonBlitPipeline::mesh_verts(&mesh).is_empty()); } + /// Mesh positions must be emitted in output space [0..1] (NOT pre-converted + /// to NDC): the shader does the single [0..1]→NDC conversion, so an identity + /// mesh must produce positions within [0..1], matching the corner-pin path. + /// Regression guard against the double-NDC bug that clipped surfaces. + #[test] + fn mesh_verts_positions_stay_in_output_space() { + let mesh = super::super::warp::WarpMesh::identity(2, 2); + let verts = PolygonBlitPipeline::mesh_verts(&mesh); + assert_eq!(verts.len(), 6); + for v in &verts { + assert!( + (0.0..=1.0).contains(&v.position[0]) && (0.0..=1.0).contains(&v.position[1]), + "identity mesh vertex must be in [0..1] output space, got {:?}", + v.position + ); + } + // The grid must span the full unit square (TL=[0,0], BR=[1,1]). + assert!(verts.iter().any(|v| v.position == [0.0, 0.0])); + assert!(verts.iter().any(|v| v.position == [1.0, 1.0])); + } + /// prepare must reuse persistent pools and grow them when the surface count /// exceeds the initial ring capacity, without allocating per surface. The /// returned offsets must be contiguous and non-overlapping. diff --git a/src/internal/renderer/context.rs b/src/internal/renderer/context.rs index 020b40e..39f1c0d 100644 --- a/src/internal/renderer/context.rs +++ b/src/internal/renderer/context.rs @@ -446,13 +446,13 @@ pub struct SurfaceRenderInfo<'a> { pub overlap_zones: super::edge_blend::SurfaceOverlapZones, } -/// Assignment of a surface to an output, with per-surface warp calibration. +/// Membership of a surface in an output. Warp now lives on the `Surface` +/// itself (`Surface.warp`); an assignment only records inclusion and the +/// per-output overlap zones used for edge blending. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SurfaceAssignment { /// UUID of the assigned surface pub surface_uuid: String, - /// Warp mode: corner-pin (4-point homography) or arbitrary mesh warp. - pub warp_mode: super::warp::WarpMode, /// Whether this assignment is enabled pub enabled: bool, /// Per-surface overlap zones (set by Auto mode detection). @@ -460,6 +460,30 @@ pub struct SurfaceAssignment { pub overlap_zones: super::edge_blend::SurfaceOverlapZones, } +/// Per-output calibration display mode. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + serde::Serialize, + serde::Deserialize, + utoipa::ToSchema, +)] +pub enum CalibrationMode { + /// Normal content rendering. + #[default] + Off, + /// A single full-frame test card fills the whole output, bypassing surface + /// geometry and warp — for physical projector alignment. + Projector, + /// Each surface shows a colored per-surface test card through its own warp — + /// for verifying surface mapping and warp. + Surfaces, +} + /// Where an output sends its content — unified across windowed and headless outputs. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] pub enum OutputTarget { @@ -601,8 +625,8 @@ pub struct OutputWindow { /// Surface assignments — which surfaces this output renders, with per-surface warp. /// Empty = render all surfaces (fallback behavior). pub surface_assignments: Vec, - /// Whether calibration mode is active (shows warp handles) - pub calibration_mode: bool, + /// Calibration display mode (Off / Projector test card / per-Surface cards). + pub calibration_mode: CalibrationMode, /// Whether edge blend is auto-computed or manually configured. pub edge_blend_mode: super::edge_blend::EdgeBlendMode, /// Edge blending configuration for multi-projector overlap zones. @@ -693,7 +717,7 @@ impl OutputWindow { polygon_pipeline, target: OutputTarget::Windowed, surface_assignments: Vec::new(), - calibration_mode: false, + calibration_mode: CalibrationMode::Off, edge_blend_mode: super::edge_blend::EdgeBlendMode::default(), edge_blend: super::edge_blend::EdgeBlendConfig::default(), edge_blend_pipeline, @@ -874,6 +898,10 @@ impl OutputWindow { // Mesh mode: warp baked into vertices, identity homography (None, PolygonBlitPipeline::mesh_verts(mesh)) } + Some(super::warp::WarpMode::Bezier(b)) => { + // Bezier: tessellate the control cage into a mesh, then bake. + (None, PolygonBlitPipeline::mesh_verts(&b.tessellate())) + } None => { let verts = PolygonBlitPipeline::triangulate_verts( surf.vertices, diff --git a/src/internal/renderer/warp.rs b/src/internal/renderer/warp.rs index 41abbc3..06da2fd 100644 --- a/src/internal/renderer/warp.rs +++ b/src/internal/renderer/warp.rs @@ -82,9 +82,72 @@ impl WarpMesh { (p.position[0] - p.uv[0]).abs() < 1e-6 && (p.position[1] - p.uv[1]).abs() < 1e-6 }) } + + /// Set the output position of the grid point at (`row`, `col`). Out-of-range + /// indices are ignored. UV (source mapping) is preserved. + pub fn set_point(&mut self, row: usize, col: usize, position: [f32; 2]) { + let cols = self.cols as usize; + if row < self.rows as usize && col < cols { + if let Some(p) = self.points.get_mut(row * cols + col) { + p.position = position; + } + } + } + + /// Sample the mesh at parametric coords (`s`, `t`) ∈ [0..1]² (s across + /// columns, t across rows), bilinearly interpolating both position and UV. + fn sample(&self, s: f32, t: f32) -> MeshPoint { + let cols = self.cols as usize; + let rows = self.rows as usize; + let fx = s.clamp(0.0, 1.0) * (cols - 1) as f32; + let fy = t.clamp(0.0, 1.0) * (rows - 1) as f32; + let x0 = (fx.floor() as usize).min(cols - 1); + let y0 = (fy.floor() as usize).min(rows - 1); + let x1 = (x0 + 1).min(cols - 1); + let y1 = (y0 + 1).min(rows - 1); + let tx = fx - x0 as f32; + let ty = fy - y0 as f32; + let lerp = + |a: [f32; 2], b: [f32; 2], f: f32| [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]; + let at = |r: usize, c: usize| self.points[r * cols + c]; + let (p00, p10, p01, p11) = (at(y0, x0), at(y0, x1), at(y1, x0), at(y1, x1)); + MeshPoint { + position: lerp( + lerp(p00.position, p10.position, tx), + lerp(p01.position, p11.position, tx), + ty, + ), + uv: lerp(lerp(p00.uv, p10.uv, tx), lerp(p01.uv, p11.uv, tx), ty), + } + } + + /// Resample this mesh onto a new `new_cols` × `new_rows` grid, preserving the + /// current deformation via bilinear interpolation. Used to subdivide (or + /// coarsen) a warp grid without the image jumping. Dimensions are clamped ≥2. + pub fn resampled(&self, new_cols: u32, new_rows: u32) -> WarpMesh { + let new_cols = new_cols.max(2); + let new_rows = new_rows.max(2); + let mut points = Vec::with_capacity((new_cols * new_rows) as usize); + for r in 0..new_rows { + let t = r as f32 / (new_rows - 1) as f32; + for c in 0..new_cols { + let s = c as f32 / (new_cols - 1) as f32; + points.push(self.sample(s, t)); + } + } + WarpMesh { + cols: new_cols, + rows: new_rows, + points, + } + } } -/// Warp mode for surface assignments: corner-pin or arbitrary mesh. +/// Maximum warp grid resolution (columns or rows) a mesh warp may be +/// subdivided to. Domain- and engine-enforced. +pub const MAX_WARP_SUBDIVISIONS: u32 = 64; + +/// Warp mode for a surface: corner-pin, arbitrary mesh, or bezier patch grid. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(tag = "type")] pub enum WarpMode { @@ -92,6 +155,9 @@ pub enum WarpMode { CornerPin { corners: [[f32; 2]; 4] }, /// Arbitrary XYUV mesh warp grid. Mesh(WarpMesh), + /// Smooth bezier patch grid (8i.6). Editable control cage; tessellated into + /// a `WarpMesh` for the GPU via [`WarpMode::render_mesh`]. + Bezier(BezierWarp), } impl WarpMode { @@ -112,7 +178,7 @@ impl WarpMode { pub fn corners(&self) -> Option<&[[f32; 2]; 4]> { match self { Self::CornerPin { corners } => Some(corners), - Self::Mesh(_) => None, + Self::Mesh(_) | Self::Bezier(_) => None, } } @@ -120,7 +186,33 @@ impl WarpMode { pub fn corners_mut(&mut self) -> Option<&mut [[f32; 2]; 4]> { match self { Self::CornerPin { corners } => Some(corners), - Self::Mesh(_) => None, + Self::Mesh(_) | Self::Bezier(_) => None, + } + } + + /// Convert this warp into a mesh of `cols` × `rows` grid points, preserving + /// the current deformation. A corner-pin becomes a bilinear grid over its + /// quad (perspective → bilinear is inherent to switching to mesh warp); an + /// existing mesh is resampled to the new resolution; a bezier warp is + /// tessellated then resampled. Dimensions clamp ≥2. + pub fn to_mesh(&self, cols: u32, rows: u32) -> WarpMesh { + match self { + Self::CornerPin { corners } => WarpMesh::from_corners(corners).resampled(cols, rows), + Self::Mesh(mesh) => mesh.resampled(cols, rows), + Self::Bezier(b) => b.tessellate().resampled(cols, rows), + } + } + + /// The `WarpMesh` the GPU pipeline consumes for this warp, if it is a + /// mesh-based warp. `CornerPin` returns `None` (rendered via homography); + /// `Mesh` returns its grid; `Bezier` tessellates its control cage. This is + /// the render-site choke point that keeps `blit`/snapshot geometry unaware + /// of the bezier representation. + pub fn render_mesh(&self) -> Option { + match self { + Self::CornerPin { .. } => None, + Self::Mesh(mesh) => Some(mesh.clone()), + Self::Bezier(b) => Some(b.tessellate()), } } @@ -140,8 +232,464 @@ impl WarpMode { .all(|(a, b)| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6) } Self::Mesh(mesh) => mesh.is_identity(), + Self::Bezier(b) => b.is_identity(), + } + } +} + +// ── Shape-conforming warp meshes (Approach B, auto-warp binding) ───── +// +// These build a `WarpMesh` whose grid boundary follows a surface's own +// outline, so content fills the shape. Pure geometry (no wgpu); consumed by +// `Surface::conforming_warp`. + +/// Build an `n`×`n` warp mesh whose grid boundary lands exactly on the ellipse +/// centred at `center` with radii `(rx, ry)` in output space, via the classic +/// elliptical square-to-disc map. Interior points fill the disc; UVs are the +/// uniform unit-square grid. `n` clamps to `[2, MAX_WARP_SUBDIVISIONS]`; +/// positions clamp to `[0, 1]` (matching `CircleHint::generate_vertices`). +pub fn disc_map_mesh(center: [f32; 2], rx: f32, ry: f32, n: u32) -> WarpMesh { + let n = n.clamp(2, MAX_WARP_SUBDIVISIONS); + let mut points = Vec::with_capacity((n * n) as usize); + for r in 0..n { + let v = r as f32 / (n - 1) as f32; + let sy = v * 2.0 - 1.0; + for c in 0..n { + let u = c as f32 / (n - 1) as f32; + let sx = u * 2.0 - 1.0; + // Elliptical grid mapping: the unit square maps onto the unit disc, + // and the square's boundary maps exactly onto the circle. + let dx = sx * (1.0 - sy * sy / 2.0).max(0.0).sqrt(); + let dy = sy * (1.0 - sx * sx / 2.0).max(0.0).sqrt(); + points.push(MeshPoint { + position: [ + (center[0] + dx * rx).clamp(0.0, 1.0), + (center[1] + dy * ry).clamp(0.0, 1.0), + ], + uv: [u, v], + }); + } + } + WarpMesh { + cols: n, + rows: n, + points, + } +} + +/// Resample a polyline to exactly `n` (≥2) points spaced uniformly by arc +/// length, including both endpoints. Degenerate inputs return a repeated point. +fn resample_polyline(pts: &[[f32; 2]], n: u32) -> Vec<[f32; 2]> { + let n = n.max(2) as usize; + if pts.is_empty() { + return vec![[0.0, 0.0]; n]; + } + if pts.len() == 1 { + return vec![pts[0]; n]; + } + let mut cum = Vec::with_capacity(pts.len()); + cum.push(0.0f32); + for w in pts.windows(2) { + let d = ((w[1][0] - w[0][0]).powi(2) + (w[1][1] - w[0][1]).powi(2)).sqrt(); + cum.push(cum.last().unwrap() + d); + } + let total = *cum.last().unwrap(); + if total <= f32::EPSILON { + return vec![pts[0]; n]; + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let target = total * i as f32 / (n as f32 - 1.0); + let mut seg = 0; + while seg + 2 < cum.len() && cum[seg + 1] < target { + seg += 1; + } + let seg_len = cum[seg + 1] - cum[seg]; + let t = if seg_len > f32::EPSILON { + (target - cum[seg]) / seg_len + } else { + 0.0 + }; + out.push([ + pts[seg][0] + (pts[seg + 1][0] - pts[seg][0]) * t, + pts[seg][1] + (pts[seg + 1][1] - pts[seg][1]) * t, + ]); + } + out +} + +/// Indices of the vertices nearest the bbox corners (TL, TR, BR, BL). +fn detect_quad_corners(verts: &[[f32; 2]]) -> [usize; 4] { + let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for v in verts { + minx = minx.min(v[0]); + miny = miny.min(v[1]); + maxx = maxx.max(v[0]); + maxy = maxy.max(v[1]); + } + let targets = [[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy]]; + let mut out = [0usize; 4]; + for (k, t) in targets.iter().enumerate() { + let mut best = 0usize; + let mut bestd = f32::MAX; + for (i, v) in verts.iter().enumerate() { + let d = (v[0] - t[0]).powi(2) + (v[1] - t[1]).powi(2); + if d < bestd { + bestd = d; + best = i; + } + } + out[k] = best; + } + out +} + +/// Walk the closed polygon `verts` forward from index `from` to `to` inclusive. +fn forward_run(verts: &[[f32; 2]], from: usize, to: usize) -> Vec<[f32; 2]> { + let n = verts.len(); + let mut out = vec![verts[from]]; + let mut i = from; + while i != to { + i = (i + 1) % n; + out.push(verts[i]); + } + out +} + +/// Single-point cubic-bezier evaluation at parameter `t` ∈ [0,1]. Kept local to +/// the warp module: the renderer must not depend on `surface/curve.rs`, and the +/// shared-flattener rule targets outline *polyline* flattening (a distinct +/// concern), not this per-point warp evaluation. +fn cubic_point(p0: [f32; 2], c1: [f32; 2], c2: [f32; 2], p1: [f32; 2], t: f32) -> [f32; 2] { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [ + a * p0[0] + b * c1[0] + c * c2[0] + d * p1[0], + a * p0[1] + b * c1[1] + c * c2[1] + d * p1[1], + ] +} + +/// Transfinite (Coons) blend of a patch's four boundary points at parametric +/// `(s, t)`: `l`/`r` are the left/right boundary points at row-parameter `t`; +/// `tp`/`bt` the top/bottom boundary points at col-parameter `s`; `c00..c11` +/// the four patch corners. Shared by `coons_mesh` (polyline sides) and +/// `BezierWarp::tessellate` (cubic sides) — one Coons code path. +#[allow(clippy::too_many_arguments)] +fn coons_blend( + l: [f32; 2], + r: [f32; 2], + tp: [f32; 2], + bt: [f32; 2], + c00: [f32; 2], + c10: [f32; 2], + c01: [f32; 2], + c11: [f32; 2], + s: f32, + t: f32, +) -> [f32; 2] { + let mut pos = [0.0f32; 2]; + for d in 0..2 { + let lc = (1.0 - s) * l[d] + s * r[d]; + let ld = (1.0 - t) * tp[d] + t * bt[d]; + let b = (1.0 - s) * (1.0 - t) * c00[d] + + s * (1.0 - t) * c10[d] + + (1.0 - s) * t * c01[d] + + s * t * c11[d]; + pos[d] = lc + ld - b; + } + pos +} + +/// Build a `cols`×`rows` Coons-patch mesh whose boundary follows the closed +/// polygon `verts` (content fills the outline; Approach B). The four sides are +/// the vertex runs between the vertices nearest the bbox corners, resampled by +/// arc length; the interior is transfinite (Coons) interpolation. UVs are the +/// uniform unit-square grid. Fewer than 3 vertices returns an identity mesh. +/// Convex shapes fill exactly; concave shapes approximate. Dims clamp ≥2. +pub fn coons_mesh(verts: &[[f32; 2]], cols: u32, rows: u32) -> WarpMesh { + let cols = cols.clamp(2, MAX_WARP_SUBDIVISIONS); + let rows = rows.clamp(2, MAX_WARP_SUBDIVISIONS); + if verts.len() < 3 { + return WarpMesh::identity(cols, rows); + } + let n = verts.len(); + let [tl, tr, br, bl] = detect_quad_corners(verts); + // Normalise winding so walking forward visits TL → TR → BR → BL. + let fd = |a: usize, b: usize| (b + n - a) % n; + if fd(tl, bl) < fd(tl, tr) { + let mut rv = verts.to_vec(); + rv.reverse(); + return coons_mesh(&rv, cols, rows); + } + // Sides, each parametrised 0→1 in (s across cols, t across rows): + let top = resample_polyline(&forward_run(verts, tl, tr), cols); // TL→TR + let right = resample_polyline(&forward_run(verts, tr, br), rows); // TR→BR + let mut bottom = resample_polyline(&forward_run(verts, br, bl), cols); // BR→BL + bottom.reverse(); // → BL→BR + let mut left = resample_polyline(&forward_run(verts, bl, tl), rows); // BL→TL + left.reverse(); // → TL→BL + + let c00 = top[0]; + let c10 = top[cols as usize - 1]; + let c01 = bottom[0]; + let c11 = bottom[cols as usize - 1]; + let mut points = Vec::with_capacity((cols * rows) as usize); + for r in 0..rows as usize { + let t = r as f32 / (rows - 1) as f32; + for c in 0..cols as usize { + let s = c as f32 / (cols - 1) as f32; + let pos = coons_blend( + left[r], right[r], top[c], bottom[c], c00, c10, c01, c11, s, t, + ); + points.push(MeshPoint { + position: pos, + uv: [s, t], + }); } } + WarpMesh { cols, rows, points } +} + +// ── Bezier patch-grid warp (8i.6) ─────────────────────────────────── +// +// A full bezier patch grid: an `anchor_cols × anchor_rows` control cage of +// on-surface anchors, with per-edge cubic tangent handles. Each grid cell is a +// Coons patch bounded by four cubic beziers; the interior is transfinite +// (Coons) interpolation. Tessellated into a `WarpMesh` for the GPU. Pure +// geometry (no wgpu); the editable cage is authoritative and the mesh derived. + +/// Default tessellation steps per patch edge for a new bezier warp. +pub const DEFAULT_BEZIER_TESS: u32 = 6; + +/// A smooth warp defined by a grid of cubic-bezier patches with tangent +/// handles. See the module comment above for the model. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BezierWarp { + /// Anchor columns in the control cage (≥2). + pub anchor_cols: u32, + /// Anchor rows in the control cage (≥2). + pub anchor_rows: u32, + /// Anchor positions, row-major (`anchor_cols` × `anchor_rows`), output + /// space [0..1]. + pub anchors: Vec<[f32; 2]>, + /// Horizontal-edge cubic handles `[near-left, near-right]`, one per + /// horizontal edge, row-major over rows then per-row edges. Length + /// `anchor_rows · (anchor_cols − 1)`. + pub h_horiz: Vec<[[f32; 2]; 2]>, + /// Vertical-edge cubic handles `[near-top, near-bottom]`, one per vertical + /// edge, row-major over edge-rows then cols. Length + /// `anchor_cols · (anchor_rows − 1)`. + pub h_vert: Vec<[[f32; 2]; 2]>, + /// Tessellation steps per patch edge (≥1). Controls output mesh density. + pub tess: u32, +} + +impl BezierWarp { + fn lerp(a: [f32; 2], b: [f32; 2], t: f32) -> [f32; 2] { + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] + } + + /// Straight-edge handles (at the ⅓ and ⅔ chord points) between `a` and `b`, + /// so a cubic through them is exactly the line segment `a→b`. + fn straight(a: [f32; 2], b: [f32; 2]) -> [[f32; 2]; 2] { + [Self::lerp(a, b, 1.0 / 3.0), Self::lerp(a, b, 2.0 / 3.0)] + } + + /// Build a bezier warp from an anchor grid with all edges straight. Anchor + /// count must equal `anchor_cols · anchor_rows` (padded if short). `tess` ≥1. + pub fn from_anchors( + anchor_cols: u32, + anchor_rows: u32, + mut anchors: Vec<[f32; 2]>, + tess: u32, + ) -> Self { + let ac = anchor_cols.max(2); + let ar = anchor_rows.max(2); + anchors.resize((ac * ar) as usize, [0.0, 0.0]); + let aci = ac as usize; + let mut h_horiz = Vec::with_capacity((ar * (ac - 1)) as usize); + for r in 0..ar as usize { + for c in 0..aci - 1 { + h_horiz.push(Self::straight( + anchors[r * aci + c], + anchors[r * aci + c + 1], + )); + } + } + let mut h_vert = Vec::with_capacity(((ar - 1) * ac) as usize); + for r in 0..ar as usize - 1 { + for c in 0..aci { + h_vert.push(Self::straight( + anchors[r * aci + c], + anchors[(r + 1) * aci + c], + )); + } + } + Self { + anchor_cols: ac, + anchor_rows: ar, + anchors, + h_horiz, + h_vert, + tess: tess.max(1), + } + } + + /// Seed a bezier warp from an existing mesh: anchors = mesh points, all + /// edges straight. At `tess = 1` this tessellates back to the same mesh + /// (lossless), so converting a mesh warp to bezier is visually identical. + pub fn from_mesh(mesh: &WarpMesh, tess: u32) -> Self { + let anchors = mesh.points.iter().map(|p| p.position).collect(); + Self::from_anchors(mesh.cols, mesh.rows, anchors, tess) + } + + #[inline] + fn ac(&self) -> usize { + self.anchor_cols.max(2) as usize + } + #[inline] + fn ar(&self) -> usize { + self.anchor_rows.max(2) as usize + } + + /// Anchor position at grid `(row, col)`. + pub fn anchor(&self, r: usize, c: usize) -> [f32; 2] { + self.anchors[r * self.ac() + c] + } + + /// Evaluate patch `(pr, pc)` at local `(s, t)` ∈ [0,1]² via Coons + /// interpolation of its four cubic-bezier boundary edges. + fn patch_point(&self, pr: usize, pc: usize, s: f32, t: f32) -> [f32; 2] { + let ac = self.ac(); + let c00 = self.anchor(pr, pc); + let c10 = self.anchor(pr, pc + 1); + let c01 = self.anchor(pr + 1, pc); + let c11 = self.anchor(pr + 1, pc + 1); + let hh_top = self.h_horiz[pr * (ac - 1) + pc]; + let hh_bot = self.h_horiz[(pr + 1) * (ac - 1) + pc]; + let hv_left = self.h_vert[pr * ac + pc]; + let hv_right = self.h_vert[pr * ac + pc + 1]; + let tp = cubic_point(c00, hh_top[0], hh_top[1], c10, s); + let bt = cubic_point(c01, hh_bot[0], hh_bot[1], c11, s); + let l = cubic_point(c00, hv_left[0], hv_left[1], c01, t); + let r = cubic_point(c10, hv_right[0], hv_right[1], c11, t); + coons_blend(l, r, tp, bt, c00, c10, c01, c11, s, t) + } + + /// Tessellate the control cage into a dense `WarpMesh`. Produces a + /// `((anchor_cols−1)·tess + 1)` × `((anchor_rows−1)·tess + 1)` mesh, clamped + /// to `[2, MAX_WARP_SUBDIVISIONS]`. UVs are the uniform unit-square grid. + pub fn tessellate(&self) -> WarpMesh { + let ac = self.ac(); + let ar = self.ar(); + let tess = self.tess.max(1); + let cols = ((ac as u32 - 1) * tess + 1).clamp(2, MAX_WARP_SUBDIVISIONS); + let rows = ((ar as u32 - 1) * tess + 1).clamp(2, MAX_WARP_SUBDIVISIONS); + let mut points = Vec::with_capacity((cols * rows) as usize); + for r in 0..rows { + let v = r as f32 / (rows - 1) as f32; + let gv = v * (ar as f32 - 1.0); + let pr = (gv.floor() as usize).min(ar - 2); + let t = gv - pr as f32; + for c in 0..cols { + let u = c as f32 / (cols - 1) as f32; + let gu = u * (ac as f32 - 1.0); + let pc = (gu.floor() as usize).min(ac - 2); + let s = gu - pc as f32; + points.push(MeshPoint { + position: self.patch_point(pr, pc, s, t), + uv: [u, v], + }); + } + } + WarpMesh { cols, rows, points } + } + + /// Whether this warp tessellates to an identity mesh (no warp effect). + pub fn is_identity(&self) -> bool { + self.tessellate().is_identity() + } + + /// Move anchor `(row, col)` to `pos`, shifting its incident tangent handles + /// by the same delta so local curvature is preserved (mirrors the surface + /// bezier-edge `move_anchor`). Out-of-range indices are ignored. + pub fn move_anchor(&mut self, r: usize, c: usize, pos: [f32; 2]) { + let (ac, ar) = (self.ac(), self.ar()); + if r >= ar || c >= ac { + return; + } + let old = self.anchor(r, c); + let d = [pos[0] - old[0], pos[1] - old[1]]; + self.anchors[r * ac + c] = pos; + if c + 1 < ac { + let h = &mut self.h_horiz[r * (ac - 1) + c][0]; + *h = [h[0] + d[0], h[1] + d[1]]; + } + if c > 0 { + let h = &mut self.h_horiz[r * (ac - 1) + (c - 1)][1]; + *h = [h[0] + d[0], h[1] + d[1]]; + } + if r + 1 < ar { + let h = &mut self.h_vert[r * ac + c][0]; + *h = [h[0] + d[0], h[1] + d[1]]; + } + if r > 0 { + let h = &mut self.h_vert[(r - 1) * ac + c][1]; + *h = [h[0] + d[0], h[1] + d[1]]; + } + } + + /// Move a single tangent handle to `pos`. `horizontal` selects a horizontal + /// edge (anchor `(r,c)→(r,c+1)`) vs a vertical edge (`(r,c)→(r+1,c)`); + /// `which` is 0 (near start anchor) or 1 (near end anchor). No-op if the + /// addressed edge does not exist. + pub fn move_handle( + &mut self, + horizontal: bool, + r: usize, + c: usize, + which: usize, + pos: [f32; 2], + ) { + let (ac, ar) = (self.ac(), self.ar()); + let which = which.min(1); + if horizontal { + if r < ar && c + 1 < ac { + self.h_horiz[r * (ac - 1) + c][which] = pos; + } + } else if r + 1 < ar && c < ac { + self.h_vert[r * ac + c][which] = pos; + } + } + + /// Rebuild the control cage at a new `cols × rows` anchor resolution, + /// resampling anchors onto the current warped surface with straightened + /// handles. Adds/removes control points; sub-anchor curvature is not + /// preserved across a re-subdivide (flagged limitation). Dims clamp to + /// `[2, MAX_WARP_SUBDIVISIONS]`. + pub fn set_cage_subdivisions(&mut self, cols: u32, rows: u32) { + let nc = cols.clamp(2, MAX_WARP_SUBDIVISIONS); + let nr = rows.clamp(2, MAX_WARP_SUBDIVISIONS); + let (oc, or) = (self.ac(), self.ar()); + let mut anchors = Vec::with_capacity((nc * nr) as usize); + for rr in 0..nr as usize { + let gv = (rr as f32 / (nr as f32 - 1.0)) * (or as f32 - 1.0); + let pr = (gv.floor() as usize).min(or - 2); + let t = gv - pr as f32; + for cc in 0..nc as usize { + let gu = (cc as f32 / (nc as f32 - 1.0)) * (oc as f32 - 1.0); + let pc = (gu.floor() as usize).min(oc - 2); + let s = gu - pc as f32; + anchors.push(self.patch_point(pr, pc, s, t)); + } + } + *self = Self::from_anchors(nc, nr, anchors, self.tess); + } + + /// Set the per-patch tessellation density (≥1). + pub fn set_tess(&mut self, tess: u32) { + self.tess = tess.max(1); + } } /// Compute a forward homography that maps from `src_corners` to `dst_corners`. @@ -500,6 +1048,83 @@ mod tests { assert_eq!(num_tris, 12); } + #[test] + fn resample_identity_stays_identity() { + // Subdividing an identity mesh yields a denser identity mesh. + let dense = WarpMesh::identity(2, 2).resampled(5, 3); + assert_eq!(dense.cols, 5); + assert_eq!(dense.rows, 3); + assert_eq!(dense.points.len(), 15); + assert!(dense.is_identity()); + } + + #[test] + fn resample_clamps_dims_to_min_two() { + let m = WarpMesh::identity(3, 3).resampled(1, 0); + assert_eq!(m.cols, 2); + assert_eq!(m.rows, 2); + } + + #[test] + fn resample_preserves_corner_positions() { + // A warped 2×2 quad, resampled denser, keeps its four corner positions. + let warped = WarpMesh { + cols: 2, + rows: 2, + points: vec![ + MeshPoint { + position: [0.1, 0.2], + uv: [0.0, 0.0], + }, + MeshPoint { + position: [0.8, 0.05], + uv: [1.0, 0.0], + }, + MeshPoint { + position: [0.15, 0.9], + uv: [0.0, 1.0], + }, + MeshPoint { + position: [0.95, 0.85], + uv: [1.0, 1.0], + }, + ], + }; + let d = warped.resampled(4, 4); + let approx = + |a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-5 && (a[1] - b[1]).abs() < 1e-5; + assert!(approx(d.points[0].position, [0.1, 0.2])); // TL + assert!(approx(d.points[3].position, [0.8, 0.05])); // TR + assert!(approx(d.points[12].position, [0.15, 0.9])); // BL + assert!(approx(d.points[15].position, [0.95, 0.85])); // BR + } + + #[test] + fn set_point_updates_position_only() { + let mut m = WarpMesh::identity(3, 3); + m.set_point(1, 1, [0.6, 0.4]); + let center = m.points[4]; // row 1, col 1 in a 3-wide grid + assert!((center.position[0] - 0.6).abs() < 1e-6); + assert!((center.position[1] - 0.4).abs() < 1e-6); + // UV (source mapping) is untouched — still the identity centre. + assert!((center.uv[0] - 0.5).abs() < 1e-6); + assert!((center.uv[1] - 0.5).abs() < 1e-6); + // Out-of-range is a no-op. + m.set_point(9, 9, [0.0, 0.0]); + assert_eq!(m.points.len(), 9); + } + + #[test] + fn to_mesh_from_corner_pin_has_requested_dims() { + let cp = WarpMode::corner_pin([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]); + let mesh = cp.to_mesh(4, 5); + assert_eq!(mesh.cols, 4); + assert_eq!(mesh.rows, 5); + assert_eq!(mesh.points.len(), 20); + // Identity corner-pin over the unit square → identity mesh. + assert!(mesh.is_identity()); + } + #[test] fn warp_mode_serialization_roundtrip() { let corner_mode = WarpMode::corner_pin([[0.1, 0.2], [0.9, 0.2], [0.9, 0.8], [0.1, 0.8]]); @@ -723,4 +1348,251 @@ mod tests { assert_eq!(mesh.points.len(), 4); } } + + // ── Shape-conforming warp (Approach B) ─────────────────────────── + + #[test] + fn disc_map_mesh_dims_and_uvs() { + let m = disc_map_mesh([0.5, 0.5], 0.4, 0.4, 5); + assert_eq!(m.cols, 5); + assert_eq!(m.rows, 5); + assert_eq!(m.points.len(), 25); + // UVs stay the uniform unit-square grid. + assert_eq!(m.points[0].uv, [0.0, 0.0]); + assert_eq!(m.points[24].uv, [1.0, 1.0]); + } + + #[test] + fn disc_map_mesh_boundary_lands_on_circle() { + let (cx, cy, radius) = (0.5f32, 0.5f32, 0.4f32); + let m = disc_map_mesh([cx, cy], radius, radius, 6); + let cols = m.cols as usize; + for r in 0..m.rows as usize { + for c in 0..cols { + let on_boundary = r == 0 || c == 0 || r + 1 == m.rows as usize || c + 1 == cols; + if !on_boundary { + continue; + } + let p = m.points[r * cols + c].position; + let dist = ((p[0] - cx).powi(2) + (p[1] - cy).powi(2)).sqrt(); + assert!( + (dist - radius).abs() < 1e-4, + "boundary point ({r},{c}) at {p:?} dist {dist} != radius {radius}" + ); + } + } + } + + #[test] + fn coons_mesh_unit_square_is_bilinear_identity() { + // A unit square given as 4 corners → positions equal a uniform grid. + let verts = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + let m = coons_mesh(&verts, 3, 3); + assert_eq!(m.points.len(), 9); + for p in &m.points { + // For the unit square, Coons position == UV. + assert!((p.position[0] - p.uv[0]).abs() < 1e-5); + assert!((p.position[1] - p.uv[1]).abs() < 1e-5); + } + } + + #[test] + fn coons_mesh_boundary_follows_offset_square_with_midpoints() { + // Square [0.2,0.8]² described with edge midpoints (8 vertices); corners + // must be detected and the grid boundary must land on the square. + let verts = [ + [0.2, 0.2], + [0.5, 0.2], + [0.8, 0.2], + [0.8, 0.5], + [0.8, 0.8], + [0.5, 0.8], + [0.2, 0.8], + [0.2, 0.5], + ]; + let m = coons_mesh(&verts, 3, 3); + let at = |r: usize, c: usize| m.points[r * 3 + c].position; + assert!((at(0, 0)[0] - 0.2).abs() < 1e-4 && (at(0, 0)[1] - 0.2).abs() < 1e-4); + assert!((at(0, 2)[0] - 0.8).abs() < 1e-4 && (at(0, 2)[1] - 0.2).abs() < 1e-4); + assert!((at(2, 2)[0] - 0.8).abs() < 1e-4 && (at(2, 2)[1] - 0.8).abs() < 1e-4); + assert!((at(2, 0)[0] - 0.2).abs() < 1e-4 && (at(2, 0)[1] - 0.8).abs() < 1e-4); + // Edge midpoint lands on the square edge. + assert!((at(0, 1)[0] - 0.5).abs() < 1e-4 && (at(0, 1)[1] - 0.2).abs() < 1e-4); + } + + #[test] + fn coons_mesh_degenerate_is_identity() { + let m = coons_mesh(&[[0.0, 0.0], [1.0, 1.0]], 3, 3); + assert!(m.is_identity()); + } + + #[test] + fn coons_mesh_triangle_is_valid_and_in_hull() { + let verts = [[0.1, 0.1], [0.9, 0.2], [0.5, 0.9]]; + let m = coons_mesh(&verts, 4, 4); + assert_eq!(m.points.len(), 16); + for p in &m.points { + assert!(p.position[0].is_finite() && p.position[1].is_finite()); + assert!((0.0..=1.0).contains(&p.position[0])); + assert!((0.0..=1.0).contains(&p.position[1])); + } + } + + // ── Bezier patch-grid warp (8i.6) ───────────────────────────────── + + fn approx2(a: [f32; 2], b: [f32; 2]) -> bool { + (a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4 + } + + #[test] + fn bezier_from_identity_mesh_tessellates_to_identity() { + // Straight-edge cage over the unit square → identity mesh, at any tess. + let b = BezierWarp::from_mesh(&WarpMesh::identity(2, 2), DEFAULT_BEZIER_TESS); + assert!(b.tessellate().is_identity()); + assert!(b.is_identity()); + } + + #[test] + fn bezier_from_mesh_tess1_is_lossless() { + // A warped 2×2 quad → bezier at tess=1 tessellates back to the same quad. + let warped = WarpMesh::from_corners(&[[0.1, 0.2], [0.8, 0.05], [0.95, 0.85], [0.15, 0.9]]); + let b = BezierWarp::from_mesh(&warped, 1); + let m = b.tessellate(); + assert_eq!((m.cols, m.rows), (2, 2)); + for (a, w) in m.points.iter().zip(warped.points.iter()) { + assert!(approx2(a.position, w.position)); + } + } + + #[test] + fn bezier_tessellate_dims() { + // 2×2 anchors, tess 4 → (2-1)*4+1 = 5 per side. + let b = BezierWarp::from_anchors( + 2, + 2, + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], + 4, + ); + let m = b.tessellate(); + assert_eq!((m.cols, m.rows), (5, 5)); + // 3×2 anchors, tess 3 → cols (3-1)*3+1=7, rows (2-1)*3+1=4. + let mut anchors = Vec::new(); + for r in 0..2 { + for c in 0..3 { + anchors.push([c as f32 / 2.0, r as f32]); + } + } + let b = BezierWarp::from_anchors(3, 2, anchors, 3); + let m = b.tessellate(); + assert_eq!((m.cols, m.rows), (7, 4)); + } + + #[test] + fn bezier_tessellate_clamps_to_max() { + // 16 anchors × tess 10 → (16-1)*10+1 = 151 → clamps to MAX. + let anchors: Vec<[f32; 2]> = (0..16 * 16).map(|_| [0.0, 0.0]).collect(); + let b = BezierWarp::from_anchors(16, 16, anchors, 10); + let m = b.tessellate(); + assert_eq!(m.cols, MAX_WARP_SUBDIVISIONS); + assert_eq!(m.rows, MAX_WARP_SUBDIVISIONS); + } + + #[test] + fn bezier_move_anchor_shifts_incident_handles() { + // 3×3 straight cage; move the centre anchor and check an incident handle + // moved by the same delta (curvature preserved). + let mut anchors = Vec::new(); + for r in 0..3 { + for c in 0..3 { + anchors.push([c as f32 / 2.0, r as f32 / 2.0]); + } + } + let mut b = BezierWarp::from_anchors(3, 3, anchors, 4); + // Horizontal edge to the right of centre (row 1, edge 1), near-left + // handle. Index = row*(cols-1) + edge = 1*(3-1) + 1 = 3. + let hidx = 3; + let before = b.h_horiz[hidx][0]; + let old = b.anchor(1, 1); + b.move_anchor(1, 1, [old[0] + 0.1, old[1] - 0.05]); + let after = b.h_horiz[hidx][0]; + assert!(approx2(after, [before[0] + 0.1, before[1] - 0.05])); + assert!(approx2(b.anchor(1, 1), [old[0] + 0.1, old[1] - 0.05])); + } + + #[test] + fn bezier_move_handle_curves_the_edge() { + // Pull a top-edge handle far off the chord; the tessellated top row must + // bulge away from the straight line between the two anchors. + let mut b = BezierWarp::from_anchors( + 2, + 2, + vec![[0.0, 0.5], [1.0, 0.5], [0.0, 1.0], [1.0, 1.0]], + 6, + ); + // Straight first: midpoint of the top row sits on y=0.5. + let m0 = b.tessellate(); + let mid0 = m0.points[m0.cols as usize / 2].position; + assert!((mid0[1] - 0.5).abs() < 1e-3); + // Curve the top edge upward via both handles. + b.move_handle(true, 0, 0, 0, [0.33, 0.1]); + b.move_handle(true, 0, 0, 1, [0.66, 0.1]); + let m1 = b.tessellate(); + let mid1 = m1.points[m1.cols as usize / 2].position; + assert!(mid1[1] < 0.4, "top edge should bulge upward, got {mid1:?}"); + } + + #[test] + fn bezier_set_cage_subdivisions_changes_count_and_keeps_shape() { + // Straight 2×2 cage over [0,1]²; subdivide to 3×3. New anchors lie on the + // (still-flat) surface, i.e. a uniform grid. + let mut b = BezierWarp::from_anchors( + 2, + 2, + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], + 4, + ); + b.set_cage_subdivisions(3, 3); + assert_eq!((b.anchor_cols, b.anchor_rows), (3, 3)); + assert_eq!(b.anchors.len(), 9); + assert!(approx2(b.anchor(1, 1), [0.5, 0.5])); + assert!(b.is_identity()); + } + + #[test] + fn warp_mode_bezier_accessors() { + let b = BezierWarp::from_mesh(&WarpMesh::identity(2, 2), 4); + let mode = WarpMode::Bezier(b); + assert!(mode.corners().is_none()); + assert!(mode.is_identity([0.0, 0.0, 1.0, 1.0])); + // render_mesh tessellates the cage. + let rm = mode.render_mesh().expect("bezier has a render mesh"); + assert_eq!((rm.cols, rm.rows), (5, 5)); + } + + #[test] + fn warp_mode_bezier_serialization_roundtrip() { + let mode = WarpMode::Bezier(BezierWarp::from_anchors( + 3, + 2, + vec![ + [0.0, 0.0], + [0.5, 0.0], + [1.0, 0.0], + [0.0, 1.0], + [0.5, 1.0], + [1.0, 1.0], + ], + 5, + )); + let json = serde_json::to_string(&mode).unwrap(); + let back: WarpMode = serde_json::from_str(&json).unwrap(); + match back { + WarpMode::Bezier(b) => { + assert_eq!((b.anchor_cols, b.anchor_rows), (3, 2)); + assert_eq!(b.tess, 5); + assert_eq!(b.anchors.len(), 6); + } + other => panic!("expected Bezier, got {other:?}"), + } + } } diff --git a/src/internal/scene/mod.rs b/src/internal/scene/mod.rs index af3a343..746419e 100644 --- a/src/internal/scene/mod.rs +++ b/src/internal/scene/mod.rs @@ -496,11 +496,16 @@ impl OutputConfig { } } -/// Per-surface warp calibration in an output. +/// Membership of a surface in an output (persisted). Warp now lives on the +/// surface (`Surface.warp`); `legacy_warp_mode` exists only to migrate +/// pre-8i.5 files that stored warp here. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SurfaceAssignmentConfig { pub surface_uuid: String, - pub warp_mode: crate::renderer::warp::WarpMode, + /// LEGACY (pre-8i.5): warp used to live on the assignment. Read at load for + /// one-time migration onto `Surface.warp`, then dropped (never re-saved). + #[serde(default, rename = "warp_mode", skip_serializing)] + pub legacy_warp_mode: Option, #[serde(default = "default_true")] pub enabled: bool, } @@ -1354,4 +1359,39 @@ mod tests { _ => panic!("Expected Recording target"), } } + + // ── Per-surface warp migration (8i.5) ──────────────────────────── + + /// Pre-8i.5 files stored warp on the assignment under `warp_mode`; it must + /// still deserialize (into `legacy_warp_mode`) so load-time migration can + /// move it onto the surface. + #[test] + fn assignment_config_reads_legacy_warp_mode() { + let json = r#"{"surface_uuid":"s1","warp_mode":{"type":"CornerPin","corners":[[0,0],[1,0],[1,1],[0,1]]},"enabled":true}"#; + let cfg: SurfaceAssignmentConfig = serde_json::from_str(json).unwrap(); + assert!( + matches!( + cfg.legacy_warp_mode, + Some(crate::renderer::warp::WarpMode::CornerPin { .. }) + ), + "legacy warp_mode should deserialize for migration" + ); + } + + /// New files must NOT re-serialize the legacy warp field. + #[test] + fn assignment_config_drops_legacy_warp_on_save() { + let cfg = SurfaceAssignmentConfig { + surface_uuid: "s1".into(), + legacy_warp_mode: Some(crate::renderer::warp::WarpMode::identity_corners([ + 0.0, 0.0, 1.0, 1.0, + ])), + enabled: true, + }; + let json = serde_json::to_string(&cfg).unwrap(); + assert!( + !json.contains("warp_mode"), + "legacy warp_mode must not be re-serialized: {json}" + ); + } } diff --git a/src/internal/surface/curve.rs b/src/internal/surface/curve.rs new file mode 100644 index 0000000..d5f0364 --- /dev/null +++ b/src/internal/surface/curve.rs @@ -0,0 +1,555 @@ +//! Curve authoring + flattening for 2D surfaces. +//! +//! A [`SurfacePath`] is an optional authoring layer on a `Surface`: an ordered +//! list of line / cubic-bezier segments. It is flattened to the polygon in +//! `Surface::vertices` (the single source of truth every downstream consumer +//! reads) whenever the path is edited — mirroring the `CircleHint` pattern. +//! +//! This is the one shared bezier flattener: SVG import and on-canvas bezier +//! editing both go through here, so there is a single sampling convention and +//! no parallel curve math elsewhere in the codebase. + +use serde::{Deserialize, Serialize}; + +/// Default subdivision counts for bezier sampling, matched to the historical +/// SVG import behavior so detection output stays stable. +pub const QUAD_STEPS: usize = 8; +pub const CUBIC_STEPS: usize = 12; + +/// One segment of a [`SurfacePath`]. Each segment ends at `to`; its start is the +/// previous segment's endpoint (or the path's `start` for the first segment). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +pub enum PathSegment { + /// Straight line to `to`. + Line { to: [f32; 2] }, + /// Cubic bezier with control points `c1`, `c2`, ending at `to`. + Cubic { + c1: [f32; 2], + c2: [f32; 2], + to: [f32; 2], + }, +} + +impl PathSegment { + /// The endpoint this segment terminates at. + pub fn end(&self) -> [f32; 2] { + match self { + PathSegment::Line { to } => *to, + PathSegment::Cubic { to, .. } => *to, + } + } +} + +/// Which control point of a [`PathSegment::Cubic`] a handle refers to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub enum CubicHandle { + /// Control point leaving the segment's start anchor. + C1, + /// Control point entering the segment's end anchor. + C2, +} + +/// An editable curve outline for a surface, in normalized canvas coords [0..1]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SurfacePath { + /// Starting point of the path (first vertex of the flattened polygon). + pub start: [f32; 2], + /// Ordered segments; each continues from the previous endpoint. + pub segments: Vec, + /// Whether the outline is closed. Surfaces render closed regardless; this + /// records authoring intent for edit-time handles. + #[serde(default = "default_true")] + pub closed: bool, +} + +fn default_true() -> bool { + true +} + +impl SurfacePath { + /// Flatten this path to a polygon vertex list for `Surface::vertices`. + /// + /// The `start` point is emitted first, followed by the sampled points of + /// each segment. The closing point is not duplicated — surfaces close + /// implicitly at render time. + pub fn flatten(&self) -> Vec<[f32; 2]> { + let mut out: Vec<[f32; 2]> = Vec::with_capacity(1 + self.segments.len()); + out.push(self.start); + let mut last = self.start; + for seg in &self.segments { + match seg { + PathSegment::Line { to } => out.push(*to), + PathSegment::Cubic { c1, c2, to } => { + out.extend(flatten_cubic(last, *c1, *c2, *to, CUBIC_STEPS)); + } + } + last = seg.end(); + } + // A closed path's final segment returns to `start`; drop the duplicated + // closing point so the polygon closes implicitly at render time. + if self.closed && out.len() > 1 { + let l = *out.last().unwrap(); + if (l[0] - self.start[0]).abs() < 1e-6 && (l[1] - self.start[1]).abs() < 1e-6 { + out.pop(); + } + } + out + } + + /// Build a closed path of straight-line segments from a polygon's vertices. + /// + /// Every edge — including the closing edge back to the first vertex — becomes + /// an explicit segment, so each edge is individually addressable for bezier + /// editing. `flatten` drops the duplicated closing point. + pub fn from_polygon(verts: &[[f32; 2]], closed: bool) -> Self { + let start = verts.first().copied().unwrap_or([0.0, 0.0]); + let mut segments = Vec::with_capacity(verts.len()); + for v in verts.iter().skip(1) { + segments.push(PathSegment::Line { to: *v }); + } + if closed && verts.len() > 1 { + segments.push(PathSegment::Line { to: start }); + } + SurfacePath { + start, + segments, + closed, + } + } + + /// Returns `true` if any segment is a cubic bezier (rather than a straight + /// line) — i.e. the path carries curvature worth preserving. + pub fn has_cubic(&self) -> bool { + self.segments + .iter() + .any(|s| matches!(s, PathSegment::Cubic { .. })) + } + + /// Apply `f` to every point of the path — `start` plus each segment's control + /// points and endpoint. Used for normalization and affine transforms. + pub fn apply_map(&mut self, f: impl Fn([f32; 2]) -> [f32; 2]) { + self.start = f(self.start); + for seg in &mut self.segments { + match seg { + PathSegment::Line { to } => *to = f(*to), + PathSegment::Cubic { c1, c2, to } => { + *c1 = f(*c1); + *c2 = f(*c2); + *to = f(*to); + } + } + } + } + + /// Number of addressable edges (one per segment). + pub fn edge_count(&self) -> usize { + self.segments.len() + } + + /// Number of distinct on-curve anchor points. A closed path's final segment + /// returns to anchor 0, so it has one anchor per segment; an open path has + /// one more anchor than segments (the start plus each endpoint). + pub fn anchor_count(&self) -> usize { + if self.closed { + self.segments.len() + } else { + self.segments.len() + 1 + } + } + + /// Position of anchor `idx`. Anchor 0 is `start`; anchor `i` is the endpoint + /// of segment `i - 1`. + pub fn anchor_pos(&self, idx: usize) -> [f32; 2] { + if idx == 0 { + self.start + } else { + self.segments + .get(idx - 1) + .map(|s| s.end()) + .unwrap_or(self.start) + } + } + + /// Start point of segment `idx` (the previous segment's endpoint, or `start`). + pub fn segment_start(&self, idx: usize) -> [f32; 2] { + if idx == 0 { + self.start + } else { + self.segments + .get(idx - 1) + .map(|s| s.end()) + .unwrap_or(self.start) + } + } + + /// Whether edge `idx` is a cubic bezier. + pub fn is_edge_cubic(&self, idx: usize) -> bool { + matches!(self.segments.get(idx), Some(PathSegment::Cubic { .. })) + } + + /// Convert edge `idx` from a line to a cubic, seeding the control points at + /// the 1/3 and 2/3 points so the initial curve is visually identical. + pub fn convert_edge_to_cubic(&mut self, idx: usize) { + if idx >= self.segments.len() { + return; + } + if !matches!(self.segments[idx], PathSegment::Line { .. }) { + return; + } + let s = self.segment_start(idx); + let e = self.segments[idx].end(); + let c1 = [s[0] + (e[0] - s[0]) / 3.0, s[1] + (e[1] - s[1]) / 3.0]; + let c2 = [ + s[0] + 2.0 * (e[0] - s[0]) / 3.0, + s[1] + 2.0 * (e[1] - s[1]) / 3.0, + ]; + self.segments[idx] = PathSegment::Cubic { c1, c2, to: e }; + } + + /// Convert edge `idx` back to a straight line, discarding control points. + pub fn convert_edge_to_line(&mut self, idx: usize) { + if let Some(seg) = self.segments.get_mut(idx) { + *seg = PathSegment::Line { to: seg.end() }; + } + } + + /// Move anchor `idx` to `pos`, dragging the adjacent segments' control + /// handles along by the same delta so the local curvature is preserved. + pub fn move_anchor(&mut self, idx: usize, pos: [f32; 2]) { + let n = self.segments.len(); + if n == 0 { + self.start = pos; + return; + } + let old = self.anchor_pos(idx); + let d = [pos[0] - old[0], pos[1] - old[1]]; + // Incoming segment (ends at this anchor): move its endpoint and c2. + let incoming = (idx + n - 1) % n; + if let Some(seg) = self.segments.get_mut(incoming) { + match seg { + PathSegment::Line { to } => *to = pos, + PathSegment::Cubic { c2, to, .. } => { + *to = pos; + c2[0] += d[0]; + c2[1] += d[1]; + } + } + } + // Outgoing segment (starts at this anchor): move its c1. + if let Some(PathSegment::Cubic { c1, .. }) = self.segments.get_mut(idx % n) { + c1[0] += d[0]; + c1[1] += d[1]; + } + if idx == 0 { + self.start = pos; + } + } + + /// Move a cubic control handle of segment `idx` to `pos`. No-op if the + /// segment is not a cubic. + pub fn move_handle(&mut self, idx: usize, handle: CubicHandle, pos: [f32; 2]) { + if let Some(PathSegment::Cubic { c1, c2, .. }) = self.segments.get_mut(idx) { + match handle { + CubicHandle::C1 => *c1 = pos, + CubicHandle::C2 => *c2 = pos, + } + } + } + + /// Sample edge `idx` into points for hit-testing / drawing. Includes the + /// segment's start point followed by `steps` sampled points; a line edge + /// returns just its two endpoints. + pub fn sample_edge(&self, idx: usize, steps: usize) -> Vec<[f32; 2]> { + let s = self.segment_start(idx); + match self.segments.get(idx) { + Some(PathSegment::Line { to }) => vec![s, *to], + Some(PathSegment::Cubic { c1, c2, to }) => { + let mut pts = Vec::with_capacity(1 + steps); + pts.push(s); + pts.extend(flatten_cubic(s, *c1, *c2, *to, steps)); + pts + } + None => vec![s], + } + } +} + +/// Sample a quadratic bezier, emitting `steps` points for t in (0, 1]. The +/// start point `p0` is not included (callers already hold it). +pub fn flatten_quad(p0: [f32; 2], ctrl: [f32; 2], p1: [f32; 2], steps: usize) -> Vec<[f32; 2]> { + let steps = steps.max(1); + (1..=steps) + .map(|i| { + let t = i as f32 / steps as f32; + let inv = 1.0 - t; + [ + inv * inv * p0[0] + 2.0 * inv * t * ctrl[0] + t * t * p1[0], + inv * inv * p0[1] + 2.0 * inv * t * ctrl[1] + t * t * p1[1], + ] + }) + .collect() +} + +/// Exactly convert a quadratic bezier's control point into the two control +/// points of an equivalent cubic bezier. Lets quads captured from SVG import be +/// stored as [`PathSegment::Cubic`] (the only curved segment kind) losslessly. +pub fn quad_to_cubic(p0: [f32; 2], ctrl: [f32; 2], p1: [f32; 2]) -> ([f32; 2], [f32; 2]) { + let c1 = [ + p0[0] + 2.0 / 3.0 * (ctrl[0] - p0[0]), + p0[1] + 2.0 / 3.0 * (ctrl[1] - p0[1]), + ]; + let c2 = [ + p1[0] + 2.0 / 3.0 * (ctrl[0] - p1[0]), + p1[1] + 2.0 / 3.0 * (ctrl[1] - p1[1]), + ]; + (c1, c2) +} + +/// Sample a cubic bezier, emitting `steps` points for t in (0, 1]. The start +/// point `p0` is not included (callers already hold it). +pub fn flatten_cubic( + p0: [f32; 2], + c1: [f32; 2], + c2: [f32; 2], + p1: [f32; 2], + steps: usize, +) -> Vec<[f32; 2]> { + let steps = steps.max(1); + (1..=steps) + .map(|i| { + let t = i as f32 / steps as f32; + let inv = 1.0 - t; + let a = inv * inv * inv; + let b = 3.0 * inv * inv * t; + let c = 3.0 * inv * t * t; + let d = t * t * t; + [ + a * p0[0] + b * c1[0] + c * c2[0] + d * p1[0], + a * p0[1] + b * c1[1] + c * c2[1] + d * p1[1], + ] + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx(a: [f32; 2], b: [f32; 2]) -> bool { + (a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4 + } + + #[test] + fn cubic_emits_steps_points_ending_at_p1() { + let pts = flatten_cubic([0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0], CUBIC_STEPS); + assert_eq!(pts.len(), CUBIC_STEPS); + assert!(approx(*pts.last().unwrap(), [3.0, 0.0])); + // Colinear control points → straight line: all y ~ 0. + assert!(pts.iter().all(|p| p[1].abs() < 1e-4)); + } + + #[test] + fn quad_emits_steps_points_ending_at_p1() { + let pts = flatten_quad([0.0, 0.0], [1.0, 1.0], [2.0, 0.0], QUAD_STEPS); + assert_eq!(pts.len(), QUAD_STEPS); + assert!(approx(*pts.last().unwrap(), [2.0, 0.0])); + } + + #[test] + fn min_one_step() { + assert_eq!( + flatten_cubic([0.0; 2], [0.0; 2], [0.0; 2], [1.0, 0.0], 0).len(), + 1 + ); + assert_eq!(flatten_quad([0.0; 2], [0.0; 2], [1.0, 0.0], 0).len(), 1); + } + + #[test] + fn path_of_lines_flattens_to_polygon() { + let path = SurfacePath { + start: [0.0, 0.0], + segments: vec![ + PathSegment::Line { to: [1.0, 0.0] }, + PathSegment::Line { to: [1.0, 1.0] }, + PathSegment::Line { to: [0.0, 1.0] }, + ], + closed: true, + }; + // start + 3 line endpoints, no duplicated closing point. + assert_eq!( + path.flatten(), + vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] + ); + } + + #[test] + fn path_with_cubic_expands_segment() { + let path = SurfacePath { + start: [0.0, 0.0], + segments: vec![PathSegment::Cubic { + c1: [1.0, 0.0], + c2: [2.0, 0.0], + to: [3.0, 0.0], + }], + closed: false, + }; + let verts = path.flatten(); + // start + CUBIC_STEPS sampled points. + assert_eq!(verts.len(), 1 + CUBIC_STEPS); + assert!(approx(verts[0], [0.0, 0.0])); + assert!(approx(*verts.last().unwrap(), [3.0, 0.0])); + } + + #[test] + fn closed_defaults_true_on_deserialize() { + // Old-style payload without `closed` → defaults to true. + let json = r#"{"start":[0.0,0.0],"segments":[]}"#; + let path: SurfacePath = serde_json::from_str(json).unwrap(); + assert!(path.closed); + } + + fn square() -> SurfacePath { + SurfacePath::from_polygon(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], true) + } + + #[test] + fn from_polygon_makes_a_segment_per_edge_including_closing() { + let p = square(); + // 4 vertices → 4 edges (last returns to start). + assert_eq!(p.edge_count(), 4); + assert_eq!(p.anchor_count(), 4); + assert_eq!(p.segments.last().unwrap().end(), [0.0, 0.0]); + } + + #[test] + fn closing_edge_dedups_in_flatten() { + // start + 4 endpoints, but the closing point == start is dropped. + assert_eq!( + square().flatten(), + vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] + ); + } + + #[test] + fn convert_edge_roundtrips_line_cubic_line() { + let mut p = square(); + assert!(!p.is_edge_cubic(0)); + p.convert_edge_to_cubic(0); + assert!(p.is_edge_cubic(0)); + // Colinear seeded controls → edge 0 stays on the original line (y≈0). + assert!(p.sample_edge(0, 12).iter().all(|v| v[1].abs() < 1e-4)); + p.convert_edge_to_line(0); + assert!(!p.is_edge_cubic(0)); + } + + #[test] + fn anchor_pos_indexes_start_then_endpoints() { + let p = square(); + assert!(approx(p.anchor_pos(0), [0.0, 0.0])); + assert!(approx(p.anchor_pos(1), [1.0, 0.0])); + assert!(approx(p.anchor_pos(3), [0.0, 1.0])); + } + + #[test] + fn move_anchor_zero_updates_start_and_closing_segment() { + let mut p = square(); + p.move_anchor(0, [0.1, 0.1]); + assert!(approx(p.start, [0.1, 0.1])); + // Closing (incoming) segment now ends at the moved anchor. + assert!(approx(p.segments.last().unwrap().end(), [0.1, 0.1])); + // Outgoing edge start follows via `start`. + assert!(approx(p.segment_start(0), [0.1, 0.1])); + } + + #[test] + fn move_anchor_drags_adjacent_cubic_handles() { + let mut p = square(); + p.convert_edge_to_cubic(0); // outgoing from anchor 0 + p.convert_edge_to_cubic(3); // incoming to anchor 0 (closing edge) + let d = [0.2, 0.3]; + // Capture pre-move control points. + let (c1_before, c2_before) = match (p.segments[0], p.segments[3]) { + (PathSegment::Cubic { c1, .. }, PathSegment::Cubic { c2, .. }) => (c1, c2), + _ => unreachable!(), + }; + p.move_anchor(0, [d[0], d[1]]); + match (p.segments[0], p.segments[3]) { + (PathSegment::Cubic { c1, .. }, PathSegment::Cubic { c2, to, .. }) => { + assert!(approx(c1, [c1_before[0] + d[0], c1_before[1] + d[1]])); + assert!(approx(c2, [c2_before[0] + d[0], c2_before[1] + d[1]])); + assert!(approx(to, d)); + } + _ => unreachable!(), + } + } + + #[test] + fn move_handle_sets_control_point() { + let mut p = square(); + p.convert_edge_to_cubic(0); + p.move_handle(0, CubicHandle::C1, [0.5, 0.5]); + p.move_handle(0, CubicHandle::C2, [0.6, 0.4]); + match p.segments[0] { + PathSegment::Cubic { c1, c2, .. } => { + assert!(approx(c1, [0.5, 0.5])); + assert!(approx(c2, [0.6, 0.4])); + } + _ => unreachable!(), + } + } + + #[test] + fn move_handle_noop_on_line() { + let mut p = square(); + p.move_handle(0, CubicHandle::C1, [0.5, 0.5]); + assert!(!p.is_edge_cubic(0)); + } + + #[test] + fn sample_edge_line_is_two_points() { + assert_eq!(square().sample_edge(0, 8).len(), 2); + } + + #[test] + fn sample_edge_cubic_includes_start_plus_steps() { + let mut p = square(); + p.convert_edge_to_cubic(0); + assert_eq!(p.sample_edge(0, 6).len(), 1 + 6); + } + + #[test] + fn has_cubic_reflects_segment_kinds() { + let mut p = square(); + assert!(!p.has_cubic()); + p.convert_edge_to_cubic(0); + assert!(p.has_cubic()); + } + + #[test] + fn apply_map_transforms_all_points() { + let mut p = square(); + p.convert_edge_to_cubic(0); + p.apply_map(|[x, y]| [x + 1.0, y + 2.0]); + assert!(approx(p.start, [1.0, 2.0])); + if let PathSegment::Cubic { c1, c2, to } = p.segments[0] { + // Control points and endpoint all shifted by the same offset. + assert!(c1[1] >= 2.0 && c2[1] >= 2.0); + assert!(approx(to, [2.0, 2.0])); + } else { + panic!("edge 0 should be cubic after conversion"); + } + } + + #[test] + fn quad_to_cubic_matches_quadratic_curve() { + // Sampling the derived cubic must equal sampling the original quadratic. + let (p0, ctrl, p1) = ([0.0, 0.0], [1.0, 2.0], [2.0, 0.0]); + let (c1, c2) = quad_to_cubic(p0, ctrl, p1); + let quad = flatten_quad(p0, ctrl, p1, 8); + let cubic = flatten_cubic(p0, c1, c2, p1, 8); + assert_eq!(quad.len(), cubic.len()); + for (q, c) in quad.iter().zip(cubic.iter()) { + assert!(approx(*q, *c), "quad {:?} vs cubic {:?}", q, c); + } + } +} diff --git a/src/internal/surface/detect.rs b/src/internal/surface/detect.rs index 1dec227..4770b52 100644 --- a/src/internal/surface/detect.rs +++ b/src/internal/surface/detect.rs @@ -16,6 +16,10 @@ pub struct DetectedContour { pub circle_fit: Option<([f32; 2], f32)>, /// Auto-generated name based on position (e.g. "top-left-1"). pub suggested_name: String, + /// Editable curve outline captured during SVG import (control points + /// preserved). `None` for raster/DXF detection, which produce polylines only. + #[serde(default)] + pub path: Option, } /// Method used to produce the binary image for contour detection. @@ -193,6 +197,7 @@ pub fn detect_contours(img: &image::GrayImage, params: &DetectionParams) -> Dete is_circular, circle_fit, suggested_name, + path: None, }); } diff --git a/src/internal/surface/import.rs b/src/internal/surface/import.rs index 9fef1dc..3ca5696 100644 --- a/src/internal/surface/import.rs +++ b/src/internal/surface/import.rs @@ -4,6 +4,7 @@ use std::io::Cursor; use usvg::tiny_skia_path; +use super::curve::{quad_to_cubic, PathSegment, SurfacePath}; use super::detect::{ check_circularity, detect_contours, shoelace_area, suggest_name, DetectedContour, DetectionParams, DetectionResult, @@ -133,18 +134,25 @@ pub fn detect_from_file( // ── SVG import ───────────────────────────────────────────────────── /// Detect surfaces from SVG data (extracts geometric paths directly). +/// +/// Cubic/quadratic bezier control points are preserved into a [`SurfacePath`] on +/// each detected contour, so curved outlines import as first-class editable +/// curves rather than pre-flattened polygons. pub fn detect_from_svg(svg_data: &[u8]) -> Result { let tree = usvg::Tree::from_data(svg_data, &usvg::Options::default()) .map_err(|e| ImportError::SvgParse(e.to_string()))?; - let mut polylines: Vec> = Vec::new(); - walk_svg_group(tree.root(), &mut polylines); + let mut paths: Vec = Vec::new(); + walk_svg_group(tree.root(), &mut paths); - if polylines.is_empty() { + if paths.is_empty() { return Err(ImportError::NoContours); } - // Compute bounding box of all points for normalization + // Flatten each path once for bounding-box, area, and circularity checks. + let polylines: Vec> = paths.iter().map(|p| p.flatten()).collect(); + + // Compute bounding box of all points for normalization. let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); for poly in &polylines { @@ -157,13 +165,11 @@ pub fn detect_from_svg(svg_data: &[u8]) -> Result } let width = (max_x - min_x).max(1e-6); let height = (max_y - min_y).max(1e-6); + let normalize = |pt: [f32; 2]| [(pt[0] - min_x) / width, (pt[1] - min_y) / height]; let mut contours = Vec::new(); - for (i, poly) in polylines.iter().enumerate() { - let normalized: Vec<[f32; 2]> = poly - .iter() - .map(|pt| [(pt[0] - min_x) / width, (pt[1] - min_y) / height]) - .collect(); + for (i, (poly, raw_path)) in polylines.iter().zip(paths.iter()).enumerate() { + let normalized: Vec<[f32; 2]> = poly.iter().map(|pt| normalize(*pt)).collect(); if normalized.len() < 3 { continue; } @@ -176,12 +182,17 @@ pub fn detect_from_svg(svg_data: &[u8]) -> Result let cx: f32 = normalized.iter().map(|v| v[0]).sum::() / normalized.len() as f32; let cy: f32 = normalized.iter().map(|v| v[1]).sum::() / normalized.len() as f32; let suggested_name = suggest_name([cx, cy], i); + // Normalize the path's control points with the same transform as the + // flattened vertices so both spaces stay in sync. + let mut path = raw_path.clone(); + path.apply_map(normalize); contours.push(DetectedContour { vertices: normalized, area, is_circular, circle_fit, suggested_name, + path: Some(path), }); } @@ -202,15 +213,18 @@ pub fn detect_from_svg(svg_data: &[u8]) -> Result }) } -/// Recursively walk a usvg Group, extracting polylines from Path nodes. -fn walk_svg_group(group: &usvg::Group, out: &mut Vec>) { +/// Recursively walk a usvg Group, extracting an editable [`SurfacePath`] from +/// each Path node. +fn walk_svg_group(group: &usvg::Group, out: &mut Vec) { for node in group.children() { match node { usvg::Node::Group(ref g) => walk_svg_group(g, out), usvg::Node::Path(ref p) => { - let polyline = flatten_svg_path(p.data()); - if polyline.len() >= 3 { - out.push(polyline); + if let Some(path) = svg_path_to_surface_path(p.data()) { + // Require at least a triangle's worth of geometry. + if path.flatten().len() >= 3 { + out.push(path); + } } } _ => {} @@ -218,62 +232,65 @@ fn walk_svg_group(group: &usvg::Group, out: &mut Vec>) { } } -/// Flatten a tiny_skia_path::Path into a polyline by sampling curve segments. -fn flatten_svg_path(path: &tiny_skia_path::Path) -> Vec<[f32; 2]> { - let mut points: Vec<[f32; 2]> = Vec::new(); +/// Convert a `tiny_skia_path::Path` into a closed [`SurfacePath`], preserving +/// bezier control points. Quadratic segments are converted losslessly to cubics +/// (the only curved segment kind). Returns `None` if the path has no drawable +/// segments. Subpaths after the first are joined with straight segments, matching +/// the historical single-outline behavior. +fn svg_path_to_surface_path(path: &tiny_skia_path::Path) -> Option { + let mut start: Option<[f32; 2]> = None; + let mut segments: Vec = Vec::new(); let mut last = [0.0f32; 2]; for seg in path.segments() { match seg { tiny_skia_path::PathSegment::MoveTo(pt) => { - last = [pt.x, pt.y]; - points.push(last); + let p = [pt.x, pt.y]; + if start.is_none() { + start = Some(p); + } else { + segments.push(PathSegment::Line { to: p }); + } + last = p; } tiny_skia_path::PathSegment::LineTo(pt) => { - last = [pt.x, pt.y]; - points.push(last); + let p = [pt.x, pt.y]; + segments.push(PathSegment::Line { to: p }); + last = p; } tiny_skia_path::PathSegment::QuadTo(ctrl, pt) => { - // Sample quadratic Bézier at intermediate points - const STEPS: usize = 8; - for i in 1..=STEPS { - let t = i as f32 / STEPS as f32; - let inv = 1.0 - t; - let x = inv * inv * last[0] + 2.0 * inv * t * ctrl.x + t * t * pt.x; - let y = inv * inv * last[1] + 2.0 * inv * t * ctrl.y + t * t * pt.y; - points.push([x, y]); - } - last = [pt.x, pt.y]; + let p1 = [pt.x, pt.y]; + let (c1, c2) = quad_to_cubic(last, [ctrl.x, ctrl.y], p1); + segments.push(PathSegment::Cubic { c1, c2, to: p1 }); + last = p1; } tiny_skia_path::PathSegment::CubicTo(c1, c2, pt) => { - // Sample cubic Bézier at intermediate points - const STEPS: usize = 12; - for i in 1..=STEPS { - let t = i as f32 / STEPS as f32; - let inv = 1.0 - t; - let x = inv * inv * inv * last[0] - + 3.0 * inv * inv * t * c1.x - + 3.0 * inv * t * t * c2.x - + t * t * t * pt.x; - let y = inv * inv * inv * last[1] - + 3.0 * inv * inv * t * c1.y - + 3.0 * inv * t * t * c2.y - + t * t * t * pt.y; - points.push([x, y]); - } - last = [pt.x, pt.y]; - } - tiny_skia_path::PathSegment::Close => { - // Close the path by connecting back to the first point - if let Some(&first) = points.first() { - if (last[0] - first[0]).abs() > 1e-4 || (last[1] - first[1]).abs() > 1e-4 { - points.push(first); - } - } + let p1 = [pt.x, pt.y]; + segments.push(PathSegment::Cubic { + c1: [c1.x, c1.y], + c2: [c2.x, c2.y], + to: p1, + }); + last = p1; } + tiny_skia_path::PathSegment::Close => {} } } - points + + let start = start?; + if segments.is_empty() { + return None; + } + // Detected outlines are filled regions: close with an explicit segment back + // to the start unless the last point already coincides with it. + if (last[0] - start[0]).abs() > 1e-4 || (last[1] - start[1]).abs() > 1e-4 { + segments.push(PathSegment::Line { to: start }); + } + Some(SurfacePath { + start, + segments, + closed: true, + }) } // ── DXF import ───────────────────────────────────────────────────── @@ -385,6 +402,7 @@ pub fn detect_from_dxf(dxf_data: &[u8]) -> Result is_circular, circle_fit, suggested_name, + path: None, }); } @@ -540,6 +558,34 @@ mod tests { assert!(!det.contours.is_empty()); } + #[test] + fn detect_from_svg_preserves_cubic_control_points() { + // A path whose top edge is a cubic bezier; the rest are straight lines. + let svg = br#" + + "#; + let det = detect_from_svg(svg).expect("cubic SVG should detect"); + let contour = det.contours.first().expect("one contour"); + let path = contour.path.as_ref().expect("path captured for SVG import"); + assert!( + path.has_cubic(), + "expected at least one cubic segment from the C command" + ); + } + + #[test] + fn detect_from_svg_rect_path_has_no_cubic() { + // A purely straight-line outline keeps a path but with no curvature, so + // it is created as a plain editable polygon (path dropped at confirm). + let svg = br#" + + "#; + let det = detect_from_svg(svg).expect("rect SVG should detect"); + let contour = det.contours.first().expect("one contour"); + let path = contour.path.as_ref().expect("path captured for SVG import"); + assert!(!path.has_cubic(), "a rect has no cubic segments"); + } + #[test] fn detect_from_svg_empty_rejects() { let svg = br#""#; diff --git a/src/internal/surface/mod.rs b/src/internal/surface/mod.rs index bff912d..2cb6a30 100644 --- a/src/internal/surface/mod.rs +++ b/src/internal/surface/mod.rs @@ -7,9 +7,12 @@ //! coordinates [0..1]. Rectangles are just 4-vertex polygons. This supports //! triangles, circles (N-gon approximations), and arbitrary shapes. +pub mod curve; pub mod detect; pub mod import; +pub use curve::{CubicHandle, PathSegment, SurfacePath}; + use crate::deck::generate_short_uuid; use crate::renderer::context::OutputSource; use serde::{Deserialize, Serialize}; @@ -76,10 +79,23 @@ pub struct Surface { /// Vertices are regenerated from the hint when radius or sides change. #[serde(default)] pub circle_hint: Option, - /// Pre-computed warp mesh for dome-generated surfaces. - /// When assigned to an output, this is used instead of identity corners. + /// Per-surface warp (corner-pin or mesh). `None` = no warp (render at the + /// polygon's native position). Promoted from the former `default_warp` + /// template; the serde `alias` keeps pre-8i.5 `.varda` files loading. + #[serde(default, alias = "default_warp")] + pub warp: Option, + /// When `true` (default for surfaces created in-app), the warp auto-conforms + /// to this surface's outline — `effective_warp()` derives it and `warp` is + /// ignored. When `false`, `warp` is authoritative and manually editable. + /// Legacy `.varda` files (no field) load as `false`, preserving any + /// hand-authored `warp` untouched. + #[serde(default)] + pub warp_bound: bool, + /// Optional curve authoring layer. When present, `vertices` is regenerated + /// from this path (flattened) whenever the path is edited — mirroring + /// `circle_hint`. `None` = the polygon in `vertices` is authoritative. #[serde(default)] - pub default_warp: Option, + pub path: Option, } /// How content is mapped onto a surface. @@ -130,7 +146,9 @@ impl Surface { content_mapping: ContentMapping::default(), output_type: SurfaceOutputType::Projection, circle_hint: None, - default_warp: None, + warp: None, + warp_bound: true, + path: None, } } @@ -151,6 +169,205 @@ impl Surface { self.circle_hint = None; } + /// The surface's warp, or an identity corner-pin seeded from its bounding + /// box when it has none. Used as the base for warp editing and rendering. + pub fn warp_or_identity(&self) -> crate::renderer::warp::WarpMode { + self.warp.clone().unwrap_or_else(|| { + let bb = self.bounding_box(); + crate::renderer::warp::WarpMode::identity_corners([bb.x, bb.y, bb.width, bb.height]) + }) + } + + /// Move one corner-pin corner (0..4), seeding an identity corner-pin first + /// when the surface has no warp. No-op if the warp is currently a mesh. + pub fn set_warp_corner(&mut self, corner_idx: usize, position: [f32; 2]) { + if corner_idx >= 4 || matches!(self.warp, Some(crate::renderer::warp::WarpMode::Mesh(_))) { + return; + } + let mut warp = self.warp_or_identity(); + if let Some(corners) = warp.corners_mut() { + corners[corner_idx] = position; + } + self.warp = Some(warp); + } + + /// Clear any warp (back to no-warp / native polygon position). + pub fn reset_warp(&mut self) { + self.warp = None; + } + + /// The warp actually applied when rendering/displaying this surface. While + /// `warp_bound`, it is derived from the shape (`conforming_warp`); otherwise + /// the stored `warp`. Single choke point for render, snapshot, and editor. + pub fn effective_warp(&self) -> Option { + if self.warp_bound { + Some(self.conforming_warp()) + } else { + self.warp.clone() + } + } + + /// A warp whose grid boundary conforms to this surface's outline (Approach + /// B, fill semantics): circles → elliptical disc-map mesh; quads → a 2×2 + /// mesh at the four vertices; other polygons → a Coons-patch mesh over the + /// vertices nearest the bbox corners. + pub fn conforming_warp(&self) -> crate::renderer::warp::WarpMode { + use crate::renderer::warp::{self, WarpMesh, WarpMode}; + if let Some(hint) = &self.circle_hint { + let n = (hint.sides / 4 + 2).clamp(3, warp::MAX_WARP_SUBDIVISIONS); + return WarpMode::Mesh(warp::disc_map_mesh( + hint.center, + hint.radius, + hint.radius * hint.aspect_ratio, + n, + )); + } + let v = &self.vertices; + if v.len() == 4 { + return WarpMode::Mesh(WarpMesh::from_corners(&[v[0], v[1], v[2], v[3]])); + } + let n = (v.len() as u32).clamp(3, 16); + WarpMode::Mesh(warp::coons_mesh(v, n, n)) + } + + /// Bind or unbind the warp from the surface shape. Unbinding materialises + /// the conforming warp into `warp` so fine-tuning starts from the shape; + /// binding clears `warp` (it is re-derived from the shape while bound). + pub fn set_warp_bound(&mut self, bound: bool) { + if bound { + self.warp_bound = true; + self.warp = None; + } else { + self.warp = Some(self.conforming_warp()); + self.warp_bound = false; + } + } + + /// Convert the warp to a `cols` × `rows` mesh, preserving the current + /// deformation. Dimensions clamp to `[2, MAX_WARP_SUBDIVISIONS]`. + pub fn set_warp_subdivisions(&mut self, cols: u32, rows: u32) { + let cols = cols.clamp(2, crate::renderer::warp::MAX_WARP_SUBDIVISIONS); + let rows = rows.clamp(2, crate::renderer::warp::MAX_WARP_SUBDIVISIONS); + let base = self.warp_or_identity(); + self.warp = Some(crate::renderer::warp::WarpMode::Mesh( + base.to_mesh(cols, rows), + )); + } + + /// Move a single mesh grid point (row-major). No-op if the warp is not a mesh. + pub fn set_warp_mesh_point(&mut self, row: usize, col: usize, position: [f32; 2]) { + if let Some(crate::renderer::warp::WarpMode::Mesh(mesh)) = &mut self.warp { + mesh.set_point(row, col, position); + } + } + + /// Convert the current warp into a smooth bezier patch grid (8i.6), seeding + /// the control cage from the current warp's mesh (or an identity 2×2 over the + /// bbox), so the shape is preserved. No-op if the warp is already bezier. + /// Meaningful only while unbound (manual editing); the caller ensures that. + pub fn convert_warp_to_bezier(&mut self) { + use crate::renderer::warp::{BezierWarp, WarpMode, DEFAULT_BEZIER_TESS}; + let base = self.warp_or_identity(); + if matches!(base, WarpMode::Bezier(_)) { + return; + } + let (cols, rows) = match &base { + WarpMode::Mesh(m) => (m.cols, m.rows), + _ => (2, 2), + }; + let mesh = base.to_mesh(cols, rows); + self.warp = Some(WarpMode::Bezier(BezierWarp::from_mesh( + &mesh, + DEFAULT_BEZIER_TESS, + ))); + } + + /// Move a bezier-warp anchor `(row, col)`. No-op if the warp is not bezier. + pub fn set_warp_bezier_anchor(&mut self, row: usize, col: usize, position: [f32; 2]) { + if let Some(crate::renderer::warp::WarpMode::Bezier(b)) = &mut self.warp { + b.move_anchor(row, col, position); + } + } + + /// Move a bezier-warp tangent handle. `horizontal` picks a horizontal edge + /// (`(r,c)→(r,c+1)`) vs a vertical edge (`(r,c)→(r+1,c)`); `which` is 0/1. + /// No-op if the warp is not bezier. + pub fn set_warp_bezier_handle( + &mut self, + horizontal: bool, + row: usize, + col: usize, + which: usize, + position: [f32; 2], + ) { + if let Some(crate::renderer::warp::WarpMode::Bezier(b)) = &mut self.warp { + b.move_handle(horizontal, row, col, which, position); + } + } + + /// Set the bezier-warp control-cage resolution (anchor `cols` × `rows`), + /// resampling onto the current surface. No-op if the warp is not bezier. + pub fn set_bezier_cage_subdivisions(&mut self, cols: u32, rows: u32) { + if let Some(crate::renderer::warp::WarpMode::Bezier(b)) = &mut self.warp { + b.set_cage_subdivisions(cols, rows); + } + } + + /// Whether this surface has a curve authoring path. + pub fn has_path(&self) -> bool { + self.path.is_some() + } + + /// Regenerate vertices from the curve path. No-op if there's no path. + pub fn regenerate_from_path(&mut self) { + if let Some(path) = &self.path { + self.vertices = path.flatten(); + } + } + + /// Ensure a curve authoring path exists, lazily building one from the current + /// polygon vertices. Curve editing supersedes circle regeneration, so any + /// `circle_hint` is dropped. + pub fn ensure_path(&mut self) -> &mut curve::SurfacePath { + if self.path.is_none() { + self.path = Some(curve::SurfacePath::from_polygon(&self.vertices, true)); + self.circle_hint = None; + } + self.path.as_mut().unwrap() + } + + /// Convert edge `edge_idx` of the curve path to a cubic bezier (`to_cubic`) + /// or back to a straight line, regenerating vertices. Lazily creates a path. + pub fn convert_edge(&mut self, edge_idx: usize, to_cubic: bool) { + self.ensure_path(); + if let Some(path) = &mut self.path { + if to_cubic { + path.convert_edge_to_cubic(edge_idx); + } else { + path.convert_edge_to_line(edge_idx); + } + } + self.regenerate_from_path(); + } + + /// Move curve anchor `anchor_idx` to `pos`, regenerating vertices. No-op if + /// the surface has no curve path. + pub fn move_path_anchor(&mut self, anchor_idx: usize, pos: [f32; 2]) { + if let Some(path) = &mut self.path { + path.move_anchor(anchor_idx, pos); + self.regenerate_from_path(); + } + } + + /// Move cubic control `handle` of segment `segment_idx` to `pos`, + /// regenerating vertices. No-op if the surface has no curve path. + pub fn move_path_handle(&mut self, segment_idx: usize, handle: CubicHandle, pos: [f32; 2]) { + if let Some(path) = &mut self.path { + path.move_handle(segment_idx, handle, pos); + self.regenerate_from_path(); + } + } + /// Axis-aligned bounding box of the polygon (including extra contours). pub fn bounding_box(&self) -> BoundingBox { let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); @@ -253,6 +470,83 @@ impl Surface { v[1] += dy; } } + // Keep the curve authoring path in sync so path-backed surfaces move too. + if let Some(path) = &mut self.path { + path.start[0] += dx; + path.start[1] += dy; + for seg in &mut path.segments { + match seg { + PathSegment::Line { to } => { + to[0] += dx; + to[1] += dy; + } + PathSegment::Cubic { c1, c2, to } => { + for p in [c1, c2, to] { + p[0] += dx; + p[1] += dy; + } + } + } + } + } + } + + /// Rotate all geometry by `angle` radians (clockwise in canvas space, y-down) + /// around `pivot`. The curve `path` and `circle_hint` are rotated in step so + /// they stay consistent with `vertices`. A circle hint's center is rotated and + /// its radius/aspect are left unchanged — exact for a true circle; an oriented + /// ellipse is approximated (axis-aligned on the next radius/side regeneration). + /// + /// Unlike [`Surface::translate`], this does not clamp to `[0..1]`: clamping + /// per-vertex would distort the shape, and partially off-canvas surfaces are + /// valid. Callers constrain interactively. + pub fn rotate(&mut self, angle: f32, pivot: [f32; 2]) { + let (s, c) = angle.sin_cos(); + let rot = |p: [f32; 2]| -> [f32; 2] { + let dx = p[0] - pivot[0]; + let dy = p[1] - pivot[1]; + [pivot[0] + dx * c - dy * s, pivot[1] + dx * s + dy * c] + }; + self.map_geometry(rot); + } + + /// Scale all geometry by `(sx, sy)` around `pivot`. The curve `path` and + /// `circle_hint` are scaled in step: the hint's center scales around `pivot`, + /// its `radius` follows the x-scale and its `aspect_ratio` absorbs the x/y + /// difference. Like [`Surface::rotate`], this does not clamp to `[0..1]`. + pub fn scale(&mut self, sx: f32, sy: f32, pivot: [f32; 2]) { + let scl = |p: [f32; 2]| -> [f32; 2] { + [ + pivot[0] + (p[0] - pivot[0]) * sx, + pivot[1] + (p[1] - pivot[1]) * sy, + ] + }; + self.map_geometry(scl); + if let Some(hint) = &mut self.circle_hint { + hint.radius *= sx; + if sx != 0.0 { + hint.aspect_ratio *= sy / sx; + } + } + } + + /// Apply a point transform to every geometry representation (vertices, extra + /// contours, curve path, circle-hint center). Shared by `rotate`/`scale`. + fn map_geometry(&mut self, f: impl Fn([f32; 2]) -> [f32; 2]) { + for v in &mut self.vertices { + *v = f(*v); + } + for contour in &mut self.extra_contours { + for v in contour.iter_mut() { + *v = f(*v); + } + } + if let Some(path) = &mut self.path { + path.apply_map(&f); + } + if let Some(hint) = &mut self.circle_hint { + hint.center = f(hint.center); + } } /// Get a mutable reference to a specific contour's vertices. @@ -347,7 +641,36 @@ impl SurfaceManager { content_mapping: ContentMapping::default(), output_type: SurfaceOutputType::Projection, circle_hint: None, - default_warp: None, + warp: None, + warp_bound: true, + path: None, + }); + uuid + } + + /// Add a surface authored as an editable curve [`SurfacePath`]. Vertices are + /// generated by flattening the path (which stays the authoritative source for + /// downstream routing/warp). Returns the new surface's UUID. + pub fn add_path_surface( + &mut self, + name: String, + path: curve::SurfacePath, + source: OutputSource, + ) -> String { + let uuid = generate_short_uuid(); + let vertices = path.flatten(); + self.surfaces.push(Surface { + uuid: uuid.clone(), + name, + vertices, + extra_contours: Vec::new(), + source, + content_mapping: ContentMapping::default(), + output_type: SurfaceOutputType::Projection, + circle_hint: None, + warp: None, + warp_bound: true, + path: Some(path), }); uuid } @@ -370,7 +693,9 @@ impl SurfaceManager { content_mapping: ContentMapping::default(), output_type: SurfaceOutputType::Projection, circle_hint: Some(hint), - default_warp: None, + warp: None, + warp_bound: true, + path: None, }); uuid } @@ -519,7 +844,9 @@ impl SurfaceManager { content_mapping, output_type, circle_hint: None, - default_warp: None, + warp: None, + warp_bound: true, + path: None, }; let insert_at = first_idx.min(self.surfaces.len()); @@ -604,7 +931,9 @@ mod tests { content_mapping: ContentMapping::default(), output_type: SurfaceOutputType::Projection, circle_hint: None, - default_warp: None, + warp: None, + warp_bound: false, + path: None, }; assert_eq!(s.center(), [0.0, 0.0]); } @@ -635,7 +964,9 @@ mod tests { content_mapping: ContentMapping::default(), output_type: SurfaceOutputType::Projection, circle_hint: None, - default_warp: None, + warp: None, + warp_bound: false, + path: None, }; assert!(!s.contains(0.5, 0.5)); } @@ -681,6 +1012,107 @@ mod tests { assert!(bb.y >= -1e-5); } + // ── Rotate / scale tests ───────────────────────────────────────── + + #[test] + fn rotate_90_maps_axis_around_origin() { + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.rotate(std::f32::consts::FRAC_PI_2, [0.0, 0.0]); + // vertex (1,0) → (0,1) under clockwise (y-down) 90° rotation. + assert!((s.vertices[1][0] - 0.0).abs() < 1e-4); + assert!((s.vertices[1][1] - 1.0).abs() < 1e-4); + } + + #[test] + fn rotate_around_center_preserves_center() { + let mut s = Surface::new_rect("R".into(), 0.2, 0.3, 0.4, 0.2, master_source()); + let c0 = s.center(); + s.rotate(0.7, c0); + let c1 = s.center(); + assert!((c0[0] - c1[0]).abs() < 1e-4); + assert!((c0[1] - c1[1]).abs() < 1e-4); + } + + #[test] + fn scale_around_origin_scales_vertices() { + let mut s = Surface::new_rect("R".into(), 0.1, 0.1, 0.2, 0.2, master_source()); + s.scale(2.0, 2.0, [0.0, 0.0]); + assert!((s.vertices[0][0] - 0.2).abs() < 1e-4); + assert!((s.vertices[2][0] - 0.6).abs() < 1e-4); + assert!((s.vertices[2][1] - 0.6).abs() < 1e-4); + } + + #[test] + fn scale_around_center_preserves_center() { + let mut s = Surface::new_rect("R".into(), 0.2, 0.2, 0.4, 0.4, master_source()); + let c0 = s.center(); + s.scale(1.5, 0.5, c0); + let c1 = s.center(); + assert!((c0[0] - c1[0]).abs() < 1e-4); + assert!((c0[1] - c1[1]).abs() < 1e-4); + let bb = s.bounding_box(); + assert!((bb.width - 0.6).abs() < 1e-4); // 0.4 * 1.5 + assert!((bb.height - 0.2).abs() < 1e-4); // 0.4 * 0.5 + } + + #[test] + fn scale_updates_circle_hint_radius_and_aspect() { + let mut s = Surface::new_rect("C".into(), 0.0, 0.0, 0.4, 0.4, master_source()); + s.circle_hint = Some(CircleHint { + center: [0.2, 0.2], + radius: 0.2, + sides: 8, + aspect_ratio: 1.0, + }); + s.scale(2.0, 3.0, [0.0, 0.0]); + let h = s.circle_hint.unwrap(); + assert!((h.radius - 0.4).abs() < 1e-4); // 0.2 * sx + assert!((h.aspect_ratio - 1.5).abs() < 1e-4); // 1.0 * sy/sx + assert!((h.center[0] - 0.4).abs() < 1e-4); + assert!((h.center[1] - 0.6).abs() < 1e-4); + } + + #[test] + fn rotate_moves_circle_hint_center() { + let mut s = Surface::new_rect("C".into(), 0.0, 0.0, 0.4, 0.4, master_source()); + s.circle_hint = Some(CircleHint { + center: [1.0, 0.0], + radius: 0.2, + sides: 8, + aspect_ratio: 1.0, + }); + s.rotate(std::f32::consts::FRAC_PI_2, [0.0, 0.0]); + let h = s.circle_hint.unwrap(); + assert!((h.center[0] - 0.0).abs() < 1e-4); + assert!((h.center[1] - 1.0).abs() < 1e-4); + assert!((h.radius - 0.2).abs() < 1e-4); // unchanged + } + + #[test] + fn scale_transforms_path_control_points() { + let mut s = Surface::new_rect("P".into(), 0.0, 0.0, 0.4, 0.4, master_source()); + s.path = Some(SurfacePath { + start: [0.0, 0.0], + segments: vec![PathSegment::Cubic { + c1: [1.0, 0.0], + c2: [2.0, 0.0], + to: [3.0, 0.0], + }], + closed: true, + }); + s.scale(2.0, 2.0, [0.0, 0.0]); + let p = s.path.unwrap(); + assert_eq!(p.start, [0.0, 0.0]); + match p.segments[0] { + PathSegment::Cubic { c1, c2, to } => { + assert!((c1[0] - 2.0).abs() < 1e-4); + assert!((c2[0] - 4.0).abs() < 1e-4); + assert!((to[0] - 6.0).abs() < 1e-4); + } + _ => panic!("expected cubic"), + } + } + // ── Nearest vertex tests ───────────────────────────────────────── #[test] @@ -770,7 +1202,9 @@ mod tests { content_mapping: ContentMapping::default(), output_type: SurfaceOutputType::Projection, circle_hint: Some(hint), - default_warp: None, + warp: None, + warp_bound: false, + path: None, }; s.regenerate_circle_vertices(); assert_eq!(s.vertices.len(), 6); @@ -793,7 +1227,9 @@ mod tests { content_mapping: ContentMapping::default(), output_type: SurfaceOutputType::Projection, circle_hint: Some(hint), - default_warp: None, + warp: None, + warp_bound: false, + path: None, }; assert!(s.is_circle()); s.convert_to_polygon(); @@ -801,6 +1237,127 @@ mod tests { assert_eq!(s.vertices.len(), 6); // Vertices preserved } + #[test] + fn surface_regenerate_from_path_flattens() { + let path = SurfacePath { + start: [0.0, 0.0], + segments: vec![ + PathSegment::Line { to: [1.0, 0.0] }, + PathSegment::Line { to: [1.0, 1.0] }, + ], + closed: true, + }; + let mut s = Surface::new_rect("P".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.path = Some(path); + assert!(s.has_path()); + s.regenerate_from_path(); + assert_eq!(s.vertices, vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]); + } + + #[test] + fn surface_without_path_deserializes_from_legacy_json() { + // Legacy stage.json surface (no `path` field) → path defaults to None. + let json = r#"{ + "uuid":"abc12345","name":"Legacy", + "vertices":[[0.0,0.0],[1.0,0.0],[1.0,1.0],[0.0,1.0]], + "source":"Master","content_mapping":"Fill","output_type":"Projection" + }"#; + let s: Surface = serde_json::from_str(json).unwrap(); + assert!(s.path.is_none()); + assert!(!s.has_path()); + assert_eq!(s.vertices.len(), 4); + } + + // ── Auto-warp binding (8i.5a) tests ────────────────────────────── + + #[test] + fn new_surface_is_warp_bound_by_default() { + let s = Surface::new_rect("R".into(), 0.1, 0.1, 0.4, 0.4, master_source()); + assert!(s.warp_bound); + } + + #[test] + fn effective_warp_bound_rect_is_conforming_mesh() { + use crate::renderer::warp::WarpMode; + let s = Surface::new_rect("R".into(), 0.2, 0.3, 0.4, 0.2, master_source()); + // Bound → derived conforming warp (a 2×2 mesh at the four corners), + // regardless of the (empty) stored `warp`. + match s.effective_warp() { + Some(WarpMode::Mesh(m)) => { + assert_eq!((m.cols, m.rows), (2, 2)); + assert_eq!(m.points[0].position, [0.2, 0.3]); + } + other => panic!("expected conforming mesh, got {other:?}"), + } + } + + #[test] + fn effective_warp_unbound_returns_stored_warp() { + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.warp_bound = false; + s.warp = None; + assert!(s.effective_warp().is_none()); + } + + #[test] + fn unbind_materialises_conforming_warp() { + let mut s = Surface::new_rect("R".into(), 0.1, 0.1, 0.5, 0.5, master_source()); + assert!(s.warp.is_none()); + s.set_warp_bound(false); + assert!(!s.warp_bound); + // The shape's conforming warp is now the editable stored warp. + assert!(s.warp.is_some()); + } + + #[test] + fn rebind_clears_stored_warp() { + let mut s = Surface::new_rect("R".into(), 0.1, 0.1, 0.5, 0.5, master_source()); + s.set_warp_bound(false); + assert!(s.warp.is_some()); + s.set_warp_bound(true); + assert!(s.warp_bound); + assert!(s.warp.is_none()); + } + + #[test] + fn circle_conforming_warp_is_mesh() { + use crate::renderer::warp::WarpMode; + let hint = CircleHint { + center: [0.5, 0.5], + radius: 0.3, + sides: 32, + aspect_ratio: 1.0, + }; + let uuid = generate_short_uuid(); + let s = Surface { + uuid, + name: "C".into(), + vertices: hint.generate_vertices(), + extra_contours: vec![], + source: master_source(), + content_mapping: ContentMapping::default(), + output_type: SurfaceOutputType::Projection, + circle_hint: Some(hint), + warp: None, + warp_bound: true, + path: None, + }; + assert!(matches!(s.conforming_warp(), WarpMode::Mesh(_))); + } + + #[test] + fn legacy_json_loads_unbound_preserving_warp() { + // Pre-8i.5a file: no `warp_bound`, so it must default to false so any + // stored warp stays authoritative. + let json = r#"{ + "uuid":"abc12345","name":"Legacy", + "vertices":[[0.0,0.0],[1.0,0.0],[1.0,1.0],[0.0,1.0]], + "source":"Master","content_mapping":"Fill","output_type":"Projection" + }"#; + let s: Surface = serde_json::from_str(json).unwrap(); + assert!(!s.warp_bound); + } + // ── Contour tests ──────────────────────────────────────────────── #[test] @@ -841,6 +1398,48 @@ mod tests { assert_eq!(s.uuid.len(), 8); } + // ── Bezier edge editing (8i.4) ─────────────────────────────────── + + #[test] + fn convert_edge_lazily_builds_path_and_regenerates() { + let mut s = Surface::new_rect("C".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + assert!(!s.has_path()); + s.convert_edge(0, true); + assert!(s.has_path()); + assert!(s.path.as_ref().unwrap().is_edge_cubic(0)); + // Cubic edge 0 tessellates into more vertices than the original 4. + assert!(s.vertices.len() > 4); + } + + #[test] + fn ensure_path_clears_circle_hint() { + let mut s = Surface::new_rect("C".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.circle_hint = Some(CircleHint { + center: [0.5, 0.5], + radius: 0.5, + sides: 8, + aspect_ratio: 1.0, + }); + s.ensure_path(); + assert!(s.circle_hint.is_none()); + } + + #[test] + fn move_path_anchor_updates_vertices() { + let mut s = Surface::new_rect("C".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.ensure_path(); + s.move_path_anchor(0, [0.2, 0.3]); + assert!((s.vertices[0][0] - 0.2).abs() < 1e-5); + assert!((s.vertices[0][1] - 0.3).abs() < 1e-5); + } + + #[test] + fn move_path_handle_noop_without_path() { + let mut s = Surface::new_rect("C".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.move_path_handle(0, CubicHandle::C1, [0.5, 0.5]); + assert!(!s.has_path()); + } + #[test] fn manager_add_surface() { let mut mgr = SurfaceManager::new(); @@ -850,6 +1449,22 @@ mod tests { assert_eq!(mgr.surfaces[0].uuid, uuid); } + #[test] + fn manager_add_path_surface_attaches_path_and_flattens() { + let mut mgr = SurfaceManager::new(); + let mut path = curve::SurfacePath::from_polygon( + &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], + true, + ); + path.convert_edge_to_cubic(0); + let uuid = mgr.add_path_surface("Curved".into(), path, master_source()); + let s = mgr.surfaces.iter().find(|s| s.uuid == uuid).unwrap(); + assert!(s.has_path()); + assert!(s.path.as_ref().unwrap().has_cubic()); + // A cubic edge flattens into more vertices than the raw 4 corners. + assert!(s.vertices.len() > 4); + } + #[test] fn manager_remove_surface() { let mut mgr = SurfaceManager::new(); @@ -1008,4 +1623,160 @@ mod tests { fn content_mapping_default() { assert_eq!(ContentMapping::default(), ContentMapping::Fill); } + + // ── Per-surface warp editing (8i.5) ─────────────────────────────── + + #[test] + fn warp_defaults_to_none() { + let s = Surface::new_rect("R".into(), 0.1, 0.1, 0.4, 0.3, master_source()); + assert!(s.warp.is_none()); + } + + #[test] + fn set_warp_corner_seeds_identity_then_moves() { + use crate::renderer::warp::WarpMode; + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_corner(0, [0.2, 0.3]); + match s.warp { + Some(WarpMode::CornerPin { corners }) => assert_eq!(corners[0], [0.2, 0.3]), + _ => panic!("expected corner-pin warp"), + } + } + + #[test] + fn set_warp_corner_ignored_out_of_range() { + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_corner(4, [0.2, 0.3]); + assert!(s.warp.is_none()); + } + + #[test] + fn set_warp_subdivisions_makes_mesh_and_clamps() { + use crate::renderer::warp::{WarpMode, MAX_WARP_SUBDIVISIONS}; + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_subdivisions(1, 1000); + match s.warp { + Some(WarpMode::Mesh(mesh)) => { + assert_eq!(mesh.cols, 2); + assert_eq!(mesh.rows, MAX_WARP_SUBDIVISIONS); + } + _ => panic!("expected mesh warp"), + } + } + + #[test] + fn set_warp_corner_noop_on_mesh() { + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_subdivisions(3, 3); + let before = format!("{:?}", s.warp); + s.set_warp_corner(0, [0.9, 0.9]); + assert_eq!(before, format!("{:?}", s.warp)); + } + + #[test] + fn set_warp_mesh_point_moves_point() { + use crate::renderer::warp::WarpMode; + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_subdivisions(3, 3); + s.set_warp_mesh_point(1, 1, [0.55, 0.55]); + match s.warp { + Some(WarpMode::Mesh(mesh)) => { + let p = mesh.points[mesh.cols as usize + 1].position; + assert!((p[0] - 0.55).abs() < 1e-6 && (p[1] - 0.55).abs() < 1e-6); + } + _ => panic!("expected mesh warp"), + } + } + + #[test] + fn reset_warp_clears() { + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_subdivisions(3, 3); + s.reset_warp(); + assert!(s.warp.is_none()); + } + + // ── Bezier warp (8i.6) ─────────────────────────────────────────── + + #[test] + fn convert_warp_to_bezier_seeds_cage_from_shape() { + use crate::renderer::warp::WarpMode; + let mut s = Surface::new_rect("R".into(), 0.1, 0.2, 0.5, 0.4, master_source()); + s.set_warp_bound(false); // manual editing + s.convert_warp_to_bezier(); + match &s.warp { + Some(WarpMode::Bezier(b)) => { + // Seeded from the identity 2×2 corner-pin over the bbox. + assert_eq!((b.anchor_cols, b.anchor_rows), (2, 2)); + assert_eq!(b.anchor(0, 0), [0.1, 0.2]); + assert_eq!(b.anchor(1, 1), [0.6, 0.6]); + } + other => panic!("expected bezier warp, got {other:?}"), + } + } + + #[test] + fn convert_warp_to_bezier_preserves_mesh_dims() { + use crate::renderer::warp::WarpMode; + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_bound(false); + s.set_warp_subdivisions(4, 3); + s.convert_warp_to_bezier(); + match &s.warp { + Some(WarpMode::Bezier(b)) => assert_eq!((b.anchor_cols, b.anchor_rows), (4, 3)), + other => panic!("expected bezier warp, got {other:?}"), + } + } + + #[test] + fn convert_warp_to_bezier_noop_when_already_bezier() { + use crate::renderer::warp::WarpMode; + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_bound(false); + s.convert_warp_to_bezier(); + s.set_bezier_cage_subdivisions(3, 3); + s.convert_warp_to_bezier(); // must not reset the 3×3 cage back to 2×2 + match &s.warp { + Some(WarpMode::Bezier(b)) => assert_eq!((b.anchor_cols, b.anchor_rows), (3, 3)), + other => panic!("expected bezier warp, got {other:?}"), + } + } + + #[test] + fn set_warp_bezier_anchor_moves_it() { + use crate::renderer::warp::WarpMode; + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_bound(false); + s.convert_warp_to_bezier(); + s.set_warp_bezier_anchor(0, 0, [0.2, 0.3]); + match &s.warp { + Some(WarpMode::Bezier(b)) => assert_eq!(b.anchor(0, 0), [0.2, 0.3]), + other => panic!("expected bezier warp, got {other:?}"), + } + } + + #[test] + fn set_warp_bezier_handle_and_anchor_noop_on_mesh() { + // On a non-bezier warp these are no-ops (don't panic / don't change type). + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_bound(false); + s.set_warp_subdivisions(3, 3); + let before = format!("{:?}", s.warp); + s.set_warp_bezier_anchor(0, 0, [0.9, 0.9]); + s.set_warp_bezier_handle(true, 0, 0, 0, [0.5, 0.5]); + s.set_bezier_cage_subdivisions(5, 5); + assert_eq!(before, format!("{:?}", s.warp)); + } + + #[test] + fn effective_warp_bezier_is_returned_when_unbound() { + use crate::renderer::warp::WarpMode; + let mut s = Surface::new_rect("R".into(), 0.0, 0.0, 1.0, 1.0, master_source()); + s.set_warp_bound(false); + s.convert_warp_to_bezier(); + // Effective warp hands the bezier cage through (for the editor); render + // sites tessellate it via WarpMode::render_mesh. + assert!(matches!(s.effective_warp(), Some(WarpMode::Bezier(_)))); + assert!(s.effective_warp().unwrap().render_mesh().is_some()); + } } diff --git a/src/usecases/api/projection.rs b/src/usecases/api/projection.rs index afcc061..3de94ea 100644 --- a/src/usecases/api/projection.rs +++ b/src/usecases/api/projection.rs @@ -226,7 +226,7 @@ pub(crate) mod tests { is_on_display: true, is_active: false, surface_assignments: vec![], - calibration_mode: false, + calibration_mode: crate::renderer::context::CalibrationMode::Off, audio_passthrough: None, }], surfaces: vec![SurfaceSnapshot { @@ -238,7 +238,9 @@ pub(crate) mod tests { content_mapping: ContentMapping::Fill, output_type: SurfaceOutputType::Projection, circle_hint: None, - default_warp: None, + warp: None, + warp_bound: false, + path: None, }], monitors: vec![MonitorSnapshot { name: "HDMI-1".into(), @@ -368,7 +370,7 @@ pub(crate) mod tests { is_on_display: false, is_active: true, surface_assignments: vec![], - calibration_mode: false, + calibration_mode: crate::renderer::context::CalibrationMode::Off, audio_passthrough: Some(AudioPassthroughSnapshot { device: "Scarlett 2i2".into(), frames_written: 42, diff --git a/src/usecases/api/routes/outputs.rs b/src/usecases/api/routes/outputs.rs index e070084..7ac60a4 100644 --- a/src/usecases/api/routes/outputs.rs +++ b/src/usecases/api/routes/outputs.rs @@ -125,67 +125,20 @@ pub async fn stop(State(s): State, Path(idx): Path) -> impl Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), } } -#[utoipa::path(post, path = "/api/outputs/{idx}/calibration", params(("idx" = usize, Path, description = "Output index")), responses((status = 200, body = CommandResult)), tag = "Outputs")] -pub async fn toggle_calibration( - State(s): State, - Path(idx): Path, -) -> impl IntoResponse { - match s - .send_command(EngineCommand::ToggleCalibration { idx }) - .await - { - Ok(r) => command_response(r), - Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), - } -} - #[derive(Deserialize, ToSchema)] -pub struct SetWarpBody { - /// Index of the surface assignment to warp. - pub assignment_idx: usize, - /// Corner index (0–3). - pub corner_idx: usize, - /// New [x, y] position for the corner in normalised coordinates. - pub position: [f32; 2], +pub struct SetCalibrationModeBody { + /// Calibration display mode (Off / Projector / Surfaces). + pub mode: crate::renderer::context::CalibrationMode, } -#[utoipa::path(put, path = "/api/outputs/{idx}/warp", params(("idx" = usize, Path, description = "Output index")), request_body = SetWarpBody, responses((status = 200, body = CommandResult)), tag = "Outputs")] -pub async fn set_warp( +#[utoipa::path(put, path = "/api/outputs/{idx}/calibration", params(("idx" = usize, Path, description = "Output index")), request_body = SetCalibrationModeBody, responses((status = 200, body = CommandResult)), tag = "Outputs")] +pub async fn set_calibration_mode( State(s): State, Path(idx): Path, - Json(b): Json, + Json(b): Json, ) -> impl IntoResponse { match s - .send_command(EngineCommand::SetWarpCorner { - output_idx: idx, - assignment_idx: b.assignment_idx, - corner_idx: b.corner_idx, - position: b.position, - }) - .await - { - Ok(r) => command_response(r), - Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), - } -} - -#[derive(Deserialize, ToSchema)] -pub struct ResetWarpBody { - /// Index of the surface assignment whose warp to reset. - pub assignment_idx: usize, -} - -#[utoipa::path(post, path = "/api/outputs/{idx}/reset-warp", params(("idx" = usize, Path, description = "Output index")), request_body = ResetWarpBody, responses((status = 200, body = CommandResult)), tag = "Outputs")] -pub async fn reset_warp( - State(s): State, - Path(idx): Path, - Json(b): Json, -) -> impl IntoResponse { - match s - .send_command(EngineCommand::ResetWarp { - output_idx: idx, - assignment_idx: b.assignment_idx, - }) + .send_command(EngineCommand::SetCalibrationMode { idx, mode: b.mode }) .await { Ok(r) => command_response(r), diff --git a/src/usecases/api/routes/surfaces.rs b/src/usecases/api/routes/surfaces.rs index f3f49df..782eaff 100644 --- a/src/usecases/api/routes/surfaces.rs +++ b/src/usecases/api/routes/surfaces.rs @@ -8,7 +8,7 @@ use utoipa::ToSchema; use crate::engine::{CommandResult, EngineCommand}; use crate::internal::renderer::context::OutputSource; -use crate::internal::surface::{ContentMapping, SurfaceOutputType}; +use crate::internal::surface::{ContentMapping, CubicHandle, SurfaceOutputType}; use crate::usecases::api::{command_response, SharedState}; #[derive(Deserialize, ToSchema)] @@ -313,12 +313,51 @@ pub struct MoveBody { pub dy: f32, } #[derive(Deserialize, ToSchema)] +pub struct RotateBody { + /// Rotation in radians (clockwise in canvas space, y-down). + pub angle: f32, + /// Pivot point [x, y] in normalised coordinates. + pub pivot: [f32; 2], +} +#[derive(Deserialize, ToSchema)] +pub struct ScaleBody { + /// Horizontal scale factor. + pub sx: f32, + /// Vertical scale factor. + pub sy: f32, + /// Pivot point [x, y] in normalised coordinates. + pub pivot: [f32; 2], +} +#[derive(Deserialize, ToSchema)] pub struct ContourVerticesBody { /// Contour index within the surface. pub contour: usize, /// Updated vertex positions as [x, y] pairs. pub vertices: Vec<[f32; 2]>, } +#[derive(Deserialize, ToSchema)] +pub struct ConvertEdgeBody { + /// Index of the curve-path edge to convert. + pub edge_idx: usize, + /// `true` converts the edge to a cubic bezier; `false` back to a line. + pub to_cubic: bool, +} +#[derive(Deserialize, ToSchema)] +pub struct MovePathAnchorBody { + /// Index of the curve-path anchor to move. + pub anchor_idx: usize, + /// New anchor position [x, y] in normalised coordinates. + pub pos: [f32; 2], +} +#[derive(Deserialize, ToSchema)] +pub struct MovePathHandleBody { + /// Index of the cubic segment whose control handle is being moved. + pub segment_idx: usize, + /// Which control handle of the cubic segment (C1 or C2). + pub handle: CubicHandle, + /// New handle position [x, y] in normalised coordinates. + pub pos: [f32; 2], +} #[utoipa::path(post, path = "/api/surfaces/{uuid}/vertices/insert", params(("uuid" = String, Path, description = "Surface UUID")), request_body = InsertVertexBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] pub async fn insert_vertex( @@ -372,6 +411,43 @@ pub async fn set_circle_sides( Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), } } +#[utoipa::path(put, path = "/api/surfaces/{uuid}/rotate", params(("uuid" = String, Path, description = "Surface UUID")), request_body = RotateBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn rotate_surface( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::RotateSurface { + uuid, + angle: b.angle, + pivot: b.pivot, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} +#[utoipa::path(put, path = "/api/surfaces/{uuid}/scale", params(("uuid" = String, Path, description = "Surface UUID")), request_body = ScaleBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn scale_surface( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::ScaleSurface { + uuid, + sx: b.sx, + sy: b.sy, + pivot: b.pivot, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} #[utoipa::path(post, path = "/api/surfaces/{uuid}/convert-to-polygon", params(("uuid" = String, Path, description = "Surface UUID")), responses((status = 200, body = CommandResult)), tag = "Surfaces")] pub async fn convert_to_polygon( State(s): State, @@ -453,3 +529,294 @@ pub async fn update_contour_vertices( Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), } } + +#[utoipa::path(put, path = "/api/surfaces/{uuid}/edge/convert", params(("uuid" = String, Path, description = "Surface UUID")), request_body = ConvertEdgeBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn convert_edge( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::ConvertSurfaceEdge { + uuid, + edge_idx: b.edge_idx, + to_cubic: b.to_cubic, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[utoipa::path(put, path = "/api/surfaces/{uuid}/path/anchor", params(("uuid" = String, Path, description = "Surface UUID")), request_body = MovePathAnchorBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn move_path_anchor( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::MovePathAnchor { + uuid, + anchor_idx: b.anchor_idx, + pos: b.pos, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[utoipa::path(put, path = "/api/surfaces/{uuid}/path/handle", params(("uuid" = String, Path, description = "Surface UUID")), request_body = MovePathHandleBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn move_path_handle( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::MovePathHandle { + uuid, + segment_idx: b.segment_idx, + handle: b.handle, + pos: b.pos, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +// ── Per-surface warp (8i.5) ─────────────────────────────────────── + +#[derive(Deserialize, ToSchema)] +pub struct SetWarpCornerBody { + /// Corner index (0–3, TL/TR/BR/BL). + pub corner_idx: usize, + /// New [x, y] position for the corner in normalised coordinates. + pub position: [f32; 2], +} + +#[utoipa::path(put, path = "/api/surfaces/{uuid}/warp/corner", params(("uuid" = String, Path, description = "Surface UUID")), request_body = SetWarpCornerBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn set_warp_corner( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::SetWarpCorner { + surface_uuid: uuid, + corner_idx: b.corner_idx, + position: b.position, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[utoipa::path(post, path = "/api/surfaces/{uuid}/warp/reset", params(("uuid" = String, Path, description = "Surface UUID")), responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn reset_warp( + State(s): State, + Path(uuid): Path, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::ResetWarp { surface_uuid: uuid }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[derive(Deserialize, ToSchema)] +pub struct SetWarpSubdivisionsBody { + /// Number of grid columns (clamped to [2, 64]). + pub cols: u32, + /// Number of grid rows (clamped to [2, 64]). + pub rows: u32, +} + +#[utoipa::path(put, path = "/api/surfaces/{uuid}/warp/subdivisions", params(("uuid" = String, Path, description = "Surface UUID")), request_body = SetWarpSubdivisionsBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn set_warp_subdivisions( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::SetWarpSubdivisions { + surface_uuid: uuid, + cols: b.cols, + rows: b.rows, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[derive(Deserialize, ToSchema)] +pub struct SetWarpMeshPointBody { + /// Grid row of the point (0-based). + pub row: usize, + /// Grid column of the point (0-based). + pub col: usize, + /// New [x, y] position in normalised coordinates. + pub position: [f32; 2], +} + +#[utoipa::path(put, path = "/api/surfaces/{uuid}/warp/mesh-point", params(("uuid" = String, Path, description = "Surface UUID")), request_body = SetWarpMeshPointBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn set_warp_mesh_point( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::SetWarpMeshPoint { + surface_uuid: uuid, + row: b.row, + col: b.col, + position: b.position, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[derive(Deserialize, ToSchema)] +pub struct SetWarpBoundBody { + /// `true` = auto-conform the warp to the surface shape; `false` = unbind for + /// manual fine-tuning (materialises the conforming warp). + pub bound: bool, +} + +#[utoipa::path(post, path = "/api/surfaces/{uuid}/warp/bind", params(("uuid" = String, Path, description = "Surface UUID")), request_body = SetWarpBoundBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn set_warp_bound( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::SetWarpBound { + surface_uuid: uuid, + bound: b.bound, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[utoipa::path(post, path = "/api/surfaces/{uuid}/warp/bezier", params(("uuid" = String, Path, description = "Surface UUID")), responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn convert_warp_to_bezier( + State(s): State, + Path(uuid): Path, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::ConvertWarpToBezier { surface_uuid: uuid }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[derive(Deserialize, ToSchema)] +pub struct MoveWarpAnchorBody { + /// Anchor grid row (0-based). + pub row: usize, + /// Anchor grid column (0-based). + pub col: usize, + /// New [x, y] position in normalised coordinates. + pub position: [f32; 2], +} + +#[utoipa::path(put, path = "/api/surfaces/{uuid}/warp/anchor", params(("uuid" = String, Path, description = "Surface UUID")), request_body = MoveWarpAnchorBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn move_warp_anchor( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::MoveWarpAnchor { + surface_uuid: uuid, + row: b.row, + col: b.col, + position: b.position, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[derive(Deserialize, ToSchema)] +pub struct MoveWarpHandleBody { + /// `true` = a horizontal edge ((r,c)→(r,c+1)); `false` = a vertical edge + /// ((r,c)→(r+1,c)). + pub horizontal: bool, + /// Edge start-anchor grid row (0-based). + pub row: usize, + /// Edge start-anchor grid column (0-based). + pub col: usize, + /// Which handle of the edge: 0 (near start anchor) or 1 (near end anchor). + pub which: usize, + /// New [x, y] position in normalised coordinates. + pub position: [f32; 2], +} + +#[utoipa::path(put, path = "/api/surfaces/{uuid}/warp/handle", params(("uuid" = String, Path, description = "Surface UUID")), request_body = MoveWarpHandleBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn move_warp_handle( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::MoveWarpHandle { + surface_uuid: uuid, + horizontal: b.horizontal, + row: b.row, + col: b.col, + which: b.which, + position: b.position, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} + +#[derive(Deserialize, ToSchema)] +pub struct SetBezierCageBody { + /// Number of anchor columns (clamped to [2, 64]). + pub cols: u32, + /// Number of anchor rows (clamped to [2, 64]). + pub rows: u32, +} + +#[utoipa::path(put, path = "/api/surfaces/{uuid}/warp/cage", params(("uuid" = String, Path, description = "Surface UUID")), request_body = SetBezierCageBody, responses((status = 200, body = CommandResult)), tag = "Surfaces")] +pub async fn set_bezier_cage_subdivisions( + State(s): State, + Path(uuid): Path, + Json(b): Json, +) -> impl IntoResponse { + match s + .send_command(EngineCommand::SetBezierCageSubdivisions { + surface_uuid: uuid, + cols: b.cols, + rows: b.rows, + }) + .await + { + Ok(r) => command_response(r), + Err(m) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, m).into_response(), + } +} diff --git a/src/usecases/api/routes/tests.rs b/src/usecases/api/routes/tests.rs index 6948813..7a88407 100644 --- a/src/usecases/api/routes/tests.rs +++ b/src/usecases/api/routes/tests.rs @@ -910,6 +910,66 @@ mod tests { assert_eq!(json["status"], "ok"); } + #[tokio::test] + async fn test_rotate_surface() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/srf-001/rotate", + serde_json::json!({"angle": 0.5, "pivot": [0.5, 0.5]}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_scale_surface() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/srf-001/scale", + serde_json::json!({"sx": 1.5, "sy": 0.5, "pivot": [0.0, 0.0]}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_convert_edge() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/srf-001/edge/convert", + serde_json::json!({"edge_idx": 0, "to_cubic": true}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_move_path_anchor() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/srf-001/path/anchor", + serde_json::json!({"anchor_idx": 1, "pos": [0.3, 0.4]}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_move_path_handle() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/srf-001/path/handle", + serde_json::json!({"segment_idx": 0, "handle": "C1", "pos": [0.6, 0.7]}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + // ── Write: Output routes ──────────────────────────────────── #[tokio::test] @@ -1001,11 +1061,11 @@ mod tests { } #[tokio::test] - async fn test_toggle_calibration() { - let (status, json) = post_json( + async fn test_set_calibration_mode() { + let (status, json) = put_json( router_with_mock_engine(), "/api/outputs/0/calibration", - serde_json::json!({}), + serde_json::json!({"mode": "Projector"}), ) .await; assert_eq!(status, StatusCode::OK); @@ -1016,8 +1076,8 @@ mod tests { async fn test_set_warp_corner() { let (status, json) = put_json( router_with_mock_engine(), - "/api/outputs/0/warp", - serde_json::json!({"assignment_idx": 0, "corner_idx": 0, "position": [0.1, 0.1]}), + "/api/surfaces/s1/warp/corner", + serde_json::json!({"corner_idx": 0, "position": [0.1, 0.1]}), ) .await; assert_eq!(status, StatusCode::OK); @@ -1028,8 +1088,92 @@ mod tests { async fn test_reset_warp() { let (status, json) = post_json( router_with_mock_engine(), - "/api/outputs/0/reset-warp", - serde_json::json!({"assignment_idx": 0}), + "/api/surfaces/s1/warp/reset", + serde_json::json!({}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_set_warp_subdivisions() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/s1/warp/subdivisions", + serde_json::json!({"cols": 3, "rows": 3}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_set_warp_mesh_point() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/s1/warp/mesh-point", + serde_json::json!({"row": 1, "col": 1, "position": [0.6, 0.4]}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_set_warp_bound() { + let (status, json) = post_json( + router_with_mock_engine(), + "/api/surfaces/s1/warp/bind", + serde_json::json!({"bound": false}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_convert_warp_to_bezier() { + let (status, json) = post_json( + router_with_mock_engine(), + "/api/surfaces/s1/warp/bezier", + serde_json::json!({}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_move_warp_anchor() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/s1/warp/anchor", + serde_json::json!({"row": 0, "col": 0, "position": [0.2, 0.3]}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_move_warp_handle() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/s1/warp/handle", + serde_json::json!({"horizontal": true, "row": 0, "col": 0, "which": 0, "position": [0.3, 0.1]}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn test_set_bezier_cage_subdivisions() { + let (status, json) = put_json( + router_with_mock_engine(), + "/api/surfaces/s1/warp/cage", + serde_json::json!({"cols": 3, "rows": 3}), ) .await; assert_eq!(status, StatusCode::OK); diff --git a/src/usecases/api/runner.rs b/src/usecases/api/runner.rs index c9cad39..f3962ee 100644 --- a/src/usecases/api/runner.rs +++ b/src/usecases/api/runner.rs @@ -107,7 +107,15 @@ use utoipa_swagger_ui::SwaggerUi; routes::surfaces::insert_vertex, routes::surfaces::set_circle_radius, routes::surfaces::set_circle_sides, routes::surfaces::convert_to_polygon, routes::surfaces::combine, routes::surfaces::move_surface, + routes::surfaces::rotate_surface, routes::surfaces::scale_surface, routes::surfaces::update_contour_vertices, + routes::surfaces::convert_edge, routes::surfaces::move_path_anchor, + routes::surfaces::move_path_handle, + routes::surfaces::set_warp_corner, routes::surfaces::reset_warp, + routes::surfaces::set_warp_subdivisions, routes::surfaces::set_warp_mesh_point, + routes::surfaces::set_warp_bound, + routes::surfaces::convert_warp_to_bezier, routes::surfaces::move_warp_anchor, + routes::surfaces::move_warp_handle, routes::surfaces::set_bezier_cage_subdivisions, // Stage Detection routes::stage::detect_image, routes::stage::detect_svg, routes::stage::detect_dxf, routes::stage::detect_confirm, routes::stage::detect_camera, @@ -116,8 +124,7 @@ use utoipa_swagger_ui::SwaggerUi; routes::outputs::set_display, routes::outputs::assign_surface, routes::outputs::unassign_surface, routes::outputs::create_headless, routes::outputs::start, routes::outputs::stop, - routes::outputs::toggle_calibration, routes::outputs::set_warp, - routes::outputs::reset_warp, routes::outputs::set_target, + routes::outputs::set_calibration_mode, routes::outputs::set_target, routes::outputs::set_edge_blend, routes::outputs::set_edge_blend_mode, // Sequences routes::sequences::create, routes::sequences::delete, @@ -694,10 +701,66 @@ pub fn build_router(shared: SharedState) -> Router { "/api/surfaces/{uuid}/move", axum::routing::put(routes::surfaces::move_surface), ) + .route( + "/api/surfaces/{uuid}/rotate", + axum::routing::put(routes::surfaces::rotate_surface), + ) + .route( + "/api/surfaces/{uuid}/scale", + axum::routing::put(routes::surfaces::scale_surface), + ) .route( "/api/surfaces/{uuid}/contour-vertices", axum::routing::put(routes::surfaces::update_contour_vertices), ) + .route( + "/api/surfaces/{uuid}/edge/convert", + axum::routing::put(routes::surfaces::convert_edge), + ) + .route( + "/api/surfaces/{uuid}/path/anchor", + axum::routing::put(routes::surfaces::move_path_anchor), + ) + .route( + "/api/surfaces/{uuid}/path/handle", + axum::routing::put(routes::surfaces::move_path_handle), + ) + .route( + "/api/surfaces/{uuid}/warp/corner", + axum::routing::put(routes::surfaces::set_warp_corner), + ) + .route( + "/api/surfaces/{uuid}/warp/reset", + axum::routing::post(routes::surfaces::reset_warp), + ) + .route( + "/api/surfaces/{uuid}/warp/subdivisions", + axum::routing::put(routes::surfaces::set_warp_subdivisions), + ) + .route( + "/api/surfaces/{uuid}/warp/mesh-point", + axum::routing::put(routes::surfaces::set_warp_mesh_point), + ) + .route( + "/api/surfaces/{uuid}/warp/bind", + axum::routing::post(routes::surfaces::set_warp_bound), + ) + .route( + "/api/surfaces/{uuid}/warp/bezier", + axum::routing::post(routes::surfaces::convert_warp_to_bezier), + ) + .route( + "/api/surfaces/{uuid}/warp/anchor", + axum::routing::put(routes::surfaces::move_warp_anchor), + ) + .route( + "/api/surfaces/{uuid}/warp/handle", + axum::routing::put(routes::surfaces::move_warp_handle), + ) + .route( + "/api/surfaces/{uuid}/warp/cage", + axum::routing::put(routes::surfaces::set_bezier_cage_subdivisions), + ) // ── Write: Outputs extras ─────────────────────────────── .route( "/api/outputs/headless", @@ -713,15 +776,7 @@ pub fn build_router(shared: SharedState) -> Router { ) .route( "/api/outputs/{idx}/calibration", - axum::routing::post(routes::outputs::toggle_calibration), - ) - .route( - "/api/outputs/{idx}/warp", - axum::routing::put(routes::outputs::set_warp), - ) - .route( - "/api/outputs/{idx}/reset-warp", - axum::routing::post(routes::outputs::reset_warp), + axum::routing::put(routes::outputs::set_calibration_mode), ) .route( "/api/outputs/{idx}/target", diff --git a/src/usecases/ui/mod.rs b/src/usecases/ui/mod.rs index d2b422f..f493c05 100644 --- a/src/usecases/ui/mod.rs +++ b/src/usecases/ui/mod.rs @@ -14,7 +14,7 @@ use crate::params::ParamValue; use crate::renderer::context::OutputSource; use crate::renderer::slicer::{DomeGeometry, DomePreset, DomeSetup}; use crate::surface::detect::{DetectedContour, DetectionParams}; -use crate::surface::{CircleHint, ContentMapping, SurfaceOutputType}; +use crate::surface::{CircleHint, ContentMapping, CubicHandle, SurfaceOutputType, SurfacePath}; use crate::{BlendMode, ScalingMode, ShaderParams}; // Re-export default render resolution constants from the engine layer @@ -993,19 +993,10 @@ pub enum OutputAction { output_idx: usize, assignment_idx: usize, }, - /// Toggle calibration mode on an output (windowed only) - ToggleCalibration { idx: usize }, - /// Update a warp corner for a surface assignment - SetWarpCorner { - output_idx: usize, - assignment_idx: usize, - corner_idx: usize, - position: [f32; 2], - }, - /// Reset warp to identity for a surface assignment - ResetWarp { - output_idx: usize, - assignment_idx: usize, + /// Set the calibration display mode on an output (windowed only) + SetCalibrationMode { + idx: usize, + mode: crate::renderer::context::CalibrationMode, }, /// Set edge blending configuration for an output SetEdgeBlend { @@ -1029,7 +1020,6 @@ pub enum OutputAction { pub struct SurfaceAssignmentUI { pub surface_uuid: String, pub surface_name: String, - pub warp_mode: crate::renderer::warp::WarpMode, pub enabled: bool, /// Per-surface overlap zones (Auto mode). Empty when Manual or no overlaps. pub overlap_zones: crate::renderer::edge_blend::SurfaceOverlapZones, @@ -1051,7 +1041,7 @@ pub struct OutputUI { /// Duration of active recording/streaming pub active_duration: std::time::Duration, pub surface_assignments: Vec, - pub calibration_mode: bool, + pub calibration_mode: crate::renderer::context::CalibrationMode, /// Edge blend mode (Auto / Manual) pub edge_blend_mode: crate::renderer::edge_blend::EdgeBlendMode, /// Edge blending configuration @@ -1093,6 +1083,38 @@ pub enum SurfaceAction { }, /// Move a surface by a delta (moves all contours) MoveDelta { uuid: String, dx: f32, dy: f32 }, + /// Rotate a surface by `angle` radians around `pivot` (normalized coords) + Rotate { + uuid: String, + angle: f32, + pivot: [f32; 2], + }, + /// Scale a surface by `(sx, sy)` around `pivot` (normalized coords) + Scale { + uuid: String, + sx: f32, + sy: f32, + pivot: [f32; 2], + }, + /// Convert a curve-path edge to a cubic bezier or back to a straight line + ConvertEdge { + uuid: String, + edge_idx: usize, + to_cubic: bool, + }, + /// Move a curve-path anchor to a new position (normalized coords) + MoveAnchor { + uuid: String, + anchor_idx: usize, + pos: [f32; 2], + }, + /// Move a cubic control handle of a curve-path segment (normalized coords) + MoveHandle { + uuid: String, + segment_idx: usize, + handle: CubicHandle, + pos: [f32; 2], + }, /// Change the content source for a surface SetSource { uuid: String, source: OutputSource }, /// Change the output type for a surface @@ -1113,6 +1135,45 @@ pub enum SurfaceAction { FlipHorizontal { uuid: String }, /// Flip a surface vertically (mirror around its bounding box center Y) FlipVertical { uuid: String }, + /// Move one corner-pin corner of a surface's warp (per-surface) + SetWarpCorner { + uuid: String, + corner_idx: usize, + position: [f32; 2], + }, + /// Clear a surface's warp (back to no-warp / native position) + ResetWarp { uuid: String }, + /// Convert a surface's warp into a `cols` × `rows` mesh (preserving deformation) + SetWarpSubdivisions { uuid: String, cols: u32, rows: u32 }, + /// Move a single mesh grid point of a surface's mesh warp + SetWarpMeshPoint { + uuid: String, + row: usize, + col: usize, + position: [f32; 2], + }, + /// Bind/unbind a surface's warp from its shape (auto-warp) + SetWarpBound { uuid: String, bound: bool }, + /// Convert a surface's warp into a smooth bezier patch grid (8i.6) + ConvertWarpToBezier { uuid: String }, + /// Move a bezier-warp control anchor + MoveWarpAnchor { + uuid: String, + row: usize, + col: usize, + position: [f32; 2], + }, + /// Move a bezier-warp tangent handle (`horizontal` edge vs vertical; `which` 0/1) + MoveWarpHandle { + uuid: String, + horizontal: bool, + row: usize, + col: usize, + which: usize, + position: [f32; 2], + }, + /// Set the bezier-warp control-cage resolution (anchor `cols` × `rows`) + SetBezierCageSubdivisions { uuid: String, cols: u32, rows: u32 }, /// Insert a vertex on an edge (after vertex at `after_vert_idx`) InsertVertex { uuid: String, @@ -1181,6 +1242,16 @@ pub struct SurfaceUI { pub content_mapping: ContentMapping, pub output_type: SurfaceOutputType, pub circle_hint: Option, + /// Effective per-surface warp (corner-pin or mesh); `None` = no warp. While + /// `warp_bound`, this is the shape-conforming warp. Drives the stage + /// bottom-bar warp editor. + pub warp: Option, + /// Whether the warp auto-conforms to the surface shape. When `true` the + /// bottom-bar warp controls are locked (read-only). + pub warp_bound: bool, + /// Curve authoring path, when the surface is bezier-edited. Drives the + /// anchor/handle overlay and edge hit-testing in the stage editor. + pub path: Option, } /// Crossfader action from UI diff --git a/src/usecases/ui/panels/deck_detail.rs b/src/usecases/ui/panels/deck_detail.rs index d88c10f..5801c74 100644 --- a/src/usecases/ui/panels/deck_detail.rs +++ b/src/usecases/ui/panels/deck_detail.rs @@ -2,7 +2,7 @@ use super::super::{ widgets, AutoTransitionAction, EffectDrag, LibraryDrag, ModulationAction, ParamUpdate, - UIActions, UIData, VideoAction, + SurfaceAction, SurfaceUI, UIActions, UIData, VideoAction, }; use super::effects::{render_channel_effect_detail, render_master_effect_detail}; use super::sequence::{render_sequence_step_editor, render_timeline_strip}; @@ -81,6 +81,13 @@ pub(super) fn render_bottom_panel(ui: &mut egui::Ui, data: &UIData, actions: &mu }); } + // While the stage editor is open the bottom bar hosts the per-surface warp + // editor for the selected surface (8i.5). + if data.stage_editor_open { + render_stage_bottom_bar(ui, data, actions); + return; + } + // Context-sensitive bottom bar: master effects, channel effects, sequence, or deck detail if data.selected_master { render_master_effect_detail(ui, data, actions); @@ -93,6 +100,583 @@ pub(super) fn render_bottom_panel(ui: &mut egui::Ui, data: &UIData, actions: &mu } } +/// Shared context-memory key: the stage editor publishes its current surface +/// selection here so the bottom detail bar can target it. +pub(super) fn stage_selection_id() -> egui::Id { + egui::Id::new("varda_stage_selected_surfaces") +} + +/// Upper bound on grid resolution offered by the steppers (engine clamps to 64). +const UI_MAX_WARP_SUBDIVISIONS: u32 = 16; + +/// `(cols, rows)` of a surface's warp. `None` or a corner-pin reads as 2×2. +fn warp_grid_dims(warp: &Option) -> (u32, u32) { + match warp { + Some(crate::renderer::warp::WarpMode::Mesh(m)) => (m.cols, m.rows), + _ => (2, 2), + } +} + +fn corner_to_rc(i: usize) -> (usize, usize) { + match i { + 0 => (0, 0), + 1 => (0, 1), + 2 => (1, 1), + _ => (1, 0), + } +} +fn rc_to_corner(row: usize, col: usize) -> usize { + match (row, col) { + (0, 0) => 0, + (0, 1) => 1, + (1, 1) => 2, + _ => 3, + } +} + +/// Axis-aligned bbox `(x, y, w, h)` of a surface's primary contour. +fn surface_bbox(surface: &SurfaceUI) -> [f32; 4] { + let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for v in &surface.vertices { + minx = minx.min(v[0]); + miny = miny.min(v[1]); + maxx = maxx.max(v[0]); + maxy = maxy.max(v[1]); + } + if !minx.is_finite() { + return [0.0, 0.0, 1.0, 1.0]; + } + [minx, miny, (maxx - minx).max(1e-4), (maxy - miny).max(1e-4)] +} + +/// Bottom-bar content while the stage editor is open: the per-surface warp editor +/// for the single selected surface, else a hint. +fn render_stage_bottom_bar(ui: &mut egui::Ui, data: &UIData, actions: &mut UIActions) { + let selected: Vec = ui + .ctx() + .memory(|m| m.data.get_temp::>(stage_selection_id())) + .unwrap_or_default(); + let sel: Vec<&SurfaceUI> = selected + .iter() + .filter_map(|u| data.surfaces.iter().find(|s| &s.uuid == u)) + .collect(); + if sel.len() == 1 { + render_surface_warp_editor(ui, sel[0], actions); + } else { + let msg = if sel.is_empty() { + "Select a surface on the stage to edit its warp" + } else { + "Select a single surface to edit its warp" + }; + ui.centered_and_justified(|ui| { + ui.label(egui::RichText::new(msg).weak()); + }); + } +} + +/// Per-surface warp editor: subdivide steppers + a draggable grid canvas. +fn render_surface_warp_editor(ui: &mut egui::Ui, surface: &SurfaceUI, actions: &mut UIActions) { + use crate::renderer::warp::WarpMode; + let uuid = surface.uuid.clone(); + let bound = surface.warp_bound; + let is_bezier = matches!(surface.warp, Some(WarpMode::Bezier(_))); + // In bezier mode the steppers control the anchor cage; otherwise the mesh grid. + let (cols, rows) = if let Some(WarpMode::Bezier(b)) = &surface.warp { + (b.anchor_cols, b.anchor_rows) + } else { + warp_grid_dims(&surface.warp) + }; + + ui.horizontal(|ui| { + ui.label(egui::RichText::new(format!("⊞ Warp — {}", surface.name)).strong()); + ui.separator(); + let mut bind = bound; + if ui + .checkbox(&mut bind, "🔗 Bind to shape") + .on_hover_text( + "Auto-conform the warp grid to the surface outline; uncheck to fine-tune", + ) + .changed() + { + actions.surface_actions.push(SurfaceAction::SetWarpBound { + uuid: uuid.clone(), + bound: bind, + }); + } + ui.separator(); + ui.add_enabled_ui(!bound, |ui| { + ui.label( + egui::RichText::new(if is_bezier { "cage" } else { "grid" }) + .weak() + .small(), + ); + if ui + .small_button("−") + .on_hover_text("Fewer columns") + .clicked() + { + actions.surface_actions.push(subdiv_action( + is_bezier, + uuid.clone(), + cols.saturating_sub(1).max(2), + rows, + )); + } + ui.label(egui::RichText::new(format!("{cols}")).monospace().small()); + if ui.small_button("+").on_hover_text("More columns").clicked() { + actions.surface_actions.push(subdiv_action( + is_bezier, + uuid.clone(), + (cols + 1).min(UI_MAX_WARP_SUBDIVISIONS), + rows, + )); + } + ui.label(egui::RichText::new("×").weak().small()); + if ui.small_button("−").on_hover_text("Fewer rows").clicked() { + actions.surface_actions.push(subdiv_action( + is_bezier, + uuid.clone(), + cols, + rows.saturating_sub(1).max(2), + )); + } + ui.label(egui::RichText::new(format!("{rows}")).monospace().small()); + if ui.small_button("+").on_hover_text("More rows").clicked() { + actions.surface_actions.push(subdiv_action( + is_bezier, + uuid.clone(), + cols, + (rows + 1).min(UI_MAX_WARP_SUBDIVISIONS), + )); + } + ui.separator(); + // Curve ↔ grid mode toggle (8i.6). + if is_bezier { + if ui + .small_button("⊞ Grid") + .on_hover_text("Switch back to a straight mesh warp") + .clicked() + { + actions + .surface_actions + .push(SurfaceAction::SetWarpSubdivisions { + uuid: uuid.clone(), + cols, + rows, + }); + } + } else if ui + .small_button("〰 Curve") + .on_hover_text("Convert to a smooth bezier warp with tangent handles") + .clicked() + { + actions + .surface_actions + .push(SurfaceAction::ConvertWarpToBezier { uuid: uuid.clone() }); + } + ui.separator(); + if ui + .small_button("↺ Reset") + .on_hover_text("Clear warp") + .clicked() + { + actions + .surface_actions + .push(SurfaceAction::ResetWarp { uuid: uuid.clone() }); + } + }); + }); + + // Canvas sized to remaining bottom-bar space, capped to 16:9. + let avail = ui.available_size(); + let canvas_width = (avail.x - 8.0).max(64.0); + let canvas_height = (canvas_width * 0.5625).min((avail.y - 4.0).max(64.0)); + let (canvas_rect, resp) = ui.allocate_exact_size( + egui::vec2(canvas_width, canvas_height), + egui::Sense::click_and_drag(), + ); + let painter = ui.painter_at(canvas_rect); + painter.rect_filled(canvas_rect, 2.0, egui::Color32::from_rgb(15, 15, 25)); + + let to_screen = |nx: f32, ny: f32| { + egui::pos2( + canvas_rect.left() + nx * canvas_width, + canvas_rect.top() + ny * canvas_height, + ) + }; + let from_screen = |p: egui::Pos2| { + [ + ((p.x - canvas_rect.left()) / canvas_width).clamp(0.0, 1.0), + ((p.y - canvas_rect.top()) / canvas_height).clamp(0.0, 1.0), + ] + }; + + // Bezier warp: dedicated cage editor (anchors + tangent handles). + if let Some(WarpMode::Bezier(b)) = &surface.warp { + render_bezier_canvas(ui, b, &uuid, bound, canvas_rect, &resp, actions); + return; + } + + // Handles as (row, col, pos). A `None` warp shows identity corners from bbox. + let is_mesh = matches!(surface.warp, Some(WarpMode::Mesh(_))); + let mut handles: Vec<(usize, usize, [f32; 2])> = Vec::new(); + match &surface.warp { + Some(WarpMode::Mesh(mesh)) => { + for r in 0..mesh.rows as usize { + for c in 0..mesh.cols as usize { + handles.push((r, c, mesh.points[r * mesh.cols as usize + c].position)); + } + } + } + Some(WarpMode::CornerPin { corners }) => { + for (i, corner) in corners.iter().enumerate() { + let (r, c) = corner_to_rc(i); + handles.push((r, c, *corner)); + } + } + // Bezier cage handles are drawn/edited by a dedicated overlay (8i.6); + // no mesh-point handles here. + Some(WarpMode::Bezier(_)) => {} + None => { + let [x, y, w, h] = surface_bbox(surface); + let corners = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]; + for (i, corner) in corners.iter().enumerate() { + let (r, c) = corner_to_rc(i); + handles.push((r, c, *corner)); + } + } + } + + let grid_stroke = egui::Stroke::new(1.5, egui::Color32::from_rgb(120, 160, 200)); + let outline_stroke = egui::Stroke::new(2.0, egui::Color32::from_rgb(200, 200, 200)); + if let Some(WarpMode::Mesh(mesh)) = &surface.warp { + let cc = mesh.cols as usize; + let rr = mesh.rows as usize; + let at = |r: usize, c: usize| { + let p = mesh.points[r * cc + c].position; + to_screen(p[0], p[1]) + }; + for r in 0..rr { + for c in 0..cc { + if c + 1 < cc { + painter.line_segment([at(r, c), at(r, c + 1)], grid_stroke); + } + if r + 1 < rr { + painter.line_segment([at(r, c), at(r + 1, c)], grid_stroke); + } + } + } + } else { + // Corner-pin quad outline (TL, TR, BR, BL). + let order = [(0usize, 0usize), (0, 1), (1, 1), (1, 0)]; + let pts: Vec = order + .iter() + .map(|(r, c)| { + let p = handles + .iter() + .find(|h| h.0 == *r && h.1 == *c) + .map(|h| h.2) + .unwrap_or([0.0, 0.0]); + to_screen(p[0], p[1]) + }) + .collect(); + for i in 0..4 { + painter.line_segment([pts[i], pts[(i + 1) % 4]], outline_stroke); + } + } + + let state_id = ui.id().with("surface_warp").with(uuid.as_str()); + let mut dragging: Option<(usize, usize)> = ui + .memory(|m| m.data.get_temp::>(state_id)) + .flatten(); + + let corner_colors = [ + egui::Color32::from_rgb(255, 100, 100), + egui::Color32::from_rgb(100, 255, 100), + egui::Color32::from_rgb(100, 100, 255), + egui::Color32::from_rgb(255, 255, 100), + ]; + for &(row, col, position) in &handles { + let pos = to_screen(position[0], position[1]); + let active = dragging == Some((row, col)); + if is_mesh { + let is_corner = + (row == 0 || row + 1 == rows as usize) && (col == 0 || col + 1 == cols as usize); + let base = if is_corner { 5.5 } else { 4.0 }; + let r = if active { base + 2.0 } else { base }; + painter.circle_filled(pos, r, egui::Color32::from_rgb(120, 220, 255)); + } else { + let ci = rc_to_corner(row, col); + let r = if active { 10.0 } else { 8.0 }; + painter.circle_filled(pos, r, corner_colors[ci]); + } + } + + if resp.drag_started() && !bound { + if let Some(p) = resp.interact_pointer_pos() { + let mut best: Option<(usize, usize, f32)> = None; + for &(row, col, position) in &handles { + let d = p.distance(to_screen(position[0], position[1])); + if d < 18.0 && best.is_none_or(|(_, _, bd)| d < bd) { + best = Some((row, col, d)); + } + } + if let Some((row, col, _)) = best { + dragging = Some((row, col)); + } + } + } + + if let Some((row, col)) = dragging { + if resp.dragged() { + if let Some(p) = resp.interact_pointer_pos() { + let np = from_screen(p); + if is_mesh { + actions + .surface_actions + .push(SurfaceAction::SetWarpMeshPoint { + uuid: uuid.clone(), + row, + col, + position: np, + }); + } else { + actions.surface_actions.push(SurfaceAction::SetWarpCorner { + uuid: uuid.clone(), + corner_idx: rc_to_corner(row, col), + position: np, + }); + } + } + } + } + if resp.drag_stopped() { + dragging = None; + } + ui.memory_mut(|m| m.data.insert_temp(state_id, dragging)); +} + +/// Build the subdivision action for the warp steppers: in bezier mode this +/// resizes the anchor cage, otherwise the mesh grid. +fn subdiv_action(is_bezier: bool, uuid: String, cols: u32, rows: u32) -> SurfaceAction { + if is_bezier { + SurfaceAction::SetBezierCageSubdivisions { uuid, cols, rows } + } else { + SurfaceAction::SetWarpSubdivisions { uuid, cols, rows } + } +} + +/// A draggable target in the bezier warp cage editor. +#[derive(Clone, Copy, PartialEq)] +enum BezDrag { + Anchor { + r: usize, + c: usize, + }, + Handle { + horizontal: bool, + r: usize, + c: usize, + which: usize, + }, +} + +/// Bezier warp cage editor (8i.6): faint tessellated grid + control cage +/// (anchors, tangent handles, connector lines) with drag interaction. +fn render_bezier_canvas( + ui: &mut egui::Ui, + b: &crate::renderer::warp::BezierWarp, + uuid: &str, + bound: bool, + canvas_rect: egui::Rect, + resp: &egui::Response, + actions: &mut UIActions, +) { + let (cw, ch) = (canvas_rect.width(), canvas_rect.height()); + let painter = ui.painter_at(canvas_rect); + let to_screen = |p: [f32; 2]| { + egui::pos2( + canvas_rect.left() + p[0] * cw, + canvas_rect.top() + p[1] * ch, + ) + }; + let from_screen = |p: egui::Pos2| { + [ + ((p.x - canvas_rect.left()) / cw).clamp(0.0, 1.0), + ((p.y - canvas_rect.top()) / ch).clamp(0.0, 1.0), + ] + }; + let ac = b.anchor_cols as usize; + let ar = b.anchor_rows as usize; + + // 1. Faint tessellated grid — shows the actual smooth warped surface. + let mesh = b.tessellate(); + let (cc, rr) = (mesh.cols as usize, mesh.rows as usize); + let grid = egui::Stroke::new(1.0, egui::Color32::from_rgb(70, 90, 120)); + let mat = |r: usize, c: usize| to_screen(mesh.points[r * cc + c].position); + for r in 0..rr { + for c in 0..cc { + if c + 1 < cc { + painter.line_segment([mat(r, c), mat(r, c + 1)], grid); + } + if r + 1 < rr { + painter.line_segment([mat(r, c), mat(r + 1, c)], grid); + } + } + } + + // 2. Handle positions (drawn + hit-tested with priority over anchors). + let mut handles: Vec<(BezDrag, [f32; 2])> = Vec::new(); + for r in 0..ar { + for c in 0..ac - 1 { + let h = b.h_horiz[r * (ac - 1) + c]; + handles.push(( + BezDrag::Handle { + horizontal: true, + r, + c, + which: 0, + }, + h[0], + )); + handles.push(( + BezDrag::Handle { + horizontal: true, + r, + c, + which: 1, + }, + h[1], + )); + } + } + for r in 0..ar - 1 { + for c in 0..ac { + let h = b.h_vert[r * ac + c]; + handles.push(( + BezDrag::Handle { + horizontal: false, + r, + c, + which: 0, + }, + h[0], + )); + handles.push(( + BezDrag::Handle { + horizontal: false, + r, + c, + which: 1, + }, + h[1], + )); + } + } + + // 3. Connector lines (anchor → its tangent handles). + let hstroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(150, 150, 90)); + for r in 0..ar { + for c in 0..ac - 1 { + let h = b.h_horiz[r * (ac - 1) + c]; + painter.line_segment([to_screen(b.anchor(r, c)), to_screen(h[0])], hstroke); + painter.line_segment([to_screen(b.anchor(r, c + 1)), to_screen(h[1])], hstroke); + } + } + for r in 0..ar - 1 { + for c in 0..ac { + let h = b.h_vert[r * ac + c]; + painter.line_segment([to_screen(b.anchor(r, c)), to_screen(h[0])], hstroke); + painter.line_segment([to_screen(b.anchor(r + 1, c)), to_screen(h[1])], hstroke); + } + } + + // 4. Handles (small yellow squares). + for (_, p) in &handles { + painter.rect_filled( + egui::Rect::from_center_size(to_screen(*p), egui::vec2(7.0, 7.0)), + 1.0, + egui::Color32::from_rgb(230, 210, 90), + ); + } + + // 5. Anchors (cyan circles; corners larger). + for r in 0..ar { + for c in 0..ac { + let is_corner = (r == 0 || r + 1 == ar) && (c == 0 || c + 1 == ac); + painter.circle_filled( + to_screen(b.anchor(r, c)), + if is_corner { 6.0 } else { 4.5 }, + egui::Color32::from_rgb(120, 220, 255), + ); + } + } + + // 6. Drag interaction (handles take priority over anchors on tie). + let state_id = ui.id().with("surface_bezier_warp").with(uuid); + let mut drag: Option = ui + .memory(|m| m.data.get_temp::>(state_id)) + .flatten(); + if resp.drag_started() && !bound { + if let Some(p) = resp.interact_pointer_pos() { + let mut best: Option<(BezDrag, f32)> = None; + for (t, pos) in handles.iter().copied() { + let d = p.distance(to_screen(pos)); + if d < 16.0 && best.is_none_or(|(_, bd)| d < bd) { + best = Some((t, d)); + } + } + for r in 0..ar { + for c in 0..ac { + let d = p.distance(to_screen(b.anchor(r, c))); + if d < 16.0 && best.is_none_or(|(_, bd)| d < bd) { + best = Some((BezDrag::Anchor { r, c }, d)); + } + } + } + if let Some((t, _)) = best { + drag = Some(t); + } + } + } + if let Some(t) = drag { + if resp.dragged() { + if let Some(p) = resp.interact_pointer_pos() { + let np = from_screen(p); + match t { + BezDrag::Anchor { r, c } => { + actions.surface_actions.push(SurfaceAction::MoveWarpAnchor { + uuid: uuid.to_string(), + row: r, + col: c, + position: np, + }); + } + BezDrag::Handle { + horizontal, + r, + c, + which, + } => { + actions.surface_actions.push(SurfaceAction::MoveWarpHandle { + uuid: uuid.to_string(), + horizontal, + row: r, + col: c, + which, + position: np, + }); + } + } + } + } + } + if resp.drag_stopped() { + drag = None; + } + ui.memory_mut(|m| m.data.insert_temp(state_id, drag)); +} + /// Render the selected deck's full details (params, effects, blend, scaling) in the bottom bar pub(super) fn render_selected_deck_detail( ui: &mut egui::Ui, diff --git a/src/usecases/ui/panels/outputs.rs b/src/usecases/ui/panels/outputs.rs index 654ebde..f401f3e 100644 --- a/src/usecases/ui/panels/outputs.rs +++ b/src/usecases/ui/panels/outputs.rs @@ -182,17 +182,24 @@ fn render_windowed_controls( }); }); - // Calibration toggle + // Calibration mode selector (Off / Projector test card / per-Surface cards). + // Warp editing itself now lives in the stage editor's bottom detail bar. ui.horizontal(|ui| { - let cal_label = if output.calibration_mode { - "🔧 Done" - } else { - "🔧 Calibrate" - }; - if ui.button(egui::RichText::new(cal_label).small()).clicked() { - actions - .output_actions - .push(OutputAction::ToggleCalibration { idx }); + use crate::renderer::context::CalibrationMode; + ui.label(egui::RichText::new("🔧 Calibrate:").small()); + for (label, mode) in [ + ("Off", CalibrationMode::Off), + ("Projector", CalibrationMode::Projector), + ("Surfaces", CalibrationMode::Surfaces), + ] { + if ui + .selectable_label(output.calibration_mode == mode, label) + .clicked() + { + actions + .output_actions + .push(OutputAction::SetCalibrationMode { idx, mode }); + } } }); @@ -222,12 +229,6 @@ fn render_windowed_controls( for (ai, assignment) in output.surface_assignments.iter().enumerate() { ui.horizontal(|ui| { ui.label(egui::RichText::new(&assignment.surface_name).small()); - if ui.small_button("↺").on_hover_text("Reset warp").clicked() { - actions.output_actions.push(OutputAction::ResetWarp { - output_idx: idx, - assignment_idx: ai, - }); - } if ui.small_button("x").on_hover_text("Unassign").clicked() { actions.output_actions.push(OutputAction::UnassignSurface { output_idx: idx, @@ -237,10 +238,6 @@ fn render_windowed_controls( }); } - if output.calibration_mode && !output.surface_assignments.is_empty() { - render_warp_calibration(ui, idx, output, actions); - } - // Edge blending render_edge_blend_controls(ui, idx, output, actions); } @@ -423,12 +420,6 @@ fn render_headless_controls( for (ai, assignment) in output.surface_assignments.iter().enumerate() { ui.horizontal(|ui| { ui.label(egui::RichText::new(&assignment.surface_name).small()); - if ui.small_button("↺").on_hover_text("Reset warp").clicked() { - actions.output_actions.push(OutputAction::ResetWarp { - output_idx: idx, - assignment_idx: ai, - }); - } if ui.small_button("x").on_hover_text("Unassign").clicked() { actions.output_actions.push(OutputAction::UnassignSurface { output_idx: idx, @@ -1034,158 +1025,6 @@ fn render_edge_blend_controls( }); } -/// Render the warp calibration mini-canvas for an output. -/// Shows surface assignments as quads with draggable corner handles. -pub(super) fn render_warp_calibration( - ui: &mut egui::Ui, - output_idx: usize, - output: &super::super::OutputUI, - actions: &mut UIActions, -) { - ui.add_space(4.0); - ui.label( - egui::RichText::new("⊞ Drag corners to warp") - .small() - .color(egui::Color32::YELLOW), - ); - - let canvas_width = ui.available_width() - 4.0; - let canvas_height = canvas_width * 0.5625; // 16:9 - let (canvas_rect, canvas_response) = ui.allocate_exact_size( - egui::vec2(canvas_width, canvas_height), - egui::Sense::click_and_drag(), - ); - - let painter = ui.painter_at(canvas_rect); - - // Dark background - painter.rect_filled(canvas_rect, 2.0, egui::Color32::from_rgb(15, 15, 25)); - - // Convert normalized [0..1] to canvas pixel position - let to_screen = |nx: f32, ny: f32| -> egui::Pos2 { - egui::pos2( - canvas_rect.left() + nx * canvas_width, - canvas_rect.top() + ny * canvas_height, - ) - }; - let from_screen = |pos: egui::Pos2| -> [f32; 2] { - [ - ((pos.x - canvas_rect.left()) / canvas_width).clamp(0.0, 1.0), - ((pos.y - canvas_rect.top()) / canvas_height).clamp(0.0, 1.0), - ] - }; - - let corner_colors = [ - egui::Color32::from_rgb(255, 100, 100), // TL - red - egui::Color32::from_rgb(100, 255, 100), // TR - green - egui::Color32::from_rgb(100, 100, 255), // BR - blue - egui::Color32::from_rgb(255, 255, 100), // BL - yellow - ]; - let corner_labels = ["TL", "TR", "BR", "BL"]; - - // State for dragging — store as Option<(usize, usize)> consistently - let state_id = ui.id().with("warp_cal").with(output_idx); - let mut dragging: Option<(usize, usize)> = ui.memory(|mem| { - mem.data - .get_temp::>(state_id) - .flatten() - }); - - // Draw each assigned surface's warp quad and handles (corner-pin only) - for (ai, assignment) in output.surface_assignments.iter().enumerate() { - let corners = match &assignment.warp_mode { - crate::renderer::warp::WarpMode::CornerPin { corners } => corners, - crate::renderer::warp::WarpMode::Mesh(_) => continue, // Mesh warp not editable via corner handles - }; - - // Draw the warp quad outline - let screen_corners: Vec = - corners.iter().map(|c| to_screen(c[0], c[1])).collect(); - for i in 0..4 { - let j = (i + 1) % 4; - painter.line_segment( - [screen_corners[i], screen_corners[j]], - egui::Stroke::new(2.0, egui::Color32::from_rgb(200, 200, 200)), - ); - } - - // Draw surface name at center - let cx = corners.iter().map(|c| c[0]).sum::() / 4.0; - let cy = corners.iter().map(|c| c[1]).sum::() / 4.0; - painter.text( - to_screen(cx, cy), - egui::Align2::CENTER_CENTER, - &assignment.surface_name, - egui::FontId::proportional(11.0), - egui::Color32::WHITE, - ); - - // Draw corner handles - let handle_radius = 8.0; - for (ci, corner) in corners.iter().enumerate() { - let pos = to_screen(corner[0], corner[1]); - let is_dragging = dragging == Some((ai, ci)); - let r = if is_dragging { - handle_radius + 2.0 - } else { - handle_radius - }; - painter.circle_filled(pos, r, corner_colors[ci]); - painter.text( - pos, - egui::Align2::CENTER_CENTER, - corner_labels[ci], - egui::FontId::proportional(9.0), - egui::Color32::BLACK, - ); - } - } - - // Handle dragging (corner-pin only) - if canvas_response.drag_started() { - if let Some(pos) = canvas_response.interact_pointer_pos() { - // Find nearest corner - let mut best: Option<(usize, usize, f32)> = None; - for (ai, assignment) in output.surface_assignments.iter().enumerate() { - let corners = match &assignment.warp_mode { - crate::renderer::warp::WarpMode::CornerPin { corners } => corners, - crate::renderer::warp::WarpMode::Mesh(_) => continue, - }; - for (ci, corner) in corners.iter().enumerate() { - let screen_pos = to_screen(corner[0], corner[1]); - let dist = pos.distance(screen_pos); - if dist < 20.0 && best.is_none_or(|(_, _, d)| dist < d) { - best = Some((ai, ci, dist)); - } - } - } - if let Some((ai, ci, _)) = best { - dragging = Some((ai, ci)); - } - } - } - - if let Some((ai, ci)) = dragging { - if canvas_response.dragged() { - if let Some(pos) = canvas_response.interact_pointer_pos() { - let new_pos = from_screen(pos); - actions.output_actions.push(OutputAction::SetWarpCorner { - output_idx, - assignment_idx: ai, - corner_idx: ci, - position: new_pos, - }); - } - } - } - - if canvas_response.drag_stopped() { - dragging = None; - } - - ui.memory_mut(|mem| mem.data.insert_temp(state_id, dragging)); -} - #[cfg(test)] mod tests { use super::*; @@ -1211,7 +1050,7 @@ mod tests { is_active: true, active_duration: std::time::Duration::ZERO, surface_assignments: vec![], - calibration_mode: false, + calibration_mode: crate::renderer::context::CalibrationMode::Off, edge_blend_mode: crate::renderer::edge_blend::EdgeBlendMode::default(), edge_blend: crate::renderer::edge_blend::EdgeBlendConfig::default(), rotation: crate::renderer::context::OutputRotation::default(), diff --git a/src/usecases/ui/panels/stage.rs b/src/usecases/ui/panels/stage.rs index 1dfa643..a7d8e23 100644 --- a/src/usecases/ui/panels/stage.rs +++ b/src/usecases/ui/panels/stage.rs @@ -1,13 +1,13 @@ //! Surface editor and stage editor panels. use super::super::{ - CameraDetectAction, CameraDetectMode, DomeAction, SurfaceAction, UIActions, UIData, + CameraDetectAction, CameraDetectMode, DomeAction, SurfaceAction, SurfaceUI, UIActions, UIData, }; use super::geometry::polygon_shape; use crate::renderer::context::OutputSource; use crate::renderer::slicer::DomePreset; use crate::surface::detect::{DetectionMethod, HullMode}; -use crate::surface::{CircleHint, ContentMapping, SurfaceOutputType}; +use crate::surface::{CircleHint, ContentMapping, CubicHandle, PathSegment, SurfaceOutputType}; /// Drag state for edge dragging: /// (surface_uuid, contour_idx, edge_start_idx, original_v0, original_v1, grab_point_on_edge) @@ -22,6 +22,147 @@ type HitSurface = (String, f32, f32); /// Combined hit-test result: (vertex, edge, surface) type HitTestResult = (Option, Option, Option); +/// Pixel margin between the content bounding box and the transform gizmo box. +/// Kept wide enough that the corner scale handles clear the surface's own +/// corner vertices, so vertex editing and gizmo scaling don't fight over the +/// same clicks. +const GIZMO_MARGIN_PX: f32 = 20.0; +/// Pixel offset of the rotation knob above the gizmo box's top edge. +const GIZMO_ROTATE_OFFSET_PX: f32 = 28.0; +/// Hit-test radius (pixels) for gizmo scale/rotate handles. +const GIZMO_HANDLE_HIT_PX: f32 = 14.0; + +/// Active scale-drag on the transform gizmo. `last_sx`/`last_sy` track the total +/// scale so far so each frame can emit only the incremental delta. +#[derive(Debug, Clone, Copy)] +struct ScaleDrag { + pivot: [f32; 2], + start_handle: [f32; 2], + last_sx: f32, + last_sy: f32, + axis_x: bool, + axis_y: bool, +} + +/// Active rotate-drag on the transform gizmo. `last_angle` tracks the previous +/// frame's pointer angle so each frame emits only the incremental delta. +#[derive(Debug, Clone, Copy)] +struct RotateDrag { + center: [f32; 2], + last_angle: f32, +} + +/// Union bounding box `(x, y, w, h)` in normalized coords of the selected +/// surfaces, or `None` when the selection is empty. +fn selection_bounds( + surfaces: &[SurfaceUI], + selected: &std::collections::BTreeSet, +) -> Option<(f32, f32, f32, f32)> { + let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); + let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); + let mut any = false; + for s in surfaces.iter().filter(|s| selected.contains(&s.uuid)) { + for v in s.vertices.iter().chain(s.extra_contours.iter().flatten()) { + min_x = min_x.min(v[0]); + min_y = min_y.min(v[1]); + max_x = max_x.max(v[0]); + max_y = max_y.max(v[1]); + any = true; + } + } + if any { + Some((min_x, min_y, max_x - min_x, max_y - min_y)) + } else { + None + } +} + +/// The gizmo's eight scale handles as `(handle, pivot, scales_x, scales_y)` in +/// normalized coords for the given box. The pivot is always the opposite handle. +fn gizmo_scale_handles(x: f32, y: f32, w: f32, h: f32) -> [([f32; 2], [f32; 2], bool, bool); 8] { + let (l, t, r, b) = (x, y, x + w, y + h); + let (mx, my) = (x + w * 0.5, y + h * 0.5); + [ + ([l, t], [r, b], true, true), // top-left ↔ bottom-right + ([r, t], [l, b], true, true), // top-right ↔ bottom-left + ([r, b], [l, t], true, true), // bottom-right ↔ top-left + ([l, b], [r, t], true, true), // bottom-left ↔ top-right + ([mx, t], [mx, b], false, true), // top ↔ bottom + ([mx, b], [mx, t], false, true), // bottom ↔ top + ([r, my], [l, my], true, false), // right ↔ left + ([l, my], [r, my], true, false), // left ↔ right + ] +} + +/// If the pointer began on a transform-gizmo handle for the current selection, +/// start the matching scale/rotate drag and return `true`, clearing any other +/// drag state. Returns `false` when no gizmo handle was hit. +#[allow(clippy::too_many_arguments)] +fn try_begin_gizmo_drag( + state: &mut StageEditorState, + surfaces: &[SurfaceUI], + pos: egui::Pos2, + nx: f32, + ny: f32, + canvas_rect: egui::Rect, + canvas_width: f32, + canvas_height: f32, +) -> bool { + let Some((bx, by, bw, bh)) = selection_bounds(surfaces, &state.selected_surfaces) else { + return false; + }; + let mx = GIZMO_MARGIN_PX / canvas_width; + let my = GIZMO_MARGIN_PX / canvas_height; + let (gx, gy, gw, gh) = (bx - mx, by - my, bw + 2.0 * mx, bh + 2.0 * my); + let to_px = |p: [f32; 2]| { + egui::pos2( + canvas_rect.left() + p[0] * canvas_width, + canvas_rect.top() + p[1] * canvas_height, + ) + }; + let center = [gx + gw * 0.5, gy + gh * 0.5]; + + // Rotation knob first (it sits outside the box, so it can't clash). + let top_mid = to_px([gx + gw * 0.5, gy]); + let knob = egui::pos2(top_mid.x, top_mid.y - GIZMO_ROTATE_OFFSET_PX); + if pos.distance(knob) < GIZMO_HANDLE_HIT_PX { + let angle = (ny - center[1]).atan2(nx - center[0]); + clear_all_drag(state); + state.dragging_rotate = Some(RotateDrag { + center, + last_angle: angle, + }); + return true; + } + + for (handle, pivot, axis_x, axis_y) in gizmo_scale_handles(gx, gy, gw, gh) { + if pos.distance(to_px(handle)) < GIZMO_HANDLE_HIT_PX { + clear_all_drag(state); + state.dragging_scale = Some(ScaleDrag { + pivot, + start_handle: handle, + last_sx: 1.0, + last_sy: 1.0, + axis_x, + axis_y, + }); + return true; + } + } + false +} + +/// Clear every drag-in-progress field on the stage editor state. +fn clear_all_drag(state: &mut StageEditorState) { + state.dragging_vertex = None; + state.moving_surface = None; + state.selection_rect_start = None; + state.dragging_radius = None; + state.dragging_edge = None; + state.dragging_scale = None; + state.dragging_rotate = None; +} + /// Stage editor mode: 2D polygon editing or 3D dome mode. #[derive(Debug, Clone, Copy, Default, PartialEq)] enum StageEditorMode { @@ -493,6 +634,57 @@ pub(super) fn render_surface_editor(ui: &mut egui::Ui, data: &UIData, actions: & } }); }); + + // Precision transform: bounds of the primary contour (X/Y = position, + // W/H = size). Editing emits Move/Scale so it stays in sync with the gizmo. + { + let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); + let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); + for v in &surface.vertices { + min_x = min_x.min(v[0]); + min_y = min_y.min(v[1]); + max_x = max_x.max(v[0]); + max_y = max_y.max(v[1]); + } + if min_x <= max_x { + let (x0, y0, w0, h0) = (min_x, min_y, max_x - min_x, max_y - min_y); + let (mut xv, mut yv) = (x0, y0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("X").weak().size(10.0)); + let rx = + ui.add(egui::DragValue::new(&mut xv).speed(0.002).max_decimals(3)); + ui.label(egui::RichText::new("Y").weak().size(10.0)); + let ry = + ui.add(egui::DragValue::new(&mut yv).speed(0.002).max_decimals(3)); + if rx.changed() || ry.changed() { + actions.surface_actions.push(SurfaceAction::MoveDelta { + uuid: surface.uuid.clone(), + dx: xv - x0, + dy: yv - y0, + }); + } + }); + let (mut wv, mut hv) = (w0, h0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("W").weak().size(10.0)); + let rw = + ui.add(egui::DragValue::new(&mut wv).speed(0.002).max_decimals(3)); + ui.label(egui::RichText::new("H").weak().size(10.0)); + let rh = + ui.add(egui::DragValue::new(&mut hv).speed(0.002).max_decimals(3)); + if rw.changed() || rh.changed() { + let sx = if w0 > 1e-5 { wv.max(0.001) / w0 } else { 1.0 }; + let sy = if h0 > 1e-5 { hv.max(0.001) / h0 } else { 1.0 }; + actions.surface_actions.push(SurfaceAction::Scale { + uuid: surface.uuid.clone(), + sx, + sy, + pivot: [x0, y0], + }); + } + }); + } + } }); ui.add_space(2.0); } @@ -530,6 +722,7 @@ enum DrawingTool { Rectangle, Polygon, Circle, + Bezier, } /// State for active drawing operations in the stage editor @@ -557,6 +750,14 @@ struct StageEditorState { /// Drag state for edge dragging: (surface_uuid, contour_idx, edge_start_idx, /// original_v0, original_v1, grab_point_on_edge) dragging_edge: Option, + /// Drag state for the transform gizmo's scale handles. + dragging_scale: Option, + /// Drag state for the transform gizmo's rotation knob. + dragging_rotate: Option, + /// Drag state for a curve anchor: (surface_uuid, anchor_idx) + dragging_anchor: Option<(String, usize)>, + /// Drag state for a cubic control handle: (surface_uuid, segment_idx, handle) + dragging_handle: Option<(String, usize, CubicHandle)>, } /// Full-screen stage editor — replaces the deck view @@ -595,6 +796,11 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu "⬤ Circle", "Draw circle/N-gon surfaces (C)", ), + ( + DrawingTool::Bezier, + "✒ Bezier", + "Bezier edit — click an edge to curve/straighten it, drag anchors & handles", + ), ]; for (tool, label, tooltip) in &tools { let selected = state.tool == *tool; @@ -1141,8 +1347,51 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu egui::Color32::WHITE, ); - // For circles: render radius handle instead of vertex handles - if is_selected && surface.circle_hint.is_some() { + // For path-backed (bezier) surfaces: draw the anchor/handle overlay + // instead of the dense flattened-vertex handles. + if let Some(path) = &surface.path { + let to_px = |p: [f32; 2]| { + egui::pos2( + canvas_rect.left() + p[0] * canvas_width, + canvas_rect.top() + p[1] * canvas_height, + ) + }; + let anchor_color = egui::Color32::from_rgb(90, 220, 220); + let handle_color = egui::Color32::from_rgb(255, 180, 60); + // Control handles + connector lines (Bezier tool only). + if state.tool == DrawingTool::Bezier { + for (si, seg) in path.segments.iter().enumerate() { + if let PathSegment::Cubic { c1, c2, .. } = seg { + let s_px = to_px(path.segment_start(si)); + let e_px = to_px(seg.end()); + let (c1_px, c2_px) = (to_px(*c1), to_px(*c2)); + let line = egui::Stroke::new(1.0, egui::Color32::GRAY); + painter.line_segment([s_px, c1_px], line); + painter.line_segment([e_px, c2_px], line); + for cp in [c1_px, c2_px] { + painter.circle_filled(cp, 5.0, handle_color); + painter.circle_stroke( + cp, + 5.0, + egui::Stroke::new(1.0, egui::Color32::BLACK), + ); + } + } + } + } + // Anchors (always shown for path surfaces). + for ai in 0..path.anchor_count() { + let a_px = to_px(path.anchor_pos(ai)); + let r = egui::Rect::from_center_size(a_px, egui::vec2(9.0, 9.0)); + painter.rect_filled(r, 2.0, anchor_color); + painter.rect_stroke( + r, + 2.0, + egui::Stroke::new(1.0, egui::Color32::BLACK), + egui::StrokeKind::Outside, + ); + } + } else if is_selected && surface.circle_hint.is_some() { let Some(hint) = surface.circle_hint else { continue; }; @@ -1206,6 +1455,44 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu } } + // ── Transform gizmo (Select tool, non-empty selection) ─────────── + if state.tool == DrawingTool::Select { + if let Some((bx, by, bw, bh)) = selection_bounds(&data.surfaces, &state.selected_surfaces) { + let mx = GIZMO_MARGIN_PX / canvas_width; + let my = GIZMO_MARGIN_PX / canvas_height; + let (gx, gy, gw, gh) = (bx - mx, by - my, bw + 2.0 * mx, bh + 2.0 * my); + let to_px = |p: [f32; 2]| { + egui::pos2( + canvas_rect.left() + p[0] * canvas_width, + canvas_rect.top() + p[1] * canvas_height, + ) + }; + let gcolor = egui::Color32::from_rgb(90, 200, 255); + let box_rect = egui::Rect::from_two_pos(to_px([gx, gy]), to_px([gx + gw, gy + gh])); + painter.rect_stroke( + box_rect, + 0.0, + egui::Stroke::new(1.0, gcolor), + egui::StrokeKind::Outside, + ); + for (handle, _pivot, _ax, _ay) in gizmo_scale_handles(gx, gy, gw, gh) { + let hr = egui::Rect::from_center_size(to_px(handle), egui::vec2(8.0, 8.0)); + painter.rect_filled(hr, 1.0, gcolor); + painter.rect_stroke( + hr, + 1.0, + egui::Stroke::new(1.0, egui::Color32::BLACK), + egui::StrokeKind::Outside, + ); + } + let top_mid = to_px([gx + gw * 0.5, gy]); + let knob = egui::pos2(top_mid.x, top_mid.y - GIZMO_ROTATE_OFFSET_PX); + painter.line_segment([top_mid, knob], egui::Stroke::new(1.0, gcolor)); + painter.circle_filled(knob, 5.0, gcolor); + painter.circle_stroke(knob, 5.0, egui::Stroke::new(1.0, egui::Color32::BLACK)); + } + } + // Draw in-progress polygon if !state.polygon_verts.is_empty() && state.tool == DrawingTool::Polygon { let pixel_verts: Vec = state @@ -1300,6 +1587,15 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu [snap(nx), snap(ny)] }; + // Raw (un-snapped) normalized cursor position. Bezier handle/anchor editing + // needs sub-grid precision: hit-testing against off-grid control points and + // dragging them must not snap-jump to the grid. + let to_norm_raw = |pos: egui::Pos2| -> [f32; 2] { + let nx = ((pos.x - canvas_rect.left()) / canvas_width).clamp(0.0, 1.0); + let ny = ((pos.y - canvas_rect.top()) / canvas_height).clamp(0.0, 1.0); + [nx, ny] + }; + // Helper: check if a point is inside any existing surface (returns surface UUID) let point_in_any_surface = |nx: f32, ny: f32| -> Option { for surface in data.surfaces.iter().rev() { @@ -1386,13 +1682,16 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu for surface in data.surfaces.iter().rev() { let uid = &surface.uuid; + // Path-backed surfaces edit via the Bezier tool; their + // flattened vertices/edges are not directly grabbable here. + let is_path = surface.path.is_some(); // Check all contours for vertex/edge hits let contours: Vec<&Vec<[f32; 2]>> = std::iter::once(&surface.vertices) .chain(surface.extra_contours.iter()) .collect(); for (ci, verts) in contours.iter().enumerate() { for (vi, v) in verts.iter().enumerate() { - if pixel_dist(nx, ny, v[0], v[1]) < vertex_threshold_px { + if !is_path && pixel_dist(nx, ny, v[0], v[1]) < vertex_threshold_px { found_vertex = Some((uid.clone(), ci, vi)); return (found_vertex, None, None); } @@ -1400,7 +1699,7 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu } // Standard edge detection (narrow threshold, works from outside) - if found_edge.is_none() { + if !is_path && found_edge.is_none() { if let Some((ci, ei, proj, _d)) = find_closest_edge(nx, ny, surface, edge_threshold_px) { @@ -1435,7 +1734,7 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu found_surface = Some((uid.clone(), nx, ny)); // If cursor is inside the surface but no edge found yet, // try again with a wider threshold to catch edges from inside. - if found_edge.is_none() { + if !is_path && found_edge.is_none() { if let Some((ci, ei, proj, _d)) = find_closest_edge(nx, ny, surface, edge_inner_threshold_px) { @@ -1448,9 +1747,11 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu (found_vertex, found_edge, found_surface) }; - // Hover feedback: change cursor when over interactive elements + // Hover feedback: change cursor when over interactive elements. + // Hit-testing uses the raw (un-snapped) cursor so off-grid vertices + // and edges remain grabbable; snapping applies only to placement. if let Some(pos) = canvas_response.hover_pos() { - let [nx, ny] = to_norm(pos); + let [nx, ny] = to_norm_raw(pos); let (found_vertex, found_edge, found_surface) = hit_test(nx, ny); if found_vertex.is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); @@ -1466,7 +1767,7 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu // Click to select (without drag) if canvas_response.clicked() { if let Some(pos) = canvas_response.interact_pointer_pos() { - let [nx, ny] = to_norm(pos); + let [nx, ny] = to_norm_raw(pos); let (found_vertex, _found_edge, found_surface) = hit_test(nx, ny); if let Some((si, _ci, _vi)) = found_vertex { if shift_held { @@ -1496,7 +1797,7 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu // Double-click on edge to insert vertex if canvas_response.double_clicked() { if let Some(pos) = canvas_response.interact_pointer_pos() { - let [nx, ny] = to_norm(pos); + let [nx, ny] = to_norm_raw(pos); let (_found_vertex, found_edge, _found_surface) = hit_test(nx, ny); if let Some((uuid, _ci, ei, snap_pos)) = found_edge { let snapped = [snap(snap_pos[0]), snap(snap_pos[1])]; @@ -1515,64 +1816,60 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu if canvas_response.drag_started() { if let Some(pos) = canvas_response.interact_pointer_pos() { let [nx, ny] = to_norm(pos); + // Raw cursor for hit-testing; off-grid vertices/edges (e.g. + // after a gizmo scale/rotate) stay grabbable. Placement and + // drag-reference math below stay in snapped space. + let [rnx, rny] = to_norm_raw(pos); + + // Transform gizmo handles take priority over vertex/edge/body. + // The gizmo hit-tests in raw pixels; nx,ny only seed the + // rotate start angle (kept snapped to match the drag loop). + let gizmo_consumed = try_begin_gizmo_drag( + &mut state, + &data.surfaces, + pos, + nx, + ny, + canvas_rect, + canvas_width, + canvas_height, + ); - // Check for radius handle hit on selected circles first - let mut found_radius_handle = None; - for sel_uuid in &state.selected_surfaces { - if let Some(surface) = data.surfaces.iter().find(|s| s.uuid == *sel_uuid) { - if let Some(hint) = &surface.circle_hint { - let hx = hint.center[0] + hint.radius; - let hy = hint.center[1]; - if pixel_dist(nx, ny, hx, hy) < 14.0 { - found_radius_handle = Some(sel_uuid.clone()); - break; + if !gizmo_consumed { + // Check for radius handle hit on selected circles first + let mut found_radius_handle = None; + for sel_uuid in &state.selected_surfaces { + if let Some(surface) = + data.surfaces.iter().find(|s| s.uuid == *sel_uuid) + { + if let Some(hint) = &surface.circle_hint { + let hx = hint.center[0] + hint.radius; + let hy = hint.center[1]; + if pixel_dist(rnx, rny, hx, hy) < 14.0 { + found_radius_handle = Some(sel_uuid.clone()); + break; + } } } } - } - if let Some(uuid) = found_radius_handle { - state.dragging_radius = Some(uuid); - state.dragging_vertex = None; - state.moving_surface = None; - state.selection_rect_start = None; - state.dragging_edge = None; - } else { - let (found_vertex, found_edge, found_surface) = hit_test(nx, ny); - - if let Some((uuid, ci, vi)) = found_vertex { - // If vertex drag on a circle, auto-convert to polygon first - if data - .surfaces - .iter() - .find(|s| s.uuid == uuid) - .is_some_and(|s| s.circle_hint.is_some()) - { - actions - .surface_actions - .push(SurfaceAction::ConvertToPolygon { uuid: uuid.clone() }); - } - if !shift_held { - state.selected_surfaces.clear(); - } - state.selected_surfaces.insert(uuid.clone()); - state.dragging_vertex = Some((uuid, ci, vi)); + if let Some(uuid) = found_radius_handle { + state.dragging_radius = Some(uuid); + state.dragging_vertex = None; state.moving_surface = None; state.selection_rect_start = None; state.dragging_edge = None; - } else if let Some((uuid, ci, ei, grab_pt)) = found_edge { - // Edge drag: store original edge endpoints + grab point - if let Some(surface) = data.surfaces.iter().find(|s| s.uuid == uuid) { - let verts = if ci == 0 { - &surface.vertices - } else { - &surface.extra_contours[ci - 1] - }; - let ej = (ei + 1) % verts.len(); - let v0 = verts[ei]; - let v1 = verts[ej]; - // Auto-convert circle to polygon before edge drag - if surface.circle_hint.is_some() { + } else { + let (found_vertex, found_edge, found_surface) = hit_test(rnx, rny); + + if let Some((uuid, ci, vi)) = found_vertex { + // If vertex drag on a circle, auto-convert to polygon first + if data + .surfaces + .iter() + .find(|s| s.uuid == uuid) + .is_some_and(|s| s.circle_hint.is_some()) + { actions .surface_actions .push(SurfaceAction::ConvertToPolygon { @@ -1583,28 +1880,59 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu state.selected_surfaces.clear(); } state.selected_surfaces.insert(uuid.clone()); - state.dragging_edge = Some((uuid, ci, ei, v0, v1, grab_pt)); - state.dragging_vertex = None; + state.dragging_vertex = Some((uuid, ci, vi)); state.moving_surface = None; state.selection_rect_start = None; + state.dragging_edge = None; + } else if let Some((uuid, ci, ei, _proj)) = found_edge { + // Edge drag: store original edge endpoints + grab point. + // Grab point is the snapped cursor so the drag loop + // (also snapped) starts with a zero delta — no jump. + if let Some(surface) = data.surfaces.iter().find(|s| s.uuid == uuid) + { + let verts = if ci == 0 { + &surface.vertices + } else { + &surface.extra_contours[ci - 1] + }; + let ej = (ei + 1) % verts.len(); + let v0 = verts[ei]; + let v1 = verts[ej]; + // Auto-convert circle to polygon before edge drag + if surface.circle_hint.is_some() { + actions.surface_actions.push( + SurfaceAction::ConvertToPolygon { uuid: uuid.clone() }, + ); + } + if !shift_held { + state.selected_surfaces.clear(); + } + state.selected_surfaces.insert(uuid.clone()); + state.dragging_edge = Some((uuid, ci, ei, v0, v1, [nx, ny])); + state.dragging_vertex = None; + state.moving_surface = None; + state.selection_rect_start = None; + } + } else if let Some((uuid, _rx, _ry)) = found_surface { + if !shift_held && !state.selected_surfaces.contains(&uuid) { + state.selected_surfaces.clear(); + } + state.selected_surfaces.insert(uuid.clone()); + // Store the snapped grab point so the move loop + // (snapped) starts with a zero delta — no jump. + state.moving_surface = Some((uuid, nx, ny)); + state.dragging_vertex = None; + state.selection_rect_start = None; + state.dragging_edge = None; + } else { + if !shift_held { + state.selected_surfaces.clear(); + } + state.selection_rect_start = Some([nx, ny]); + state.dragging_vertex = None; + state.moving_surface = None; + state.dragging_edge = None; } - } else if let Some((uuid, lx, ly)) = found_surface { - if !shift_held && !state.selected_surfaces.contains(&uuid) { - state.selected_surfaces.clear(); - } - state.selected_surfaces.insert(uuid.clone()); - state.moving_surface = Some((uuid, lx, ly)); - state.dragging_vertex = None; - state.selection_rect_start = None; - state.dragging_edge = None; - } else { - if !shift_held { - state.selected_surfaces.clear(); - } - state.selection_rect_start = Some([nx, ny]); - state.dragging_vertex = None; - state.moving_surface = None; - state.dragging_edge = None; } } } @@ -1614,7 +1942,76 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu if let Some(pos) = canvas_response.interact_pointer_pos() { let [nx, ny] = to_norm(pos); - if let Some(ref uuid) = state.dragging_radius { + if let Some(rot) = state.dragging_rotate { + let angle = (ny - rot.center[1]).atan2(nx - rot.center[0]); + let mut delta = angle - rot.last_angle; + if delta > std::f32::consts::PI { + delta -= std::f32::consts::TAU; + } else if delta < -std::f32::consts::PI { + delta += std::f32::consts::TAU; + } + for surf_uuid in &state.selected_surfaces { + if data.surfaces.iter().any(|s| s.uuid == *surf_uuid) { + actions.surface_actions.push(SurfaceAction::Rotate { + uuid: surf_uuid.clone(), + angle: delta, + pivot: rot.center, + }); + } + } + state.dragging_rotate = Some(RotateDrag { + center: rot.center, + last_angle: angle, + }); + } else if let Some(sc) = state.dragging_scale { + let raw_sx = if sc.axis_x { + let d = sc.start_handle[0] - sc.pivot[0]; + if d.abs() > 1e-5 { + (nx - sc.pivot[0]) / d + } else { + 1.0 + } + } else { + 1.0 + }; + let raw_sy = if sc.axis_y { + let d = sc.start_handle[1] - sc.pivot[1]; + if d.abs() > 1e-5 { + (ny - sc.pivot[1]) / d + } else { + 1.0 + } + } else { + 1.0 + }; + let total_sx = raw_sx.max(0.05); + let total_sy = raw_sy.max(0.05); + let dsx = if sc.last_sx.abs() > 1e-5 { + total_sx / sc.last_sx + } else { + 1.0 + }; + let dsy = if sc.last_sy.abs() > 1e-5 { + total_sy / sc.last_sy + } else { + 1.0 + }; + for surf_uuid in &state.selected_surfaces { + if data.surfaces.iter().any(|s| s.uuid == *surf_uuid) { + actions.surface_actions.push(SurfaceAction::Scale { + uuid: surf_uuid.clone(), + sx: dsx, + sy: dsy, + pivot: sc.pivot, + }); + } + } + state.dragging_scale = Some(ScaleDrag { + last_sx: total_sx, + last_sy: total_sy, + ..sc + }); + } else if let Some(ref uuid) = state.dragging_radius { // Compute new radius from cursor distance to circle center if let Some(surface) = data.surfaces.iter().find(|s| s.uuid == *uuid) { if let Some(hint) = &surface.circle_hint { @@ -1752,6 +2149,8 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu state.moving_surface = None; state.dragging_radius = None; state.dragging_edge = None; + state.dragging_scale = None; + state.dragging_rotate = None; } // Delete selected surfaces (handled below via keymap) @@ -1883,6 +2282,161 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu } } } + + DrawingTool::Bezier => { + let pixel_dist = |nx: f32, ny: f32, vx: f32, vy: f32| -> f32 { + let dx_px = (nx - vx) * canvas_width; + let dy_px = (ny - vy) * canvas_height; + (dx_px * dx_px + dy_px * dy_px).sqrt() + }; + let seg_dist = |nx: f32, ny: f32, a: [f32; 2], b: [f32; 2]| -> f32 { + let dx = (b[0] - a[0]) * canvas_width; + let dy = (b[1] - a[1]) * canvas_height; + let len_sq = dx * dx + dy * dy; + if len_sq < 1e-6 { + return pixel_dist(nx, ny, a[0], a[1]); + } + let px = (nx - a[0]) * canvas_width; + let py = (ny - a[1]) * canvas_height; + let t = ((px * dx + py * dy) / len_sq).clamp(0.0, 1.0); + pixel_dist(nx, ny, a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])) + }; + let polyline_dist = |nx: f32, ny: f32, pts: &[[f32; 2]]| -> f32 { + pts.windows(2) + .map(|w| seg_dist(nx, ny, w[0], w[1])) + .fold(f32::MAX, f32::min) + }; + let handle_hit_px = 16.0; + let anchor_hit_px = 16.0; + let edge_hit_px = 10.0; + + let hit_handle = |nx: f32, ny: f32| -> Option<(String, usize, CubicHandle)> { + for surface in data.surfaces.iter().rev() { + if let Some(path) = &surface.path { + for (si, seg) in path.segments.iter().enumerate() { + if let PathSegment::Cubic { c1, c2, .. } = seg { + if pixel_dist(nx, ny, c1[0], c1[1]) < handle_hit_px { + return Some((surface.uuid.clone(), si, CubicHandle::C1)); + } + if pixel_dist(nx, ny, c2[0], c2[1]) < handle_hit_px { + return Some((surface.uuid.clone(), si, CubicHandle::C2)); + } + } + } + } + } + None + }; + let hit_anchor = |nx: f32, ny: f32| -> Option<(String, usize)> { + for surface in data.surfaces.iter().rev() { + if let Some(path) = &surface.path { + for ai in 0..path.anchor_count() { + let a = path.anchor_pos(ai); + if pixel_dist(nx, ny, a[0], a[1]) < anchor_hit_px { + return Some((surface.uuid.clone(), ai)); + } + } + } + } + None + }; + let hit_edge = |nx: f32, ny: f32| -> Option<(String, usize, bool)> { + let mut best: Option<(String, usize, bool, f32)> = None; + for surface in data.surfaces.iter().rev() { + if let Some(path) = &surface.path { + for ei in 0..path.edge_count() { + let d = polyline_dist(nx, ny, &path.sample_edge(ei, 12)); + if d < edge_hit_px && best.as_ref().is_none_or(|b| d < b.3) { + best = Some((surface.uuid.clone(), ei, path.is_edge_cubic(ei), d)); + } + } + } else { + let verts = &surface.vertices; + let n = verts.len(); + for ei in 0..n { + let ej = (ei + 1) % n; + let d = seg_dist(nx, ny, verts[ei], verts[ej]); + if d < edge_hit_px && best.as_ref().is_none_or(|b| d < b.3) { + best = Some((surface.uuid.clone(), ei, false, d)); + } + } + } + } + best.map(|(u, e, c, _)| (u, e, c)) + }; + + // Hover feedback. + if let Some(pos) = canvas_response.hover_pos() { + let [nx, ny] = to_norm_raw(pos); + if hit_handle(nx, ny).is_some() || hit_anchor(nx, ny).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } else if hit_edge(nx, ny).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + + // Click an edge to toggle line <-> cubic. + if canvas_response.clicked() { + if let Some(pos) = canvas_response.interact_pointer_pos() { + let [nx, ny] = to_norm_raw(pos); + if hit_handle(nx, ny).is_none() && hit_anchor(nx, ny).is_none() { + if let Some((uuid, edge_idx, is_cubic)) = hit_edge(nx, ny) { + actions.surface_actions.push(SurfaceAction::ConvertEdge { + uuid: uuid.clone(), + edge_idx, + to_cubic: !is_cubic, + }); + state.selected_surfaces.clear(); + state.selected_surfaces.insert(uuid); + } + } + } + } + + // Begin dragging a control handle or an anchor. + if canvas_response.drag_started() { + if let Some(pos) = canvas_response.interact_pointer_pos() { + let [nx, ny] = to_norm_raw(pos); + if let Some((uuid, si, handle)) = hit_handle(nx, ny) { + state.selected_surfaces.clear(); + state.selected_surfaces.insert(uuid.clone()); + state.dragging_handle = Some((uuid, si, handle)); + state.dragging_anchor = None; + } else if let Some((uuid, ai)) = hit_anchor(nx, ny) { + state.selected_surfaces.clear(); + state.selected_surfaces.insert(uuid.clone()); + state.dragging_anchor = Some((uuid, ai)); + state.dragging_handle = None; + } + } + } + + // Apply the active drag. + if canvas_response.dragged() { + if let Some(pos) = canvas_response.interact_pointer_pos() { + let [nx, ny] = to_norm_raw(pos); + if let Some((ref uuid, si, handle)) = state.dragging_handle { + actions.surface_actions.push(SurfaceAction::MoveHandle { + uuid: uuid.clone(), + segment_idx: si, + handle, + pos: [nx, ny], + }); + } else if let Some((ref uuid, ai)) = state.dragging_anchor { + actions.surface_actions.push(SurfaceAction::MoveAnchor { + uuid: uuid.clone(), + anchor_idx: ai, + pos: [nx, ny], + }); + } + } + } + + if canvas_response.drag_stopped() { + state.dragging_handle = None; + state.dragging_anchor = None; + } + } } // Keyboard shortcuts (data-driven via keymap) @@ -1950,6 +2504,14 @@ pub(super) fn render_stage_editor(ui: &mut egui::Ui, data: &UIData, actions: &mu } } + // Publish the current selection so the bottom detail bar can edit the + // selected surface's warp (8i.5). + let published: Vec = state.selected_surfaces.iter().cloned().collect(); + ui.ctx().memory_mut(|mem| { + mem.data + .insert_temp(super::deck_detail::stage_selection_id(), published) + }); + // Persist state ui.memory_mut(|mem| mem.data.insert_temp(state_id, state)); } diff --git a/tests/engine_integration.rs b/tests/engine_integration.rs index 167a4d9..38f8f19 100644 --- a/tests/engine_integration.rs +++ b/tests/engine_integration.rs @@ -1,7 +1,7 @@ //! Engine integration tests — multi-step command workflows through real headless VardaApp. use varda::app::{AppConfig, VardaApp}; -use varda::engine::{BlendMode, CommandResult, EngineCommand}; +use varda::engine::{BlendMode, CommandResult, EngineCommand, ErrorCode}; use varda::modulation::LFOWaveform; use clap::Parser; @@ -1280,3 +1280,237 @@ fn chaos_state_consistency_after_storm() { } assert!((state.mixer.crossfader - 0.75).abs() < 1e-4); } + +// ── Mesh-warp editing (8i.5) ───────────────────────────────────────── + +/// Full pipeline: add a surface, create a headless output, assign the surface, +/// then subdivide its warp into a mesh and drag an interior point — verifying +/// each step through the engine snapshot. Mirrors how the UI and API both drive +/// per-assignment mesh warp. +#[test] +fn mesh_warp_subdivide_and_drag_point() { + use varda::renderer::context::{OutputSource, OutputTarget}; + use varda::renderer::warp::WarpMode; + + let Some(mut app) = headless_app() else { + return; + }; + + // Surface + output + assignment. + send_cmd( + &mut app, + EngineCommand::AddSurface { + name: "Warp Target".into(), + source: OutputSource::Master, + }, + ); + let surface_uuid = app + .build_engine_state() + .outputs + .surfaces + .iter() + .find(|s| s.name == "Warp Target") + .unwrap() + .uuid + .clone(); + send_cmd( + &mut app, + EngineCommand::CreateHeadlessOutput { + target: OutputTarget::NdiSend { + sender_name: "Warp Out".into(), + }, + }, + ); + let r = send_cmd( + &mut app, + EngineCommand::AssignSurfaceToOutputByIdx { + output_idx: 0, + surface_uuid: surface_uuid.clone(), + }, + ); + assert!(matches!(r, CommandResult::Ok), "{r:?}"); + + let surface_warp = |app: &mut VardaApp| { + app.build_engine_state() + .outputs + .surfaces + .iter() + .find(|s| s.uuid == surface_uuid) + .unwrap() + .warp + .clone() + }; + + // Auto-warp: a fresh surface is shape-bound, so its *effective* warp is the + // conforming mesh (never `None`). Unbind to enable manual mesh editing. + assert!(surface_warp(&mut app).is_some()); + let r = send_cmd( + &mut app, + EngineCommand::SetWarpBound { + surface_uuid: surface_uuid.clone(), + bound: false, + }, + ); + assert!(matches!(r, CommandResult::Ok), "{r:?}"); + + // Subdivide → 3×3 mesh, preserving the (identity) deformation. + let r = send_cmd( + &mut app, + EngineCommand::SetWarpSubdivisions { + surface_uuid: surface_uuid.clone(), + cols: 3, + rows: 3, + }, + ); + assert!(matches!(r, CommandResult::Ok), "{r:?}"); + let Some(WarpMode::Mesh(mesh)) = surface_warp(&mut app) else { + panic!("expected mesh warp after subdivision"); + }; + assert_eq!(mesh.cols, 3); + assert_eq!(mesh.rows, 3); + assert_eq!(mesh.points.len(), 9); + + // Drag the centre point (row 1, col 1 → index 4). + fire( + &mut app, + EngineCommand::SetWarpMeshPoint { + surface_uuid: surface_uuid.clone(), + row: 1, + col: 1, + position: [0.6, 0.4], + }, + ); + let Some(WarpMode::Mesh(mesh)) = surface_warp(&mut app) else { + panic!("expected mesh warp"); + }; + assert!((mesh.points[4].position[0] - 0.6).abs() < 1e-6); + assert!((mesh.points[4].position[1] - 0.4).abs() < 1e-6); +} + +/// Bezier warp (8i.6): convert an unbound surface's warp into a bezier cage, +/// edit an anchor and a tangent handle, and resize the cage — all through the +/// engine command path. +#[test] +fn bezier_warp_convert_and_edit() { + use varda::renderer::context::OutputSource; + use varda::renderer::warp::WarpMode; + let Some(mut app) = headless_app() else { + return; + }; + send_cmd( + &mut app, + EngineCommand::AddSurface { + name: "Bez".into(), + source: OutputSource::Master, + }, + ); + let uuid = app + .build_engine_state() + .outputs + .surfaces + .iter() + .find(|s| s.name == "Bez") + .unwrap() + .uuid + .clone(); + + let warp = |app: &mut VardaApp| { + app.build_engine_state() + .outputs + .surfaces + .iter() + .find(|s| s.uuid == uuid) + .unwrap() + .warp + .clone() + }; + + // New surfaces are shape-bound; unbind to enable manual editing, then curve. + send_cmd( + &mut app, + EngineCommand::SetWarpBound { + surface_uuid: uuid.clone(), + bound: false, + }, + ); + send_cmd( + &mut app, + EngineCommand::ConvertWarpToBezier { + surface_uuid: uuid.clone(), + }, + ); + let Some(WarpMode::Bezier(b)) = warp(&mut app) else { + panic!("expected bezier warp after convert"); + }; + assert_eq!((b.anchor_cols, b.anchor_rows), (2, 2)); + + // Move a corner anchor. + fire( + &mut app, + EngineCommand::MoveWarpAnchor { + surface_uuid: uuid.clone(), + row: 0, + col: 0, + position: [0.15, 0.25], + }, + ); + let Some(WarpMode::Bezier(b)) = warp(&mut app) else { + panic!("expected bezier warp"); + }; + assert!((b.anchor(0, 0)[0] - 0.15).abs() < 1e-6 && (b.anchor(0, 0)[1] - 0.25).abs() < 1e-6); + + // Curve the top edge by pulling its near-left tangent handle. + fire( + &mut app, + EngineCommand::MoveWarpHandle { + surface_uuid: uuid.clone(), + horizontal: true, + row: 0, + col: 0, + which: 0, + position: [0.33, 0.05], + }, + ); + let Some(WarpMode::Bezier(b)) = warp(&mut app) else { + panic!("expected bezier warp"); + }; + assert!((b.h_horiz[0][0][1] - 0.05).abs() < 1e-6); + + // Resize the control cage to 3×3. + send_cmd( + &mut app, + EngineCommand::SetBezierCageSubdivisions { + surface_uuid: uuid.clone(), + cols: 3, + rows: 3, + }, + ); + let Some(WarpMode::Bezier(b)) = warp(&mut app) else { + panic!("expected bezier warp"); + }; + assert_eq!((b.anchor_cols, b.anchor_rows), (3, 3)); +} + +/// Setting subdivisions on a non-existent surface surfaces NotFound rather than +/// silently succeeding. +#[test] +fn mesh_warp_subdivisions_bad_index_errs() { + let Some(mut app) = headless_app() else { + return; + }; + let r = send_cmd( + &mut app, + EngineCommand::SetWarpSubdivisions { + surface_uuid: "does-not-exist".into(), + cols: 3, + rows: 3, + }, + ); + assert!(matches!( + r, + CommandResult::Err { + code: ErrorCode::NotFound, + .. + } + )); +}