graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: an edit inside a simnet is in from the next frame
An edit to the chain or the seed goes on from the frame in hand under
the new key instead of re-solving from the seed; its checkpoints go.
The solve is then mixed, and a scrub back from it does not keep the
frame it leaves, so returning to the start clears what was solved
before the edit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 37 +++++++++++++++++++++--
src/geometry.rs | 92 +++++++++++++++++++++++++++++++++++++++++++--------------
2 files changed, 104 insertions(+), 25 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 84a42f4..efb6a42 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -273,7 +273,9 @@ gone from cce-ui with the wgpu path).
`input` node reads off the feedback stack instead of jumping to the outer
graph. Solves run up to the playbar frame and cache per node id on `State::
sim_cache` (playing forward = one step per frame); the cache key hashes the
- simnet subtree + seed, so edits restart the sim, and backward scrubs resume
+ simnet subtree + seed; an edit goes on from the frame in hand under the
+ new key (since 2026-09-30 — see "An edit is in from the next frame"
+ below), and backward scrubs resume
from the nearest CHECKPOINT behind them (steps are not invertible; until
2026-09-29 they restarted from the seed — see "Simulation checkpoints"
below). The scene walk does NOT recurse
@@ -1669,8 +1671,8 @@ need.
for**, the latest state included, so a scrub either way inside what
has been solved steps fewer than an interval's frames.
- **They belong to one key.** An edit to the chain or the seed changes
- the key and they go with the solve they were frames of. An edit at
- frame 120 is still 120 steps: nothing earlier than an edit survives it.
+ the key and they go with the solve they were frames of; the frame in
+ hand does not (the next section).
- **Within a count and a budget** (`CHECKPOINTS_MAX` 48,
`CHECKPOINT_BUDGET` 512 MB by an estimate of a state's size). With no
room the SPACING doubles and stays doubled — what is off the wider
@@ -1754,6 +1756,35 @@ selected row's marker, stood at the first frame while the scene beside
them played. `rows_selected_inside_a_simnet_follow_the_simulation` is the
test.
+### An edit is in from the next frame
+
+An edit inside a simnet's chain, or to its seed, does not restart the
+solve (since 2026-09-30; until then the key change dropped the cache
+entry, so an edit at frame 120 was a re-solve of 120 frames, and a slider
+dragged inside a simnet re-solved the whole run per pixel). In
+`resolve_simnet_geometry_with_errors` an entry of another key whose
+frame is at or behind the one asked for is kept under the NEW key — its
+state and what its last substep consumed — and its checkpoints dropped,
+being frames of the solve as it was. So:
+
+- the frame in hand stands as it is, and the edit shows from the next
+ frame forward, the solve going on from the state in hand;
+- such a solve is MIXED (`SimSolve::mixed`): its earlier frames are of
+ the chain as it was. A scrub BACK from it has no checkpoint to resume
+ from and does not keep the frame it leaves (which a scrub back from a
+ clean solve does), so it re-solves from the seed with the edit in from
+ the first frame — at the start frame the seed itself: going back to
+ frame 1 is what clears the frames solved before the edit, and the solve
+ that begins there is clean;
+- a solve at the start frame (frame 0 of the sim) is always the seed, so
+ an edit made there restarts at once.
+
+The disk cache (Cache on) is by key, so a continued solve is written
+under the new key and read back by it.
+`test_editing_the_chain_invalidates_the_cache` and the edit half of
+`a_scrub_resumes_from_a_checkpoint_and_arrives_at_the_same_state` are
+the tests.
+
### The volume representation
`src/volume.rs` is a dense signed distance field — `Volume { origin, voxel,
diff --git a/src/geometry.rs b/src/geometry.rs
index 23a369e..f79339f 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1069,6 +1069,11 @@ struct SimSolve {
/// from the nearest one behind it instead of the seed. See
/// [`Checkpoint`].
checkpoints: Checkpoints,
+ /// Whether the solve went on across an edit (a key change) from the
+ /// frame then in hand, so its earlier frames are of the chain as it
+ /// was: a scrub back does not keep the frame it leaves, and the solve
+ /// begins again from the seed.
+ mixed: bool,
}
/// A frame of a solve, kept in memory: its state and what its last substep
@@ -6753,9 +6758,31 @@ pub fn resolve_simnet_geometry_with_errors(
// somewhere earlier. Memory first, then disk, then the seed.
//
// The entry is taken out while the solve runs and put back at its end.
- // One of another key (the chain or the seed was edited) is dropped
- // here, its checkpoints with it.
- let prior = sim.cache.entries.remove(&target.id).filter(|e| e.key == key);
+ //
+ // One of another key — the chain or the seed was edited — is NOT
+ // dropped (since 2026-09-30): the solve goes on from the state in hand
+ // under the new key, so an edit takes effect from the next frame
+ // forward and the frames already solved are not solved again. Its
+ // checkpoints go, being frames of the solve as it was, so a scrub
+ // BACK after an edit resumes from nothing nearer than the seed —
+ // which is the restart, and at the start frame is the seed itself.
+ // Until then an edit restarted the solve from the seed wherever the
+ // playhead stood: an edit at frame 120 was 120 steps, and a slider
+ // dragged inside a simnet was a re-solve of the whole run per pixel.
+ let prior = sim.cache.entries.remove(&target.id).and_then(|e| {
+ if e.key == key {
+ // A solve that mixes frames from before an edit with frames
+ // after it is not resumed from by a scrub BACK: the frame it
+ // leaves is not kept, so what was solved before the edit is
+ // gone and the solve begins again from the seed.
+ if e.mixed && e.frame > due { None } else { Some(e) }
+ } else if e.frame > 0 && e.frame <= due {
+ Some(SimSolve { key, frame: e.frame, state: e.state, prev: e.prev, checkpoints: Checkpoints::default(), mixed: true })
+ } else {
+ None
+ }
+ });
+ let mixed = prior.as_ref().is_some_and(|e| e.mixed);
let mut checkpoints = Checkpoints::default();
let mut cached: Option<(Detail, Detail, i32)> = None;
if let Some(prior) = prior {
@@ -6862,7 +6889,7 @@ pub fn resolve_simnet_geometry_with_errors(
}
sim.cache.entries.insert(
target.id.clone(),
- SimSolve { key, frame: due, state: state.clone(), prev: prev_frame, checkpoints },
+ SimSolve { key, frame: due, state: state.clone(), prev: prev_frame, checkpoints, mixed },
);
Some(state)
}
@@ -8631,31 +8658,41 @@ mod simnet_tests {
/// old chain is not a state of the new one.
#[test]
fn test_editing_the_chain_invalidates_the_cache() {
+ // An edit takes effect from the NEXT frame forward: the frames
+ // already solved stand, and the solve goes on from the state in
+ // hand under the new parameters. Going back to the start is the
+ // restart, and the frames are solved again with the edit in from
+ // the first. Until 2026-09-30 an edit re-solved from the seed
+ // wherever the playhead stood.
let mut root = stepping_graph();
+ let base = min_x(&solve_at(&root, 1));
let sim_node = root.children.iter().find(|c| c.node_type == "simnet").unwrap().clone();
let mut cache = SimCache::default();
- {
- let mut sim = EvalSim::new(5, 1, &mut cache);
- let mut visited = Vec::new();
- let mut err = None;
- resolve_simnet_geometry_with_errors(&root, &sim_node, &mut visited, &mut err, &mut sim).unwrap();
- }
+ let at = |root: &FsNode, sim_node: &FsNode, cache: &mut SimCache, frame: i32| -> f32 {
+ let mut sim = EvalSim::new(frame, 1, cache);
+ let (mut visited, mut err) = (Vec::new(), None);
+ let g = resolve_simnet_geometry_with_errors(root, sim_node, &mut visited, &mut err, &mut sim).unwrap();
+ min_x(&g) - base
+ };
+ assert!((at(&root, &sim_node, &mut cache, 5) - 4.0).abs() < 1e-4, "four steps of +1");
+ let steps = cache.steps_run();
- // Double the step size; frame 5 (4 steps) must now read 8, not 4.
+ // Double the step size at frame 5.
{
let sim_mut = root.children.iter_mut().find(|c| c.node_type == "simnet").unwrap();
let step = sim_mut.children.iter_mut().find(|c| c.name == "step1").unwrap();
step.params.iter_mut().find(|p| p.name == "Translation").unwrap().set_text("2.00:0.00:0.00".to_string());
}
let sim_node = root.children.iter().find(|c| c.node_type == "simnet").unwrap().clone();
- let base = min_x(&solve_at(&root, 1));
- let mut sim = EvalSim::new(5, 1, &mut cache);
- let mut visited = Vec::new();
- let mut err = None;
- let g = resolve_simnet_geometry_with_errors(&root, &sim_node, &mut visited, &mut err, &mut sim).unwrap();
- let moved = min_x(&g) - base;
- assert!((moved - 8.0).abs() < 1e-4,
- "stale cache: expected 4 steps of +2.0 = 8, got {moved}");
+ // Frame 5 is as it was: no step is run for it.
+ assert!((at(&root, &sim_node, &mut cache, 5) - 4.0).abs() < 1e-4, "the frame in hand stands");
+ assert_eq!(cache.steps_run(), steps, "and nothing was solved again");
+ // Frame 6 is one step of +2 on from it.
+ assert!((at(&root, &sim_node, &mut cache, 6) - 6.0).abs() < 1e-4, "the edit is in from the next frame");
+ assert_eq!(cache.steps_run(), steps + 1);
+ // Back to the start: the restart. Frame 5 is then four steps of +2.
+ assert!((at(&root, &sim_node, &mut cache, 1) - 0.0).abs() < 1e-4, "the start frame is the seed");
+ assert!((at(&root, &sim_node, &mut cache, 5) - 8.0).abs() < 1e-4, "solved again with the edit in from the first");
}
/// Dived INTO a simnet the walk draws the solved state — its children are
@@ -9514,15 +9551,26 @@ mod simnet_tests {
let (state, _, cost) = at(&mut cache, 24);
assert_eq!((cost, state.positions() == fresh(24).0.positions()), (3 * 2, true));
- // An edit is another solve: nothing of the old one is resumed from.
+ // An edit goes on from the frame in hand (24): the frames before
+ // it stand, and none of the old solve's checkpoints are resumed
+ // from — the new solve keeps its own as it passes them.
let edited = sim_of("1.02:0.99:1.02", "2");
let mut sim = EvalSim::new(51, 1, &mut cache);
let mut err = None;
let before = sim.cache.steps_run();
let state = resolve_simnet_geometry_with_errors(&edited, &edited.children[1], &mut Vec::new(), &mut err, &mut sim).unwrap();
- assert_eq!(sim.cache.steps_run() - before, 100, "fifty frames from the seed");
+ assert_eq!(sim.cache.steps_run() - before, 27 * 2, "twenty-seven frames on from the one in hand");
assert_ne!(state.positions(), fresh(51).0.positions());
- assert_eq!(cache.checkpoint_frames(&simnet.id), vec![10, 20, 30, 40], "and its own checkpoints");
+ assert_eq!(cache.checkpoint_frames(&simnet.id), vec![30, 40], "and its own checkpoints, kept as it passed them");
+ // Back to the start it is the seed, and the edit is in from the
+ // first frame: frame 51 is then the edited chain's own fifty.
+ let mut sim = EvalSim::new(1, 1, &mut cache);
+ let seed = resolve_simnet_geometry_with_errors(&edited, &edited.children[1], &mut Vec::new(), &mut None, &mut sim).unwrap();
+ assert_eq!(seed.positions(), fresh(1).0.positions());
+ let mut sim = EvalSim::new(51, 1, &mut cache);
+ let before = sim.cache.steps_run();
+ resolve_simnet_geometry_with_errors(&edited, &edited.children[1], &mut Vec::new(), &mut None, &mut sim).unwrap();
+ assert_eq!(sim.cache.steps_run() - before, 100, "fifty frames from the seed");
// However long it runs, the count is bounded, and what is kept
// stays spread over the whole of it.