Skip to content

Commit 3a8c1ba

Browse files
authored
Merge pull request #184 from jgphilpott/copilot/add-folder-position-memory
Persist lil-gui folder open/closed states and improve reset icon UX in visualizer
2 parents 64fa3e8 + 74f9fcf commit 3a8c1ba

File tree

5 files changed

+104
-8
lines changed

5 files changed

+104
-8
lines changed

examples/visualizer/modules/interactions.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import * as THREE from 'three';
77
import { focusCameraOnPoint } from './camera.js';
88

9+
// Duration of the reset icon spin animation in milliseconds (matches CSS).
10+
const SPIN_ANIMATION_DURATION_MS = 500;
11+
912
/**
1013
* Setup main event listeners for upload, download, and reset buttons.
1114
*/
@@ -24,7 +27,22 @@ export function setupEventListeners(handleFileUploadCallback, handleDownloadCall
2427
document.getElementById('download').addEventListener('click', handleDownloadCallback);
2528

2629
// Reset button
27-
document.getElementById('reset').addEventListener('click', resetViewCallback);
30+
document.getElementById('reset').addEventListener('click', () => {
31+
if (confirm('Are you sure you want to reset? This will restore all settings to their default values.')) {
32+
const resetIcon = document.getElementById('reset');
33+
// Force a reflow so the animation restarts even if clicked while still spinning.
34+
resetIcon.classList.remove('spinning');
35+
void resetIcon.offsetWidth;
36+
resetIcon.classList.add('spinning');
37+
// Remove the class when the animation ends; timeout fallback handles
38+
// cases where animationend never fires (e.g. prefers-reduced-motion).
39+
let spinTimeout;
40+
const cleanup = () => { clearTimeout(spinTimeout); resetIcon.classList.remove('spinning'); };
41+
resetIcon.addEventListener('animationend', cleanup, { once: true });
42+
spinTimeout = setTimeout(cleanup, SPIN_ANIMATION_DURATION_MS + 100);
43+
resetViewCallback();
44+
}
45+
});
2846
}
2947

3048
/**

examples/visualizer/modules/state.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,40 @@ export function clearSlicingSettings() {
180180
console.warn('Failed to clear slicing settings from localStorage:', error);
181181
}
182182
}
183+
184+
/**
185+
* Save folder open/closed states to localStorage.
186+
*/
187+
export function saveFolderStates(states) {
188+
try {
189+
localStorage.setItem('visualizer-folder-states', JSON.stringify(states));
190+
} catch (error) {
191+
console.warn('Failed to save folder states to localStorage:', error);
192+
}
193+
}
194+
195+
/**
196+
* Load folder open/closed states from localStorage.
197+
*/
198+
export function loadFolderStates() {
199+
try {
200+
const saved = localStorage.getItem('visualizer-folder-states');
201+
if (saved) {
202+
return JSON.parse(saved);
203+
}
204+
} catch (error) {
205+
console.warn('Failed to load folder states from localStorage:', error);
206+
}
207+
return null;
208+
}
209+
210+
/**
211+
* Clear folder open/closed states from localStorage.
212+
*/
213+
export function clearFolderStates() {
214+
try {
215+
localStorage.removeItem('visualizer-folder-states');
216+
} catch (error) {
217+
console.warn('Failed to clear folder states from localStorage:', error);
218+
}
219+
}

examples/visualizer/modules/ui.js

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
7-
import { saveSlicingSettings, loadSlicingSettings } from './state.js';
7+
import { saveSlicingSettings, loadSlicingSettings, saveFolderStates, loadFolderStates } from './state.js';
88

