M13.5: camera iris bokeh depth of field and focus-link commands

3D-1: Classic 3D depth of field renders the camera's Iris Shape (Fast
Rectangle box or Triangle…Decagon polygon), Iris Rotation, Roundness, Aspect
Ratio and Diffraction Fringe as a span-kernel gather blur, with Highlight
Gain/Threshold/Saturation boosting highlights first; tilted layers blur
progressively by per-pixel camera depth. Layer > Camera > Link Focus Distance
to Point of Interest / to Layer (expressions) and Set Focus Distance to Layer
replace the stubs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 23:35:03 -04:00
co-authored by Claude Opus 5.5
parent bbaee1fa0f
commit 85c645f337
11 changed files with 638 additions and 23 deletions
+105
View File
@@ -0,0 +1,105 @@
//! Layer ▸ Camera focus commands: Link Focus Distance to Point of Interest, Link Focus Distance to
//! Layer (both add an expression to Focus Distance, as After Effects does) and Set Focus Distance
//! to Layer (a one-off value).
use effectcraft_geom::vec3;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Comp, Expression, ItemId, LayerId};
use serde_json::{Value, json};
use super::{CommandSpec, bad, comp_id, layer_mut, resolve_layer};
use crate::{EngineError, Result, Session, cmd};
/// (comp, camera, target?) from `{camera?, layer?}` or the selection (a camera plus another layer).
fn refs(s: &Session, p: &Value, cmd: &str, need_target: bool) -> Result<(ItemId, LayerId, Option<LayerId>)> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let sel: Vec<LayerId> = s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect();
let is_cam = |c: &Comp, l: LayerId| c.layer(l).is_some_and(|l| l.is_camera());
let cam = match p.get("camera") {
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?,
None => sel.iter().copied().find(|l| is_cam(comp, *l)).ok_or_else(|| bad(cmd, "select a camera layer"))?,
};
if !is_cam(comp, cam) {
return Err(bad(cmd, "`camera` is not a camera layer"));
}
let target = match p.get("layer") {
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?),
None => sel.iter().copied().find(|l| *l != cam),
};
if need_target && target.is_none() {
return Err(bad(cmd, "select the camera and the layer to focus on"));
}
Ok((cid, cam, target))
}
fn set_focus_expr(s: &mut Session, cid: ItemId, cam: LayerId, label: &str, text: String) -> Result<Value> {
s.edit(label, None, |proj, _| {
let l = layer_mut(proj, cid, cam)?;
let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad("camera", "the camera has no Focus Distance"))?;
pr.expr = Some(Expression { text: text.clone(), enabled: true });
Ok(json!({"expression": text}))
})
}
fn link_poi(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, cam, _) = refs(s, p, "camera.linkFocusToPoi", false)?;
set_focus_expr(s, cid, cam, "Link Focus Distance to Point of Interest", "length(transform.pointOfInterest, transform.position)".into())
}
/// The focus expression for a target layer: its anchor point's distance along the camera's view
/// axis.
fn layer_expr(name: &str) -> String {
let n = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
format!(
"var m = thisComp.layer({n});\nvar c = toWorld([0, 0, 0]);\nvar f = normalize(sub(toWorld([0, 0, 1]), c));\ndot(sub(m.toWorld(m.transform.anchorPoint), c), f)"
)
}
fn link_layer(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, cam, target) = refs(s, p, "camera.linkFocusToLayer", true)?;
let name = s.project.comp(cid).and_then(|c| c.layer(target?)).map(|l| l.name.clone()).ok_or(EngineError::NoComp)?;
set_focus_expr(s, cid, cam, "Link Focus Distance to Layer", layer_expr(&name))
}
/// Camera depth of `target`'s anchor point at the current time.
pub(crate) fn focus_depth(s: &Session, cid: ItemId, cam: LayerId, target: LayerId) -> Option<f64> {
let comp = s.project.comp(cid)?;
let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid));
ctx.expr = s.expr.as_deref();
let c = ctx.comp.layer(cam)?;
let t = ctx.comp.layer(target)?;
let cs = effectcraft_render::three_d::camera::layer_camera(&ctx, c);
let a = t.transform().map(|tr| ctx.v3(t, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
Some(cs.depth(ctx.world_matrix(t).apply(vec3(a[0], a[1], a[2]))))
}
fn set_to_layer(s: &mut Session, p: &Value) -> Result<Value> {
let c = "camera.setFocusToLayer";
let (cid, cam, target) = refs(s, p, c, true)?;
let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?;
if d <= 0.0 {
return Err(bad(c, "the layer is behind the camera"));
}
let t = s.time();
s.edit("Set Focus Distance to Layer", None, |proj, _| {
let l = layer_mut(proj, cid, cam)?;
let lt = l.layer_time(t);
let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad(c, "the camera has no Focus Distance"))?;
pr.set_value_at(lt, KV::Scalar(d));
Ok(json!({"focusDistance": d}))
})
}
fn camera_selected(s: &Session) -> std::result::Result<(), String> {
let c = s.active_comp().ok_or("no composition is open")?;
if s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| l.is_camera())) { Ok(()) } else { Err("select a camera layer".into()) }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], None, "{comp?, camera?}", camera_selected, link_poi),
cmd!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, link_layer),
cmd!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, set_to_layer),
]
}
+2
View File
@@ -10,6 +10,7 @@ mod edit;
mod effect;
mod file;
mod file_more;
mod focus;
mod frontend;
mod help;
mod key_transform;
@@ -129,6 +130,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view::specs());
v.extend(viewer_cmds::specs());
v.extend(shape_stroke::specs());
v.extend(focus::specs());
v.extend(key_transform::specs());
v.extend(file_more::specs());
v.extend(lottie::specs());
-3
View File
@@ -36,9 +36,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"),
stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"),
stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"),
stub!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], "{}"),
stub!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], "{}"),
stub!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], "{}"),
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
+30
View File
@@ -219,3 +219,33 @@ fn adaptive_sample_limit_drives_layer_samples() {
assert!(!json.contains("motion_blur_adaptive_limit"));
assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128);
}
#[test]
fn camera_focus_commands_undo_and_serde() {
let mut s = comp(400, 300);
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 500]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
assert!(s.is_enabled("camera.setFocusToLayer"));
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
assert!((d - before - 500.0).abs() < 1e-6, "the default camera focuses on z = 0: {before} → {d}");
s.execute("camera.linkFocusToLayer", json!({})).unwrap();
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
assert!(e["expression"].as_str().unwrap().contains("thisComp.layer("), "{e}");
let back = roundtrip(&s);
assert!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().expr.is_some());
s.execute("edit.undo", json!({})).unwrap();
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
assert!(e["expression"].is_null());
s.execute("edit.undo", json!({})).unwrap();
let v = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
assert!((v - before).abs() < 1e-9);
s.execute("edit.redo", json!({})).unwrap();
// A camera alone can't link to a layer, but can link to its point of interest.
s.execute("layer.select", json!({"layers": [cam]})).unwrap();
assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err());
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
}