From a2f6cd3ed581ecca769e81ecd7bc64ce47d29107 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 09:15:05 -0300 Subject: [PATCH] 3D Camera Tracker: a partial solve is not reported as solved (#2822) camera.solveStatus said `solved: true` for any stored solve, but the solver keeps the frames it cannot resect as unsolved and interpolates the camera there. `solved` now needs every frame, the same check camera.exportBlender makes, and `partial` marks a solve with gaps, next to `solvedFrames`. --- crates/engine/src/cmd/camera_cmds.rs | 9 +++++++-- crates/engine/src/tests/blender.rs | 17 +++++++++++++++++ docs/agents.md | 4 +++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/camera_cmds.rs b/crates/engine/src/cmd/camera_cmds.rs index e717d51cc..7f93730f8 100644 --- a/crates/engine/src/cmd/camera_cmds.rs +++ b/crates/engine/src/cmd/camera_cmds.rs @@ -246,13 +246,18 @@ fn status(s: &mut Session, p: &Value) -> Result { out["deleted"] = json!(settings.deleted.len()); out["lensDistortionSolved"] = json!(settings.lens_distortion); let solve = ct::solve(¶ms); - out["solved"] = json!(solve.is_some()); + // The solver interpolates the frames it could not solve: only a solve of every frame is + // `solved` (what `camera.exportBlender` needs); one with gaps is `partial`. + let solved_frames = solve.as_ref().map_or(0, |sv| sv.frames.iter().filter(|c| c.solved).count()); + let complete = solve.as_ref().is_some_and(|sv| solved_frames == sv.frames.len()); + out["solved"] = json!(complete); + out["partial"] = json!(solve.is_some() && !complete); if let Some(sv) = &solve { let f = sv.focal(); out["methodUsed"] = json!(sv.method_used.label()); out["averageError"] = json!(sv.average_error); out["points"] = json!(sv.points.len()); - out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count()); + out["solvedFrames"] = json!(solved_frames); out["focalLength"] = json!(f); out["horizontalAngleOfView"] = json!(sv.hfov(f)); out["groundPlane"] = json!(sv.ground.is_some()); diff --git a/crates/engine/src/tests/blender.rs b/crates/engine/src/tests/blender.rs index 84e939055..b4e0a5c80 100644 --- a/crates/engine/src/tests/blender.rs +++ b/crates/engine/src/tests/blender.rs @@ -238,6 +238,23 @@ fn blender_export_rejects_malformed_solves_without_panicking() { } } +/// A solve with unsolved (interpolated) frames is partial, not solved, so the status agrees with +/// the export, which refuses it. +#[test] +fn solve_status_reports_unsolved_frames_as_partial() { + let (mut s, lid, uid) = fixture(false); + let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap(); + assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(true), &json!(false), &json!(3)), "{st}"); + let mut solve = + ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap())).unwrap().as_ref().clone(); + solve.frames[1].solved = false; + s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap(); + s.camera_pending.clear(); + let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap(); + assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(false), &json!(true), &json!(2)), "{st}"); + assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("partial.py")})).is_err()); +} + #[test] fn blender_export_respects_script_write_permission() { let (mut s, lid, _) = fixture(false); diff --git a/docs/agents.md b/docs/agents.md index 34162aacd..c3e51e751 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -476,7 +476,9 @@ undistort?, wait?}`) apply Effect ▸ Perspective ▸ 3D Camera Tracker (or reuse the layer's) and analyse it in the background: "Analyzing in background (step 1 of 2)" tracks features, "Solving camera" solves. `camera.analyze {layer?, effect?, wait?}` re-runs it, `camera.cancel` stops it without writing -anything. `camera.solveStatus` reports `progress`/`banner`, `analyzed`, `solved`, `methodUsed`, +anything. `camera.solveStatus` reports `progress`/`banner`, `analyzed`, `solved` (every frame +solved), `partial` with `solvedFrames` of `frames` (the camera is interpolated on the other frames, +and `camera.exportBlender` refuses such a solve), `methodUsed`, `averageError` (pixels), `focalLength`, `horizontalAngleOfView`, the current frame's `camera` (position, orientation, zoom), `groundPlane` and the solved `lensDistortion` (`k1`, `k2`, normalised by half the frame diagonal) when Solve Lens Distortion (`effects/#1/advanced/lensDistortion`, also on