99
/**
1010
* Create the legends for movement types and axes.
@@ -166,7 +166,15 @@ export function createSlicingGUI(sliceCallback, useDefaults = false, rotateCallb
166166

167167
slicingGUI = new GUI({ title: 'Slicer' });
168168

169-
let h = slicingGUI.addFolder('Model Rotation');
169+
// Build a named folder map to avoid relying on lil-gui private properties.
170+
// Each entry tracks the folder reference and its current open state.
171+
const folderMap = {};
172+
const trackFolder = (title, folder, defaultOpen) => {
173+
folderMap[title] = { folder, isOpen: defaultOpen };
174+
return folder;
175+
};
176+
177+
let h = trackFolder('Model Rotation', slicingGUI.addFolder('Model Rotation'), true);
170178
h.add(params, 'rotationX', -180, 180, 1).name('Rotation X (°)').onChange((value) => {
171179
saveSlicingSettings(params);
172180
if (rotateCallback) rotateCallback('x', value);
@@ -187,26 +195,26 @@ export function createSlicingGUI(sliceCallback, useDefaults = false, rotateCallb
187195
rotateCallback('z', params.rotationZ);
188196
}
189197

190-
h = slicingGUI.addFolder('Printer & Filament');
198+
h = trackFolder('Printer & Filament', slicingGUI.addFolder('Printer & Filament'), true);
191199
h.add(params, 'printer', PRINTER_OPTIONS).name('Printer').onChange(() => saveSlicingSettings(params));
192200
h.add(params, 'filament', FILAMENT_OPTIONS).name('Filament').onChange(() => saveSlicingSettings(params));
193201
h.add(params, 'nozzleTemperature', 150, 300, 5).name('Nozzle Temp (°C)').onFinishChange(() => saveSlicingSettings(params));
194202
h.add(params, 'bedTemperature', 0, 120, 5).name('Bed Temp (°C)').onFinishChange(() => saveSlicingSettings(params));
195203
h.add(params, 'fanSpeed', 0, 100, 5).name('Fan Speed (%)').onFinishChange(() => saveSlicingSettings(params));
196204

197-
h = slicingGUI.addFolder('Slicer Settings');
205+
h = trackFolder('Slicer Settings', slicingGUI.addFolder('Slicer Settings'), true);
198206
h.add(params, 'shellWallThickness', 0.4, 2.0, 0.4).name('Shell Wall Thickness (mm)').onFinishChange(() => saveSlicingSettings(params));
199207
h.add(params, 'shellSkinThickness', 0.4, 2.0, 0.4).name('Shell Skin Thickness (mm)').onFinishChange(() => saveSlicingSettings(params));
200208
h.add(params, 'layerHeight', 0.1, 0.4, 0.05).name('Layer Height (mm)').onFinishChange(() => saveSlicingSettings(params));
201209
h.add(params, 'infillDensity', 0, 100, 5).name('Infill Density (%)').onFinishChange(() => saveSlicingSettings(params));
202210
h.add(params, 'infillPattern', INFILL_PATTERN_OPTIONS).name('Infill Pattern').onChange(() => saveSlicingSettings(params));
203211

204-
h = slicingGUI.addFolder('Adhesion');
212+
h = trackFolder('Adhesion', slicingGUI.addFolder('Adhesion'), false);
205213
h.add(params, 'adhesionEnabled').name('Adhesion Enabled').onChange(() => saveSlicingSettings(params));
206214
h.add(params, 'adhesionType', ['skirt', 'brim', 'raft']).name('Adhesion Type').onChange(() => saveSlicingSettings(params));
207215
h.close();
208216

209-
h = slicingGUI.addFolder('Support');
217+
h = trackFolder('Support', slicingGUI.addFolder('Support'), false);
210218
h.add(params, 'supportEnabled').name('Support Enabled').onChange(() => saveSlicingSettings(params));
211219
h.add(params, 'supportType', ['normal', 'tree']).name('Support Type').onChange(() => saveSlicingSettings(params));
212220
h.add(params, 'supportPlacement', ['buildPlate', 'everywhere']).name('Support Placement').onChange(() => saveSlicingSettings(params));
@@ -216,6 +224,28 @@ export function createSlicingGUI(sliceCallback, useDefaults = false, rotateCallb
216224
slicingGUI.add(params, 'slice').name('Slice');
217225
slicingGUI.open();
218226

227+
// Collect the current open/closed state of all folders.
228+
const collectFolderStates = () =>
229+
Object.fromEntries(Object.entries(folderMap).map(([title, entry]) => [title, entry.isOpen]));
230+
231+
// Apply saved folder states (skipped when resetting to defaults), then
232+
// attach a click listener to each folder title to persist state on toggle.
233+
const savedFolderStates = useDefaults ? null : loadFolderStates();
234+
for (const [title, entry] of Object.entries(folderMap)) {
235+
if (savedFolderStates && title in savedFolderStates) {
236+
entry.isOpen = savedFolderStates[title];
237+
if (entry.isOpen) {
238+
entry.folder.open();
239+
} else {
240+
entry.folder.close();
241+
}
242+
}
243+
entry.folder.$title.addEventListener('click', () => {
244+
entry.isOpen = !entry.isOpen;
245+
saveFolderStates(collectFolderStates());
246+
});
247+
}
248+
219249
// Store params on the GUI instance for access in sliceModel
220250
slicingGUI.userData = params;
221251

examples/visualizer/visualizer.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ body {
5252
transform: scale(1.1);
5353
}
5454

55+
@keyframes spin-once {
56+
from { transform: rotate(0deg); }
57+
to { transform: rotate(360deg); }
58+
}
59+
60+
#reset.spinning {
61+
animation: spin-once 0.5s ease-in-out;
62+
transition: none;
63+
}
64+
5565
.icon-row {
5666
display: flex;
5767
flex-direction: row;

examples/visualizer/visualizer.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
showGCodeLegends,
2929
updateDownloadButtonVisibility
3030
} from './modules/ui.js';
31-
import { clearSlicingSettings, saveCheckboxStates, saveAxisCheckboxStates, saveSettingsStates, saveSlicingSettings } from './modules/state.js';
31+
import { clearSlicingSettings, clearFolderStates, saveCheckboxStates, saveAxisCheckboxStates, saveSettingsStates, saveSlicingSettings } from './modules/state.js';
3232
import { centerCamera, resetCameraToDefault } from './modules/camera.js';
3333
import {
3434
setupMovementTypeToggles,
@@ -481,6 +481,7 @@ function resetView() {
481481

482482
// Reset slicing settings
483483
clearSlicingSettings();
484+
clearFolderStates();
484485

485486
// Detach TransformControls before resetting rotation.
486487
if (transformControls) {

0 commit comments

Comments
 (0)