M7.2: 3D viewer — 3D views, camera tools, camera/light wireframes, Camera/Light Settings dialogs

- Viewer renders through the comp's 3D view (RenderOpts.view; FrameKey hashes
  the view camera), shows the view name, and the bottom-bar 3D View dropdown
  (automation id viewer.view3d) switches Active Camera/Front/…/Custom View 3.
- Orbit / Pan / Dolly tools drag the current view (camera layer in Active
  Camera, merged into one undo step; view cameras otherwise).
- Handles, gizmo and picking go through the view camera; 3D layers move in
  the view plane at their depth.
- Camera frustum + POI and light position/direction wireframes
  (viewer.camera.<id>, viewer.light.<id>).
- Camera Settings / Light Settings dialogs open from New Camera/Light,
  Camera/Light Settings and Layer Settings; OK runs the engine commands.
- Timeline: POI only for two-node cameras/auto-oriented lights; "Z Rotation"
  label on 3D layers.
- Headless egui_kittest tests (UI wiring + opt-in wgpu snapshots); fix the
  remaining workspace clippy lints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 22:01:37 -04:00
co-authored by Claude Opus 5.5
parent 76ac3f74f7
commit 16a5fa7a83
11 changed files with 762 additions and 48 deletions
+4 -4
View File
@@ -54,10 +54,10 @@ fn main() -> eframe::Result {
let _ = session.execute("file.openDemoProject", json!({}));
}
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect();
if !media.is_empty() {
if let Err(e) = session.execute("file.import", json!({"paths": media})) {
eprintln!("effectcraft: {e}");
}
if !media.is_empty()
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
{
eprintln!("effectcraft: {e}");
}
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = home;
+2
View File
@@ -18,6 +18,8 @@ pub struct FrameKey {
pub frame: i64,
/// Render scale × 1000.
pub scale: u32,
/// Hash of the 3D view camera (0 = the comp's active camera).
pub view: u64,
}
struct Cache {
+19 -2
View File
@@ -41,6 +41,8 @@ pub enum Dialog {
CompSettings,
SolidSettings,
CommandPalette,
CameraSettings,
LightSettings,
}
/// Host hooks provided by the native app (file pickers etc.).
@@ -175,7 +177,22 @@ impl EffectcraftApp {
}
pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey {
FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32 }
FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32, view: self.view_hash(comp) }
}
/// Hash of the comp viewer's 3D view camera (0 for the active camera view).
pub fn view_hash(&self, comp: ItemId) -> u64 {
use std::hash::{Hash, Hasher};
let Some(cam) = self.session.view_camera(comp) else { return 0 };
let mut h = std::collections::hash_map::DefaultHasher::new();
for row in cam.view.0 {
for v in row {
v.to_bits().hash(&mut h);
}
}
cam.zoom.to_bits().hash(&mut h);
cam.ortho.hash(&mut h);
h.finish() | 1
}
/// Request a frame render (no-op if cached/in flight).
@@ -183,7 +200,7 @@ impl EffectcraftApp {
let Some(c) = self.session.project.comp(comp) else { return };
let key = self.frame_key(comp, frame, scale);
let t = c.frame_rate.tick_of(frame);
let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: None };
let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: self.session.view_camera(comp) };
self.frames.request(&self.render_source(), key, comp, t, opts);
}
+4
View File
@@ -121,6 +121,10 @@ fn reveal(app: &mut EffectcraftApp, kind: &str) {
/// Execute a UI or engine command by id.
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
let now = ctx.input(|i| i.time);
// New Camera/Light and Camera/Light Settings without parameters open their dialogs.
if crate::panels::dialogs_3d::route(app, id, &params)? {
return Ok(Value::Null);
}
if let Some(rest) = id.strip_prefix("window.panel.") {
let p = PanelKind::from_name(rest).ok_or_else(|| format!("unknown panel `{rest}`"))?;
app.show_panel(p);
+7 -3
View File
@@ -1,5 +1,5 @@
//! Modal dialogs: About (with community links), New Composition / Composition Settings, Solid
//! Settings and the command palette.
//! Settings and the command palette (Camera/Light Settings live in `dialogs_3d`).
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::json;
@@ -34,6 +34,8 @@ pub struct DialogState {
pub solid_size: [u32; 2],
pub palette_query: String,
pub palette_sel: usize,
pub camera: super::dialogs_3d::CameraDraft,
pub light: super::dialogs_3d::LightDraft,
}
pub fn open_new_comp(app: &mut EffectcraftApp) {
@@ -72,7 +74,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> {
Ok(())
}
fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
// Dim the app.
let screen = ctx.content_rect();
ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("modal-dim"))).rect_filled(screen, 0.0, Color32::from_black_alpha(120));
@@ -95,6 +97,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings),
Dialog::SolidSettings => solid(app, ctx, &t),
Dialog::CommandPalette => palette(app, ctx, &t),
Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t),
Dialog::LightSettings => super::dialogs_3d::light(app, ctx, &t),
}
}
@@ -262,7 +266,7 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
}
}
fn ui_enter(ctx: &egui::Context) -> bool {
pub(crate) fn ui_enter(ctx: &egui::Context) -> bool {
!ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter))
}
+378
View File
@@ -0,0 +1,378 @@
//! Camera Settings and Light Settings dialogs (Layer ▸ New ▸ Camera/Light, Layer ▸ Camera/Light
//! Settings, Layer Settings on a camera or light). OK runs `layer.newCamera` / `layer.newLight`
//! or `layer.cameraSettings` / `layer.lightSettings` with the dialog's values.
use effectcraft_engine::project::{AutoOrient, LayerSource, LightKind};
use effectcraft_engine::render::three_d::camera::{PRESETS, angle_of_view, default_aperture, focal_for_zoom, zoom_for_focal};
use egui::{Color32, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
#[derive(Clone, Debug, Default)]
pub struct CameraDraft {
/// Existing camera layer (Camera Settings) or None (New Camera).
pub layer: Option<u64>,
pub name: String,
pub two_node: bool,
pub zoom: f64,
pub dof: bool,
pub focus: f64,
pub lock_to_zoom: bool,
pub aperture: f64,
pub blur: f64,
}
#[derive(Clone, Debug, Default)]
pub struct LightDraft {
pub layer: Option<u64>,
pub name: String,
pub kind: usize,
pub color: [f32; 3],
pub intensity: f64,
pub cone_angle: f64,
pub cone_feather: f64,
pub falloff: usize,
pub radius: f64,
pub falloff_distance: f64,
pub casts_shadows: bool,
pub shadow_darkness: f64,
pub shadow_diffusion: f64,
}
const KINDS: [LightKind; 4] = [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient];
const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"];
fn comp_w(app: &EffectcraftApp) -> f64 {
app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0)
}
/// Open Camera Settings: for `layer` (existing) or a new camera.
pub fn open_camera(app: &mut EffectcraftApp, layer: Option<u64>) -> Result<(), String> {
let comp = app.session.active_comp().ok_or("no composition is open")?.clone();
let w = comp.width as f64;
let t = app.session.time();
let d = match layer.and_then(|id| comp.layer(effectcraft_engine::project::LayerId(id))) {
Some(l) => {
let f = |p: &str, d: f64| l.props.prop(p).map(|pr| pr.value_at(l.layer_time(t)).as_f64()).unwrap_or(d);
let zoom = f("cameraOptions/zoom", 1000.0);
let focus = f("cameraOptions/focusDistance", zoom);
CameraDraft {
layer: Some(l.id.0),
name: l.name.clone(),
two_node: l.auto_orient == AutoOrient::TowardsPointOfInterest,
zoom,
dof: l.props.prop("cameraOptions/dof").map(|p| p.value_at(l.layer_time(t)).as_bool()).unwrap_or(false),
focus,
lock_to_zoom: (focus - zoom).abs() < 1e-6,
aperture: f("cameraOptions/aperture", default_aperture(w)),
blur: f("cameraOptions/blurLevel", 100.0),
}
}
None => {
let n = comp.layers.iter().filter(|l| l.is_camera()).count() + 1;
let zoom = zoom_for_focal(w, 50.0);
CameraDraft {
layer: None,
name: format!("Camera {n}"),
two_node: true,
zoom,
dof: false,
focus: zoom,
lock_to_zoom: true,
aperture: default_aperture(w),
blur: 100.0,
}
}
};
app.dialog_state.camera = d;
app.dialog = Some(Dialog::CameraSettings);
Ok(())
}
/// Open Light Settings: for `layer` (existing) or a new light.
pub fn open_light(app: &mut EffectcraftApp, layer: Option<u64>) -> Result<(), String> {
let comp = app.session.active_comp().ok_or("no composition is open")?.clone();
let t = app.session.time();
let d = match layer.and_then(|id| comp.layer(effectcraft_engine::project::LayerId(id))) {
Some(l) => {
let LayerSource::Light { kind } = l.source else { return Err("not a light".into()) };
let v = |p: &str| l.props.prop(&format!("lightOptions/{p}")).map(|pr| pr.value_at(l.layer_time(t)));
let f = |p: &str, d: f64| v(p).map(|x| x.as_f64()).unwrap_or(d);
let c = v("color").map(|x| x.as_color()).unwrap_or([1.0; 4]);
LightDraft {
layer: Some(l.id.0),
name: l.name.clone(),
kind: KINDS.iter().position(|k| *k == kind).unwrap_or(1),
color: [c[0], c[1], c[2]],
intensity: f("intensity", 100.0),
cone_angle: f("coneAngle", 90.0),
cone_feather: f("coneFeather", 50.0),
falloff: v("falloff").map(|x| x.as_enum() as usize).unwrap_or(0).min(2),
radius: f("radius", 500.0),
falloff_distance: f("falloffDistance", 500.0),
casts_shadows: v("castsShadows").map(|x| x.as_bool()).unwrap_or(false),
shadow_darkness: f("shadowDarkness", 100.0),
shadow_diffusion: f("shadowDiffusion", 0.0),
}
}
None => {
let n = comp.layers.iter().filter(|l| l.is_light()).count() + 1;
LightDraft {
layer: None,
name: format!("Light {n}"),
kind: 1,
color: [1.0, 1.0, 1.0],
intensity: 100.0,
cone_angle: 90.0,
cone_feather: 50.0,
falloff: 0,
radius: 500.0,
falloff_distance: 500.0,
casts_shadows: false,
shadow_darkness: 100.0,
shadow_diffusion: 0.0,
}
}
};
app.dialog_state.light = d;
app.dialog = Some(Dialog::LightSettings);
Ok(())
}
fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) {
let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
r.0 = b.rect;
if b.clicked() {
*ok = true;
}
let c = ui.button("Cancel");
r.1 = c.rect;
if c.clicked() {
*close = true;
}
});
r
}
pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let w = comp_w(app);
let mut d = app.dialog_state.camera.clone();
let (mut ok, mut close) = (false, false);
let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING);
super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| {
egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
ui.label("Type");
egui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| {
ui.selectable_value(&mut d.two_node, true, "Two-Node Camera");
ui.selectable_value(&mut d.two_node, false, "One-Node Camera");
});
ui.end_row();
ui.label("Name");
ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0));
ui.end_row();
ui.label("Preset");
let focal = focal_for_zoom(w, d.zoom);
let cur = PRESETS.iter().find(|(_, f)| (f - focal).abs() < 1e-6).map(|(n, _)| *n).unwrap_or("Custom");
egui::ComboBox::from_id_salt("cam-preset").selected_text(cur).show_ui(ui, |ui| {
for (n, f) in PRESETS {
if ui.selectable_label(cur == n, n).clicked() {
d.zoom = zoom_for_focal(w, f);
}
}
});
ui.end_row();
ui.label("Zoom");
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px"));
let mut aov = angle_of_view(w, d.zoom);
if ui.add(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°")).changed() {
d.zoom = w * 0.5 / (aov.to_radians() * 0.5).tan();
}
let mut fl = focal_for_zoom(w, d.zoom);
if ui.add(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm")).changed() {
d.zoom = zoom_for_focal(w, fl);
}
});
ui.end_row();
ui.label("Depth of Field");
ui.checkbox(&mut d.dof, "Enable Depth of Field");
ui.end_row();
ui.label("Focus Distance");
ui.horizontal(|ui| {
ui.add_enabled(!d.lock_to_zoom, egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px"));
ui.checkbox(&mut d.lock_to_zoom, "Lock to Zoom");
});
ui.end_row();
ui.label("Aperture");
ui.add(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px"));
ui.end_row();
ui.label("Blur Level");
ui.add(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %"));
ui.end_row();
});
ui.add_space(14.0);
rects = buttons(ui, t, &mut ok, &mut close);
});
if d.lock_to_zoom {
d.focus = d.zoom;
}
app.auto.add("dialog.camera.ok", rects.0, "OK");
app.auto.add("dialog.camera.cancel", rects.1, "Cancel");
app.dialog_state.camera = d.clone();
if ok || super::dialogs::ui_enter(ctx) {
let mut p = json!({
"name": d.name,
"type": if d.two_node { "twoNode" } else { "oneNode" },
"zoom": d.zoom,
"dof": d.dof,
"focusDistance": d.focus,
"aperture": d.aperture,
"blurLevel": d.blur,
});
let id = match d.layer {
Some(l) => {
p["layer"] = json!(l);
"layer.cameraSettings"
}
None => "layer.newCamera",
};
if let Err(e) = app.session.execute(id, p) {
app.ui.status = e.to_string();
}
close = true;
}
if close {
app.dialog = None;
}
}
pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut d = app.dialog_state.light.clone();
let (mut ok, mut close) = (false, false);
let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING);
super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| {
egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
ui.label("Name");
ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0));
ui.end_row();
ui.label("Light Type");
egui::ComboBox::from_id_salt("light-type").selected_text(KINDS[d.kind.min(3)].label()).show_ui(ui, |ui| {
for (i, k) in KINDS.iter().enumerate() {
ui.selectable_value(&mut d.kind, i, k.label());
}
});
ui.end_row();
ui.label("Color");
ui.color_edit_button_rgb(&mut d.color);
ui.end_row();
ui.label("Intensity");
ui.add(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %"));
ui.end_row();
let kind = KINDS[d.kind.min(3)];
if kind == LightKind::Spot {
ui.label("Cone Angle");
ui.add(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°"));
ui.end_row();
ui.label("Cone Feather");
ui.add(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %"));
ui.end_row();
}
if kind != LightKind::Ambient {
ui.label("Falloff");
egui::ComboBox::from_id_salt("light-falloff").selected_text(FALLOFFS[d.falloff.min(2)]).show_ui(ui, |ui| {
for (i, f) in FALLOFFS.iter().enumerate() {
ui.selectable_value(&mut d.falloff, i, *f);
}
});
ui.end_row();
if d.falloff > 0 {
ui.label("Radius");
ui.add(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px"));
ui.end_row();
ui.label("Falloff Distance");
ui.add(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px"));
ui.end_row();
}
ui.label("Shadows");
ui.checkbox(&mut d.casts_shadows, "Casts Shadows");
ui.end_row();
ui.label("Shadow Darkness");
ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"));
ui.end_row();
ui.label("Shadow Diffusion");
ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px"));
ui.end_row();
}
});
ui.add_space(14.0);
ui.label(egui::RichText::new("Note: Shadows are only cast from layers with Cast Shadows enabled.").font(Tokens::ui(11.0)).color(t.text_dim));
ui.add_space(8.0);
rects = buttons(ui, t, &mut ok, &mut close);
});
app.auto.add("dialog.light.ok", rects.0, "OK");
app.auto.add("dialog.light.cancel", rects.1, "Cancel");
app.dialog_state.light = d.clone();
if ok || super::dialogs::ui_enter(ctx) {
let kind = KINDS[d.kind.min(3)];
let mut p = json!({
"name": d.name,
"kind": kind.label(),
"color": [d.color[0], d.color[1], d.color[2]],
"intensity": d.intensity,
});
if kind == LightKind::Spot {
p["coneAngle"] = json!(d.cone_angle);
p["coneFeather"] = json!(d.cone_feather);
}
if kind != LightKind::Ambient {
p["falloff"] = json!(d.falloff);
p["radius"] = json!(d.radius);
p["falloffDistance"] = json!(d.falloff_distance);
p["castsShadows"] = json!(d.casts_shadows);
p["shadowDarkness"] = json!(d.shadow_darkness);
p["shadowDiffusion"] = json!(d.shadow_diffusion);
}
let id = match d.layer {
Some(l) => {
p["layer"] = json!(l);
"layer.lightSettings"
}
None => "layer.newLight",
};
if let Err(e) = app.session.execute(id, p) {
app.ui.status = e.to_string();
}
close = true;
}
if close {
app.dialog = None;
}
}
/// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params.
/// Returns true when a dialog was opened.
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
if !params.as_object().is_none_or(|m| m.is_empty() || m.keys().all(|k| k == "layer")) {
return Ok(false);
}
let selected = || -> Option<(u64, bool, bool)> {
let c = app.session.active_comp()?;
let id = params.get("layer").and_then(Value::as_u64).or_else(|| app.session.state.selected_layers.first().map(|l| l.0))?;
let l = c.layer(effectcraft_engine::project::LayerId(id))?;
Some((id, l.is_camera(), l.is_light()))
};
match id {
"layer.newCamera" if params.get("layer").is_none() => open_camera(app, None).map(|_| true),
"layer.newLight" if params.get("layer").is_none() => open_light(app, None).map(|_| true),
"layer.cameraSettings" | "layer.lightSettings" | "layer.settings" => match selected() {
Some((l, true, _)) => open_camera(app, Some(l)).map(|_| true),
Some((l, _, true)) => open_light(app, Some(l)).map(|_| true),
_ => Ok(false),
},
_ => Ok(false),
}
}
+1
View File
@@ -1,6 +1,7 @@
//! Panel bodies.
pub mod dialogs;
pub mod dialogs_3d;
pub mod effect_controls;
pub mod effects_presets;
pub mod info;
+2 -4
View File
@@ -167,10 +167,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.double_clicked() {
match &it.kind {
ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
ItemKind::Folder => {
if !app.ui.project_open_folders.remove(&id.0) {
app.ui.project_open_folders.insert(id.0);
}
ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
app.ui.project_open_folders.insert(id.0);
}
_ => {}
}
+12 -8
View File
@@ -42,7 +42,6 @@ struct Cols {
label: f32,
num: f32,
name: f32,
name_w: f32,
switches: f32,
mode: f32,
trkmat: f32,
@@ -61,7 +60,7 @@ fn cols(x0: f32, width: f32, show_modes: bool) -> Cols {
let mode = switches + SW * 8.0 + 6.0;
let trkmat = mode + if show_modes { 92.0 } else { 0.0 };
let parent = trkmat + if show_modes { 112.0 } else { 0.0 };
Cols { av, label, num, name, name_w, switches, mode, trkmat, parent, end: parent + 116.0 }
Cols { av, label, num, name, switches, mode, trkmat, parent, end: parent + 116.0 }
}
/// Timeline horizontal mapping.
@@ -141,6 +140,10 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool {
if p.two_d_only && layer.is_3d() {
return false;
}
// One-node cameras (and lights with auto-orient off) have no Point of Interest.
if p.match_id == "poi" && (layer.is_camera() || layer.is_light()) && layer.auto_orient != effectcraft_engine::project::AutoOrient::TowardsPointOfInterest {
return false;
}
true
}
@@ -785,10 +788,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
if body.clicked() || body.drag_started() {
if !is_sel {
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)})));
}
if (body.clicked() || body.drag_started()) && !is_sel {
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)})));
}
layer_context_menu(&body, layer, &mut actions);
let drag_key = format!("bar-{}", layer.id.0);
@@ -944,7 +945,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
let name_x = indent + 12.0;
let pname = if prop.has_expression() { format!("{} =", prop.name) } else { prop.name.clone() };
// 3D layers show Rotation as "Z Rotation" next to X/Y Rotation.
let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid);
let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() };
let pname = if prop.has_expression() { format!("{base} =") } else { base };
lp.text(
pos2(name_x, cy),
Align2::LEFT_CENTER,
@@ -1049,7 +1053,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(("keys.select".into(), json!({"keys": []})));
}
if let (true, Some(origin), Some(cur)) =
(empty.dragged(), empty.interact_pointer_pos().map(|_| ctx.input(|i| i.pointer.press_origin())).flatten(), empty.interact_pointer_pos())
(empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos())
{
let br = Rect::from_two_pos(origin, cur);
gp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
+215 -27
View File
@@ -1,10 +1,13 @@
//! The Composition viewer: pasteboard + comp frame, magnification and resolution, transparency
//! grid, guides, layer bounding boxes with handles, anchor points, motion paths and masks, and
//! direct manipulation (move, scale, rotate, pan behind, shape/type creation, hand and zoom).
//! direct manipulation (move, scale, rotate, pan behind, shape/type creation, hand and zoom),
//! 3D views (Active Camera / Front / … / Custom View 3), camera tools (orbit, pan, dolly) and
//! camera/light wireframes.
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::render::three_d::{self, CameraState, View3D};
use effectcraft_engine::time::Tick;
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
@@ -55,21 +58,71 @@ pub fn last_fit(ctx: &egui::Context) -> f32 {
/// What a drag in the viewer is doing.
#[derive(Clone, Debug)]
enum Gesture {
Move { layers: Vec<(LayerId, [f64; 3], Mat3)>, start: [f64; 2] },
Scale { layer: LayerId, anchor_screen: Pos2, start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3, uniform: bool },
Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64 },
Anchor { layer: LayerId, start_anchor: [f64; 3], start_pos: [f64; 3], start: [f64; 2], inv: Mat3, l2p: Mat3 },
Pan { start_pan: [f32; 2] },
Create { tool: Tool, start: [f64; 2] },
Marquee { start: Pos2 },
/// Per layer: start position and the parent-space change per comp pixel of drag in x and y.
Move {
layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])>,
start: [f64; 2],
},
Camera {
tool: Tool,
},
Scale {
layer: LayerId,
start_scale: [f64; 3],
start_local: [f64; 2],
inv: Mat3,
uniform: bool,
},
Rotate {
layer: LayerId,
center: Pos2,
start_angle: f64,
start_rot: f64,
},
Anchor {
layer: LayerId,
start_anchor: [f64; 3],
start_pos: [f64; 3],
start: [f64; 2],
inv: Mat3,
l2p: Mat3,
},
Pan {
start_pan: [f32; 2],
},
Create {
tool: Tool,
start: [f64; 2],
},
Marquee {
start: Pos2,
},
}
const HANDLE: f32 = 7.0;
thread_local! {
/// The 3D view camera of the viewer being drawn (None = the comp's active camera).
static VIEW_CAM: std::cell::Cell<Option<CameraState>> = const { std::cell::Cell::new(None) };
}
/// The camera the viewer shows (view camera or the comp's active camera).
fn cam_state(ctx: &EvalCtx) -> CameraState {
VIEW_CAM.with(|c| c.get()).unwrap_or_else(|| three_d::active_camera(ctx))
}
/// Layer → comp (viewer) matrix through the current 3D view.
fn l2c(ctx: &EvalCtx, layer: &Layer) -> (Mat3, f64) {
match VIEW_CAM.with(|c| c.get()) {
Some(cam) if layer.is_3d() => (three_d::layer_to_view(ctx, layer, &cam), 0.0),
_ => ctx.layer_to_comp(layer),
}
}
/// Layer → comp matrix and its bounds quad (in comp pixels).
fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> {
let b = effectcraft_engine::render::content_bounds(ctx, layer)?;
let (m, _) = ctx.layer_to_comp(layer);
let (m, _) = l2c(ctx, layer);
let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| {
let q = m.apply(gv2(p[0], p[1]));
[q.x, q.y]
@@ -133,6 +186,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1920, 1080, Default::default(), Tick::ZERO));
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let p = ui.painter().clone();
VIEW_CAM.with(|c| c.set(app.session.view_camera(cid)));
// Navigator bar (open comps as breadcrumbs).
let nav = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
@@ -279,7 +333,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if app.ui.viewer.show_masks
&& let Some(masks) = l.masks()
{
let (m, _) = ectx.layer_to_comp(l);
let (m, _) = l2c(&ectx, l);
for g in masks.groups() {
let GroupKind::Mask { color, .. } = g.kind else { continue };
let Some(path) = g.get("path").map(|pr| ectx.value(l, pr)) else { continue };
@@ -338,8 +392,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// 3D axis gizmo.
if l.is_3d() {
let w = ectx.world_matrix(l);
let (cam, _, _) = ectx.camera();
let pc = cam * w;
let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w;
let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]);
let o = pc.apply(a3);
let len = 60.0 / zoom as f64;
@@ -356,6 +409,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
// Cameras and lights (wireframes) and the 3D view name.
if comp.has_3d() {
draw_rigs(app, &painter, &map, &ectx, &selected);
let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
let label = match (view, comp.active_camera(time)) {
(View3D::ActiveCamera, Some(c)) => format!("Active Camera ({})", c.name),
(v, _) => v.label().to_string(),
};
painter.text(area.left_top() + vec2(12.0, 10.0), Align2::LEFT_TOP, label, Tokens::ui(12.0), t.text_dim);
}
// Interaction.
let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag());
let gid = egui::Id::new("viewer-gesture");
@@ -368,6 +432,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tool::Hand => egui::CursorIcon::Grab,
Tool::Zoom => egui::CursorIcon::ZoomIn,
Tool::Type | Tool::TypeVertical => egui::CursorIcon::Text,
Tool::Orbit => egui::CursorIcon::Alias,
Tool::PanCamera => egui::CursorIcon::Move,
Tool::Dolly => egui::CursorIcon::ResizeVertical,
t if t.is_shape() || t == Tool::Pen => egui::CursorIcon::Crosshair,
_ => {
if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) {
@@ -415,7 +482,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
let layer = comp.layer(l)?;
let tr = layer.transform()?;
let (l2c, _) = ectx.layer_to_comp(layer);
let (l2c, _) = l2c(&ectx, layer);
let l2p = ectx.local_matrix(layer);
let l2p = Mat3([[l2p.0[0][0], l2p.0[0][1], 0.0], [l2p.0[1][0], l2p.0[1][1], 0.0], [0.0, 0.0, 1.0]]);
Some(Gesture::Anchor {
@@ -428,20 +495,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
})
}),
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
Tool::Orbit | Tool::PanCamera | Tool::Dolly => Some(Gesture::Camera { tool }),
Tool::Selection => {
if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() {
let layer = comp.layer(lid).cloned();
layer.and_then(|layer| {
let tr = layer.transform()?;
let (l2c, _) = ectx.layer_to_comp(&layer);
let (l2c, _) = l2c(&ectx, &layer);
let inv = l2c.inverse()?;
let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
let ac = l2c.apply(gv2(a[0], a[1]));
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let _ = hi;
Some(Gesture::Scale {
layer: lid,
anchor_screen: map.to_screen([ac.x, ac.y]),
start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]),
start_local: [lp.x - a[0], lp.y - a[1]],
inv,
@@ -449,14 +515,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
})
})
} else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
let layers: Vec<(LayerId, [f64; 3], Mat3)> = app
let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app
.session
.state
.selected_layers
.iter()
.filter_map(|id| comp.layer(*id))
.filter(|l| !l.switches.locked)
.filter_map(|l| Some((l.id, ectx.v3(l, l.transform()?, "position", [0.0; 3]), parent_inverse(&ectx, l))))
.filter_map(|l| {
let p0 = ectx.v3(l, l.transform()?, "position", [0.0; 3]);
let (ax, ay) = drag_axes(&ectx, l);
Some((l.id, p0, ax, ay))
})
.collect();
let _ = l;
(!layers.is_empty()).then_some(Gesture::Move { layers, start: cpt })
@@ -485,9 +555,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if mods.shift {
if d[0].abs() > d[1].abs() { d[1] = 0.0 } else { d[0] = 0.0 }
}
for (lid, p0, inv) in layers {
let dd = inv.apply_vec(gv2(d[0], d[1]));
let v = json!([p0[0] + dd.x, p0[1] + dd.y, p0[2]]);
for (lid, p0, ax, ay) in layers {
let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]);
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
}
}
@@ -498,7 +567,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cur = [lp.x - a[0], lp.y - a[1]];
let fx = if start_local[0].abs() > 1e-6 { cur[0] / start_local[0] } else { 1.0 };
let fy = if start_local[1].abs() > 1e-6 { cur[1] / start_local[1] } else { 1.0 };
let (fx, fy) = if mods.shift || uniform && start_local[0].abs() > 1e-6 && start_local[1].abs() > 1e-6 && mods.shift {
let _ = uniform;
let (fx, fy) = if mods.shift {
let f = (fx + fy) / 2.0;
(f, f)
} else {
@@ -528,7 +598,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
time,
expr: app.session.expr.as_deref(),
};
if let Some(m) = e2.layer_to_comp(&l).0.inverse() {
if let Some(m) = l2c(&e2, &l).0.inverse() {
*i2 = m;
}
}
@@ -555,6 +625,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/anchor", "value": na, "merge": merge}));
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/position", "value": np, "merge": merge}));
}
Gesture::Camera { tool } => {
let d = resp.drag_delta();
if d != egui::Vec2::ZERO {
let (dx, dy) = ((d.x / zoom) as f64, (d.y / zoom) as f64);
let (id, params) = match tool {
Tool::Orbit => ("camera.orbit", json!({"yaw": -d.x as f64 * 0.4, "pitch": d.y as f64 * 0.4, "merge": merge})),
Tool::PanCamera => ("camera.pan", json!({"dx": dx, "dy": dy, "merge": merge})),
_ => ("camera.dolly", json!({"amount": -dy * 2.0, "merge": merge})),
};
if let Err(e) = app.session.execute(id, params) {
app.ui.status = e.to_string();
}
}
}
Gesture::Create { start, .. } => {
let a = map.to_screen(start);
let r = Rect::from_two_pos(a, pos);
@@ -650,6 +734,99 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
bottom_bar(app, ui, bar, zoom, fit, time, &comp);
}
/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map
/// through the parent transform; 3D layers move in the view plane at their depth.
fn drag_axes(ctx: &EvalCtx, l: &Layer) -> ([f64; 3], [f64; 3]) {
if !l.is_3d() {
let inv = parent_inverse(ctx, l);
let a = inv.apply_vec(gv2(1.0, 0.0));
let b = inv.apply_vec(gv2(0.0, 1.0));
return ([a.x, a.y, 0.0], [b.x, b.y, 0.0]);
}
let cam = cam_state(ctx);
let anchor = l.transform().map(|tr| ctx.v3(l, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
let wp = ctx.world_matrix(l).apply(anchor.into());
let k = if cam.ortho { 1.0 / cam.zoom.max(1e-9) } else { cam.depth(wp).max(1.0) / cam.zoom.max(1e-9) };
let pinv = match l.parent.and_then(|p| ctx.comp.layer(p)) {
Some(p) => ctx.world_matrix(p).inverse().unwrap_or_default(),
None => effectcraft_engine::geom::Mat4::IDENTITY,
};
let a = pinv.apply_vec(cam.right() * k);
let b = pinv.apply_vec(cam.down() * k);
([a.x, a.y, a.z], [b.x, b.y, b.z])
}
/// Wireframes of cameras (frustum, point of interest) and lights (position, direction) as seen
/// through the current 3D view.
fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, selected: &[LayerId]) {
use effectcraft_engine::geom::Vec3;
use effectcraft_engine::project::{LayerSource, LightKind};
let cam = cam_state(ectx);
let (w, h) = (ectx.comp.width as f64, ectx.comp.height as f64);
let active = ectx.comp.active_camera(ectx.time).map(|c| c.id);
let through_active = VIEW_CAM.with(|c| c.get()).is_none();
let proj = |p: Vec3| cam.project(w, h, p).map(|v| map.to_screen([v.x, v.y]));
let seg = |a: Vec3, b: Vec3, stroke: Stroke| {
if let (Some(x), Some(y)) = (proj(a), proj(b)) {
painter.line_segment([x, y], stroke);
}
};
for l in ectx.comp.layers.iter().filter(|l| (l.is_camera() || l.is_light()) && l.is_active_at(ectx.time)) {
let sel = selected.contains(&l.id);
let col = Tokens::label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 });
let stroke = Stroke::new(if sel { 1.5 } else { 1.0 }, col);
let (eye, fwd, down) = three_d::camera::layer_frame(ectx, l);
let poi = |l: &Layer| -> Option<Vec3> {
let pr = l.transform()?.get("poi").filter(|_| three_d::camera::is_two_node(l))?;
Some(three_d::camera::parent_world(ectx, l).apply(ectx.value(l, pr).as_vec3().into()))
};
if l.is_camera() {
if through_active && Some(l.id) == active {
continue;
}
let zoom = l.props.prop("cameraOptions/zoom").map(|p| ectx.value(l, p).as_f64()).unwrap_or(1000.0);
let right = down.cross(fwd);
// Frustum to a plane at a quarter of the zoom distance.
let k = 0.25;
let c = eye + fwd * (zoom * k);
let corners = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].map(|(sx, sy)| c + right * (sx * w * 0.5 * k) + down * (sy * h * 0.5 * k));
for i in 0..4 {
seg(eye, corners[i], stroke);
seg(corners[i], corners[(i + 1) % 4], stroke);
}
if let Some(poi) = poi(l) {
seg(eye, poi, Stroke::new(1.0, col.gamma_multiply(0.6)));
if let Some(s) = proj(poi) {
painter.circle_stroke(s, 4.0, stroke);
}
}
if let Some(s) = proj(eye) {
app.auto.add(&format!("viewer.camera.{}", l.id.0), Rect::from_center_size(s, vec2(14.0, 14.0)), &l.name);
}
} else {
let LayerSource::Light { kind } = l.source else { continue };
if kind == LightKind::Ambient {
continue;
}
let Some(s) = proj(eye) else { continue };
painter.circle_stroke(s, 6.0, stroke);
for i in 0..8 {
let a = i as f32 * std::f32::consts::FRAC_PI_4;
let d = vec2(a.cos(), a.sin());
painter.line_segment([s + d * 8.0, s + d * 12.0], stroke);
}
if matches!(kind, LightKind::Spot | LightKind::Parallel) {
let target = poi(l).unwrap_or(eye + fwd * 300.0);
seg(eye, target, Stroke::new(1.0, col.gamma_multiply(0.7)));
if let Some(q) = proj(target) {
painter.circle_stroke(q, 3.0, stroke);
}
}
app.auto.add(&format!("viewer.light.{}", l.id.0), Rect::from_center_size(s, vec2(16.0, 16.0)), &l.name);
}
}
}
/// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer.
fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option<LayerId> {
let hit = ectx
@@ -705,7 +882,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
time: app.session.time(),
expr: None,
};
let inv = ectx.layer_to_comp(&l).0.inverse().unwrap_or(Mat3::IDENTITY);
let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY);
let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0));
let p1 = inv.apply(gv2(cx + w / 2.0, cy + h / 2.0));
let _ = app.session.execute(
@@ -847,7 +1024,7 @@ fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32,
app.ui.viewer.res = Resolution::ALL[i];
}
x = r.max.x + 6.0;
if tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest") {}
let _ = tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest");
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") {
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
}
@@ -858,9 +1035,20 @@ fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32,
if comp.has_3d() {
let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(96.0, 20.0));
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
app.auto.add("viewer.renderer3d", r, "3D Renderer");
x = r.max.x + 4.0;
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(110.0, 20.0));
let _ = widgets::dropdown(ui, r, "Active Camera", &t, egui::Id::new("vw-cam"));
let cid = app.session.active_comp_id();
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
widgets::open_popup(ui, egui::Id::new("vw-cam-pop"));
}
app.auto.add("viewer.view3d", r, "3D View");
let opts: Vec<String> = View3D::ALL.iter().map(|v| v.label().to_string()).collect();
let at = r.left_top() - vec2(0.0, 22.0 * opts.len() as f32 + 12.0);
if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-cam-pop"), at, &opts, View3D::ALL.iter().position(|v| *v == cur)) {
let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()}));
}
}
// Right: render time.
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
+118
View File
@@ -0,0 +1,118 @@
//! Headless UI checks for the 3D viewer (egui_kittest). The `snapshot` test renders the window
//! with wgpu and writes PNGs when `EC_SNAPSHOT_DIR` is set (`cargo test -p effectcraft-ui-egui
//! --test ui_3d -- --ignored`); the other test only runs the UI logic.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui_kittest::Harness;
use serde_json::json;
fn app() -> EffectcraftApp {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap();
s.execute("time.set", json!({"time": 5.0})).unwrap();
EffectcraftApp::new(s)
}
/// Step the UI until background frame renders have landed.
fn settle(h: &mut Harness<'_, EffectcraftApp>) {
for _ in 0..600 {
h.step();
if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
for _ in 0..4 {
h.step();
}
}
#[test]
fn viewer_registers_3d_controls() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
settle(&mut h);
h.step();
let ids: Vec<String> = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect();
for want in ["viewer.view3d", "viewer.renderer3d"] {
assert!(ids.iter().any(|i| i == want), "missing {want}");
}
assert!(ids.iter().any(|i| i.starts_with("viewer.light.")), "light wireframe registered");
// In a custom view the active camera is drawn as a wireframe too.
h.state_mut().session.execute("view.3d.custom1", json!({})).unwrap();
settle(&mut h);
h.step();
let ids: Vec<String> = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect();
assert!(ids.iter().any(|i| i.starts_with("viewer.camera.")), "camera wireframe registered");
}
#[test]
fn camera_and_light_dialogs_create_layers() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
h.step();
let ctx = h.ctx.clone();
let n0 = h.state().session.active_comp().unwrap().layers.len();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newCamera", json!({})).unwrap();
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::CameraSettings));
h.step();
let ok = h.state().auto.elements.iter().chain(h.state().auto.previous.iter()).find(|e| e.id == "dialog.camera.ok").map(|e| e.rect);
assert!(ok.is_some(), "camera dialog OK registered");
// OK through the dialog state (Enter).
h.key_press(egui::Key::Enter);
h.step();
assert!(h.state().dialog.is_none());
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), n0 + 1);
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newLight", json!({})).unwrap();
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LightSettings));
h.step();
h.key_press(egui::Key::Enter);
h.step();
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), n0 + 2);
// Layer Settings on the new light reopens Light Settings for it.
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.settings", json!({})).unwrap();
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LightSettings));
}
#[test]
#[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"]
fn snapshot() {
let dir = std::env::var("EC_SNAPSHOT_DIR").ok();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| app());
let shots: [(&str, Option<&str>); 4] =
[("active", None), ("custom1", Some("view.3d.custom1")), ("top", Some("view.3d.top")), ("left", Some("view.3d.left"))];
for (name, cmd) in shots {
if let Some(c) = cmd {
h.state_mut().session.execute(c, json!({})).unwrap();
}
settle(&mut h);
let img = h.render().expect("render");
if let Some(d) = &dir {
img.save(format!("{d}/ui3d_{name}.png")).unwrap();
}
}
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newCamera", json!({})).unwrap();
h.run_steps(3);
let img = h.render().expect("render");
if let Some(d) = &dir {
img.save(format!("{d}/ui3d_camera_dialog.png")).unwrap();
}
h.state_mut().dialog = None;
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newLight", json!({})).unwrap();
h.run_steps(3);
let img = h.render().expect("render");
if let Some(d) = &dir {
img.save(format!("{d}/ui3d_light_dialog.png")).unwrap();
}
// A 3D layer's rotation properties (R) and the selected layer's gizmo in the active camera.
h.state_mut().dialog = None;
h.state_mut().session.execute("view.3d.activeCamera", json!({})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": ["Card Blue"]})).unwrap();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.rotation", json!({})).unwrap();
settle(&mut h);
let img = h.render().expect("render");
if let Some(d) = &dir {
img.save(format!("{d}/ui3d_reveal_rotation.png")).unwrap();
}
}