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
57 changes: 57 additions & 0 deletions src/store/modelerStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,29 @@ describe('Modeler editing scenarios', () => {
expect(getState().tree!.params.radius).toBe(20); // default
});

it('clears a selection that the undone tree no longer contains', () => {
getState().addPrimitive('box');
const boxId = getState().tree!.id;
getState().selectNode(boxId);
expect(getState().selectedNodeId).toBe(boxId);

// Undo back past the box's creation — the selected node is gone.
getState().undo();
expect(getState().tree).toBeNull();
expect(getState().selectedNodeId).toBeNull();
});

it('keeps a selection that survives the undo', () => {
getState().addPrimitive('box');
const boxId = getState().tree!.id;
getState().updateNodeParams(boxId, { radius: 3 });
getState().selectNode(boxId);

getState().undo();
expect(getState().tree!.id).toBe(boxId);
expect(getState().selectedNodeId).toBe(boxId);
});

it('undoes wrap operation', () => {
getState().addPrimitive('box');
const boxId = getState().tree!.id;
Expand Down Expand Up @@ -236,6 +259,40 @@ describe('Modeler editing scenarios', () => {
expect(getState().tree!.enabled).toBe(true);
});

it('records the toggle in history so undo reverts only the toggle', () => {
getState().addPrimitive('box');
const id = getState().tree!.id;
getState().updateNodeParams(id, { radius: 7 });

getState().toggleNode(id);
expect(getState().tree!.enabled).toBe(false);

// Undo should restore the enabled box with radius 7 — not discard the
// parameter edit along with the toggle.
getState().undo();
expect(getState().tree!.enabled).toBe(true);
expect(getState().tree!.params.radius).toBe(7);
});

it('keeps tree in sync with history[historyIndex] after a toggle', () => {
getState().addPrimitive('box');
getState().toggleNode(getState().tree!.id);

const { tree, history, historyIndex } = getState();
expect(history[historyIndex]).toEqual(tree);
});

it('redoes a toggle', () => {
getState().addPrimitive('box');
const id = getState().tree!.id;
getState().toggleNode(id);
getState().undo();
expect(getState().tree!.enabled).toBe(true);

getState().redo();
expect(getState().tree!.enabled).toBe(false);
});

it('disabled nodes pass tree validation', () => {
getState().addPrimitive('box');
const boxId = getState().tree!.id;
Expand Down
27 changes: 23 additions & 4 deletions src/store/modelerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ function cloneTree(node: SDFNodeUI): SDFNodeUI {
return JSON.parse(JSON.stringify(node));
}

/**
* The selection to keep after the tree is replaced wholesale. Undo/redo can
* restore a tree in which the selected node no longer exists; leaving the id
* dangling makes every `findNode` consumer silently no-op.
*/
function surviving(tree: SDFNodeUI | null, selectedNodeId: string | null): string | null {
if (!tree || !selectedNodeId) return null;
return findNode(tree, selectedNodeId) ? selectedNodeId : null;
}

function findNode(tree: SDFNodeUI, id: string): SDFNodeUI | null {
if (tree.id === id) return tree;
for (const child of tree.children) {
Expand Down Expand Up @@ -258,10 +268,17 @@ export const useModelerStore = create<ModelerState>()((set, get) => ({
},

toggleNode: (id) => {
const { tree } = get();
const state = get();
const { tree } = state;
if (!tree) return;
const newTree = updateInTree(tree, id, (node) => ({ ...node, enabled: !node.enabled }));
set({ tree: newTree });
// Disabling a node changes the rendered geometry and the exported mesh, so
// this is a document mutation and belongs in history like any other.
// Without the push, `tree` and `history[historyIndex]` diverge and a
// subsequent undo discards one more edit than the user asked for.
const newHistory = state.history.slice(0, state.historyIndex + 1);
newHistory.push(cloneTree(newTree));
set({ tree: newTree, history: newHistory, historyIndex: newHistory.length - 1 });
},

toggleExpanded: (id) => {
Expand Down Expand Up @@ -683,15 +700,17 @@ export const useModelerStore = create<ModelerState>()((set, get) => ({
const { historyIndex, history } = get();
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
set({ tree: history[newIndex] ? cloneTree(history[newIndex]!) : null, historyIndex: newIndex });
const restored = history[newIndex] ? cloneTree(history[newIndex]!) : null;
set({ tree: restored, historyIndex: newIndex, selectedNodeId: surviving(restored, get().selectedNodeId) });
}
},

redo: () => {
const { historyIndex, history } = get();
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
set({ tree: history[newIndex] ? cloneTree(history[newIndex]!) : null, historyIndex: newIndex });
const restored = history[newIndex] ? cloneTree(history[newIndex]!) : null;
set({ tree: restored, historyIndex: newIndex, selectedNodeId: surviving(restored, get().selectedNodeId) });
}
},

Expand Down
Loading