Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

100 changes: 90 additions & 10 deletions docs/08-projection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

---

Expand Down Expand Up @@ -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.

---

Expand Down
120 changes: 118 additions & 2 deletions docs/13-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uuid>/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/<uuid>/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/<uuid>/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:
Expand All @@ -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` |
Expand Down
53 changes: 33 additions & 20 deletions src/app/engine_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
})
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
});
}
}
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
}
Expand Down
Loading