From acec4bdacaa8c1192e0ff0c436d90c82c0d57e27 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Thu, 23 Apr 2026 11:20:46 +0200 Subject: [PATCH 1/2] feat: add auto-rotate and record toolbar actions, split toolbar state into session vs default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toolbar toggles (bounding box, wireframe, auto-rotate) now affect only the current view; persistent defaults live in a new Settings → Toolbar tab. Auto-rotate offers a session-only speed/reverse popover; record captures the 3D view to mp4/webm with optional whole-webview or without-sidebar variants. Canvas screenshots and recordings crop out the sidebar region so the camera-offset mesh stays centered in the output. Settings tabs reorganized into Rendering/Groups/Visibility/Toolbar; dream background enabled by default. --- README.md | 16 +- package.json | 22 +- src/WebviewVisu.ts | 22 +- src/projectPaths.ts | 4 + .../src/components/layout/TopToolbar.svelte | 6 + .../src/components/popups/HelpPopup.svelte | 41 +- .../components/popups/SettingsPopup.svelte | 324 +++++++-- .../components/viewer/AutoRotateButton.svelte | 100 +++ .../viewer/BoundingBoxButton.svelte | 17 +- .../viewer/BoundingBoxLabels.svelte | 4 +- .../src/components/viewer/RecordButton.svelte | 618 ++++++++++++++++++ .../components/viewer/ScreenshotButton.svelte | 123 +++- .../components/viewer/WireframeButton.svelte | 17 +- .../viewer/src/icons/AutoRotateIcon.svelte | 14 + webviews/viewer/src/icons/RecordIcon.svelte | 16 + .../src/lib/interaction/CameraManager.ts | 70 ++ .../viewer/src/lib/settings/GlobalSettings.ts | 5 +- webviews/viewer/src/lib/state.ts | 16 +- webviews/viewer/src/main.ts | 23 +- 19 files changed, 1338 insertions(+), 120 deletions(-) create mode 100644 webviews/viewer/src/components/viewer/AutoRotateButton.svelte create mode 100644 webviews/viewer/src/components/viewer/RecordButton.svelte create mode 100644 webviews/viewer/src/icons/AutoRotateIcon.svelte create mode 100644 webviews/viewer/src/icons/RecordIcon.svelte diff --git a/README.md b/README.md index 14c261d..1eac70e 100644 --- a/README.md +++ b/README.md @@ -210,9 +210,12 @@ There are two ways to open the visualizer : - Control the camera by rotating or panning it - **Bounding box** : toggle a wireframe cube with colored axes (X red, Y green, Z blue), corner dots, and dimension labels to quickly read the characteristic size of the structure - **Wireframe mode** : switch between solid surface and wireframe rendering to inspect mesh density -- **Screenshot** : save the current 3D view as a PNG file next to your mesh and copy it to the clipboard +- **Auto-rotate** : hands-free turntable that spins the camera around the current view-up; right-click the button to reveal a popover with a speed slider (5–180 °/s) and a reverse-direction toggle — changes there apply to the current view only, persistent defaults live in _Settings → Toolbar_ +- **Screenshot** : save the current 3D view as a PNG file next to your mesh and copy it to the clipboard; right-click for a menu to capture the whole webview (toolbar + sidebar baked in) +- **Record** : capture a video of the view (mp4 when the runtime supports h264, webm otherwise), saved to `.vs-code-aster/recordings/`; left-click to start/stop, right-click for whole-webview or without-sidebar variants. The button pulses red with an elapsed-time indicator while recording - **Per-kind settings** : Settings popup exposes edge-group line thickness, edge-group depth offset (to avoid z-fighting), node-group point size, and the sidebar sort order — plus a toggle to bucket groups by kind or mix them into a single list -- **Dream background** : optional cosmetic setting that animates EDF orange and blue light blobs behind the mesh for a more vibrant workspace +- **Remembered toolbar defaults** : every toolbar button (bounding box, wireframe, auto-rotate) is session-only by default — toggling it changes the current view only. Persistent defaults are set in _Settings → Toolbar_, grouped per feature (Bounding box, Wireframe, Auto-rotate with default speed and default direction) +- **Dream background** : on by default, animates EDF orange and blue light blobs behind the mesh for a more vibrant workspace; can be disabled from _Settings → Rendering_ #### Usage tips @@ -228,12 +231,17 @@ There are two ways to open the visualizer : - Use the `Mouse wheel` to zoom in and out - Click on the `X`, `Y`, and `Z` buttons at the bottom of the sidebar to quickly align the camera along an axis - Toolbar : - - The top toolbar provides quick access to the bounding box, wireframe, and screenshot features - - Right-click the screenshot button to capture the full viewer including the sidebar + - The top toolbar provides quick access to the bounding box, wireframe, auto-rotate, screenshot, and record features + - Left-click toggles a feature for the current view only; remembered defaults live in _Settings → Toolbar_ + - Right-click the **auto-rotate** button for a session-only speed slider and reverse-direction toggle + - Right-click the **screenshot** button for a menu to capture the whole webview (sidebar included) + - Right-click the **record** button for options: whole webview, or without the sidebar +- Settings tabs : _Rendering_ (mesh-edge mode, orientation widget, dream background), _Groups_ (per-kind display), _Visibility_ (ghosted objects, highlight transparency), _Toolbar_ (default state for toolbar buttons) - File management : - Files generated by the extension are stored in a hidden `.vs-code-aster/` folder next to your project files: - `mesh_cache/` — converted `.obj` files from your `.*med` meshes, reused on subsequent opens - `screenshots/` — PNGs saved from the viewer's screenshot button + - `recordings/` — video files saved from the viewer's record button (mp4 / webm) - `run_logs/` — one timestamped log per code_aster run (oldest pruned, see `vs-code-aster.maxRunLogs`) ## Troubleshooting diff --git a/package.json b/package.json index 20521d6..54f5f55 100644 --- a/package.json +++ b/package.json @@ -362,9 +362,29 @@ "vs-code-aster.viewer.dreamBackground": { "order": 22, "type": "boolean", - "default": false, + "default": true, "markdownDescription": "Cosmetic animated EDF orange and blue light blobs slowly breathing behind the mesh. Purely decorative — does not affect mesh lighting." }, + "vs-code-aster.viewer.autoRotate": { + "order": 23, + "type": "boolean", + "default": false, + "markdownDescription": "Automatically rotate the view around the current vertical axis like a turntable." + }, + "vs-code-aster.viewer.autoRotateSpeed": { + "order": 24, + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 180, + "markdownDescription": "Default auto-rotation speed in degrees per second (between `5` and `180`). Transient overrides from the toolbar popover are not persisted." + }, + "vs-code-aster.viewer.autoRotateReverse": { + "order": 25, + "type": "boolean", + "default": false, + "markdownDescription": "Default auto-rotation direction. When enabled, the view rotates the opposite way by default. Transient overrides from the toolbar popover are not persisted." + }, "vs-code-aster.enableTelemetry": { "order": 100, "type": "boolean", diff --git a/src/WebviewVisu.ts b/src/WebviewVisu.ts index b522294..435ca00 100644 --- a/src/WebviewVisu.ts +++ b/src/WebviewVisu.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; -import { getScreenshotsDir } from './projectPaths'; +import { getScreenshotsDir, getRecordingsDir } from './projectPaths'; /** * Provides basic dialog semantics over a VS Code webview panel for mesh visualization. @@ -130,6 +130,9 @@ export class WebviewVisu implements vscode.Disposable { 'showBoundingBox', 'showWireframe', 'dreamBackground', + 'autoRotate', + 'autoRotateSpeed', + 'autoRotateReverse', ]; for (const key of settingKeys) { if (e.settings[key] !== undefined) { @@ -147,6 +150,18 @@ export class WebviewVisu implements vscode.Disposable { } break; } + case 'saveRecording': { + if (this.sourceDir) { + const dataUrl = e.dataUrl as string; + const commaIdx = dataUrl.indexOf(';base64,'); + const base64 = commaIdx >= 0 ? dataUrl.slice(commaIdx + ';base64,'.length) : dataUrl; + const buffer = Buffer.from(base64, 'base64'); + const recordingsDir = getRecordingsDir(this.sourceDir); + const filePath = path.join(recordingsDir, e.filename as string); + fs.writeFileSync(filePath, buffer); + } + break; + } case 'debugPanel': // Log debug messages from the webview console.log('[WebviewVisu] Message received from webview:', e.text); @@ -207,7 +222,10 @@ export class WebviewVisu implements vscode.Disposable { showOrientationWidget: config.get('viewer.showOrientationWidget', true), showBoundingBox: config.get('viewer.showBoundingBox', false), showWireframe: config.get('viewer.showWireframe', false), - dreamBackground: config.get('viewer.dreamBackground', false), + dreamBackground: config.get('viewer.dreamBackground', true), + autoRotate: config.get('viewer.autoRotate', false), + autoRotateSpeed: config.get('viewer.autoRotateSpeed', 15), + autoRotateReverse: config.get('viewer.autoRotateReverse', false), }; this.panel.webview.postMessage({ type: 'init', diff --git a/src/projectPaths.ts b/src/projectPaths.ts index d1414ee..e389faf 100644 --- a/src/projectPaths.ts +++ b/src/projectPaths.ts @@ -31,6 +31,10 @@ export function getScreenshotsDir(projectDir: string): string { return ensureDir(path.join(getProjectDir(projectDir), 'screenshots')); } +export function getRecordingsDir(projectDir: string): string { + return ensureDir(path.join(getProjectDir(projectDir), 'recordings')); +} + export function getRunLogsDir(projectDir: string): string { const dir = ensureDir(path.join(getProjectDir(projectDir), 'run_logs')); migrateLegacyRunLog(projectDir, dir); diff --git a/webviews/viewer/src/components/layout/TopToolbar.svelte b/webviews/viewer/src/components/layout/TopToolbar.svelte index 9ef07eb..8c67532 100644 --- a/webviews/viewer/src/components/layout/TopToolbar.svelte +++ b/webviews/viewer/src/components/layout/TopToolbar.svelte @@ -1,7 +1,9 @@
+ +
+
+ diff --git a/webviews/viewer/src/components/popups/HelpPopup.svelte b/webviews/viewer/src/components/popups/HelpPopup.svelte index 8fa5389..94fd7ad 100644 --- a/webviews/viewer/src/components/popups/HelpPopup.svelte +++ b/webviews/viewer/src/components/popups/HelpPopup.svelte @@ -14,6 +14,8 @@ import ObjectIcon from '../../icons/ObjectIcon.svelte'; import MouseScrollIcon from '../../icons/MouseScrollIcon.svelte'; import ResetIcon from '../../icons/ResetIcon.svelte'; + import AutoRotateIcon from '../../icons/AutoRotateIcon.svelte'; + import RecordIcon from '../../icons/RecordIcon.svelte'; import ScreenshotIcon from '../../icons/ScreenshotIcon.svelte'; import VolumeIcon from '../../icons/VolumeIcon.svelte'; import WireframeIcon from '../../icons/WireframeIcon.svelte'; @@ -132,15 +134,50 @@ inspect mesh density + + + + Auto-rotate the view like a turntable. + Right click to open a popover with a speed slider and a + reverse direction toggle — changes there apply to the current session + only; set the remembered defaults in Settings → Toolbar. + Left click — save the 3D view as PNG next to your file & copy to - clipboard. Right click — capture the full viewer including the sidebar.Screenshot the 3D view as PNG (saved next to your mesh & copied to + clipboard). Left click — canvas only. Right click — + menu with Screenshot whole screen to include the toolbar and sidebar. + + + + + Record a video of the 3D view (mp4 when the webview's Chromium + supports h264, webm otherwise; saved to .vs-code-aster/recordings/). The + button pulses red with an elapsed timer while recording. Left click — + start / stop a canvas-only recording. Right click — menu with + Record whole webview (bakes the toolbar, sidebar, popups, and labels into the + video) or Record without sidebar. The whole-webview mode rasterizes the DOM on + every real UI change, which can briefly freeze the viewer on each update — expect short + hitches when you click a toolbar button or toggle a group during recording. Skip the + sidebar if you have hundreds of groups and the hitches feel too long. + + + Toolbar toggles (bounding box, wireframe, auto-rotate) affect only the current viewer — + persistent defaults live in Settings → Toolbar. + {:else if activeTab === 'Objects'}
diff --git a/webviews/viewer/src/components/popups/SettingsPopup.svelte b/webviews/viewer/src/components/popups/SettingsPopup.svelte index 797bebb..30ec562 100644 --- a/webviews/viewer/src/components/popups/SettingsPopup.svelte +++ b/webviews/viewer/src/components/popups/SettingsPopup.svelte @@ -1,5 +1,12 @@ @@ -332,7 +442,7 @@
- {#if activeTab === 'Mesh edges'} + {#if activeTab === 'Rendering'}
Controls the wireframe edges drawn on every cell of the mesh. For the display of edge @@ -392,6 +502,44 @@ >
{/if} + +
+ +
+
+ Orientation widget + {@render tip( + 'Toggle the XYZ axes indicator in the bottom-right corner of the viewport.' + )} +
+ Show the axes widget in the bottom-right corner. +
+
+ +
+ +
+
+ Dream background + {@render tip( + 'Cosmetic only: animated EDF orange and blue light blobs slowly breathing behind the mesh. Does not affect lighting on the mesh itself.' + )} +
+ Animated colored glows behind the mesh, purely decorative. +
+
{:else if activeTab === 'Groups'}
@@ -564,43 +712,119 @@ >
- {:else if activeTab === 'Display'} -
-
- -
-
- Orientation widget - {@render tip( - 'Toggle the XYZ axes indicator in the bottom-right corner of the viewport.' - )} + {:else if activeTab === 'Toolbar'} +
+ + Defaults for the toolbar at the top of the viewport. Toggling a toolbar button directly + only affects the current view — only changes made here are remembered. + + +
+ Bounding box +
+ +
+
+ Enable bounding box + {@render tip( + 'Show the bounding box by default on every viewer. Toolbar overrides are not persisted.' + )} +
+ Show the axis-aligned bounding box around meshes on load.
- Show the axes widget in the bottom-right corner.
-
- -
-
- Dream background - {@render tip( - 'Cosmetic only: animated EDF orange and blue light blobs slowly breathing behind the mesh. Does not affect lighting on the mesh itself.' - )} +
+ Wireframe +
+ +
+
+ Enable wireframe + {@render tip( + 'Render meshes as wireframes by default. Toolbar overrides are not persisted.' + )} +
+ Render meshes in wireframe mode on load. +
+
+
+ +
+ Auto-rotate +
+ +
+
+ Enable auto-rotate + {@render tip( + 'Start auto-rotate by default on every viewer. Toolbar overrides are not persisted.' + )} +
+ Spin the camera around the scene on load. +
+
+ +
+
+
+ + {@render tip( + 'Default rotation speed in degrees per second. Overrides set from the toolbar popover are not persisted.' + )} +
+ {$settings.autoRotateSpeed}°/s +
+ +
+ +
+ +
+
+ Default reverse auto-rotate + {@render tip( + 'Default rotation direction. When enabled, the view rotates the opposite way by default. Overrides set from the toolbar popover are not persisted.' + )} +
+ Rotate the opposite way by default.
- Animated colored glows behind the mesh, purely decorative.
diff --git a/webviews/viewer/src/components/viewer/AutoRotateButton.svelte b/webviews/viewer/src/components/viewer/AutoRotateButton.svelte new file mode 100644 index 0000000..b4f2e24 --- /dev/null +++ b/webviews/viewer/src/components/viewer/AutoRotateButton.svelte @@ -0,0 +1,100 @@ + + + + +
+ + + {#if popoverOpen} + + {/if} +
diff --git a/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte b/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte index 9a6beb0..90e2c57 100644 --- a/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte +++ b/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte @@ -1,24 +1,17 @@ + + {#if popoverOpen} + + {/if} +
diff --git a/webviews/viewer/src/components/viewer/ScreenshotButton.svelte b/webviews/viewer/src/components/viewer/ScreenshotButton.svelte index 6cc77f7..6d12568 100644 --- a/webviews/viewer/src/components/viewer/ScreenshotButton.svelte +++ b/webviews/viewer/src/components/viewer/ScreenshotButton.svelte @@ -4,12 +4,17 @@ import { VtkApp } from '../../lib/core/VtkApp'; import { Controller } from '../../lib/Controller'; import ScreenshotIcon from '../../icons/ScreenshotIcon.svelte'; + import { openToolbarPopover } from '../../lib/state'; + + const POPOVER_ID = 'screenshot'; let tooltipFile = $state(null); let tooltipClip = $state(null); let visible = $state(false); let flash = $state(false); let capturing = $state(false); + let popoverOpen = $derived($openToolbarPopover === POPOVER_ID); + let wrapper: HTMLDivElement | undefined = $state(); let hideTimer: ReturnType | null = null; function getDreamCanvas(): HTMLCanvasElement | null { @@ -32,25 +37,47 @@ return img; } + /** + * Bounds of the visible viewport excluding the sidebar. The mesh is camera- + * offset by `updateCameraOffset` so it stays centered in this region; when + * we capture without the sidebar overlay, we crop to this region so the + * output is centered on the mesh rather than on the full window. + */ + function getContentBounds() { + const vw = document.body.offsetWidth; + const vh = document.body.offsetHeight; + const controls = document.getElementById('controls'); + if (!controls) return { x: 0, y: 0, w: vw, h: vh, vw, vh }; + const rect = controls.getBoundingClientRect(); + const sidebarOnLeft = rect.left < vw - rect.right; + return { + x: sidebarOnLeft ? rect.width : 0, + y: 0, + w: Math.max(0, vw - rect.width), + h: vh, + vw, + vh, + }; + } + async function canvasOnlyBlob(): Promise { const vtkImg = await captureVtkImage(); const dream = getDreamCanvas(); const dpr = window.devicePixelRatio || 1; - const w = document.body.offsetWidth; - const h = document.body.offsetHeight; + const bounds = getContentBounds(); const composite = document.createElement('canvas'); - composite.width = w * dpr; - composite.height = h * dpr; + composite.width = bounds.w * dpr; + composite.height = bounds.h * dpr; const ctx = composite.getContext('2d'); if (!ctx) return null; ctx.scale(dpr, dpr); if (dream) { - ctx.drawImage(dream, 0, 0, w, h); + ctx.drawImage(dream, -bounds.x, -bounds.y, bounds.vw, bounds.vh); } if (vtkImg) { - ctx.drawImage(vtkImg, 0, 0, w, h); + ctx.drawImage(vtkImg, -bounds.x, -bounds.y, bounds.vw, bounds.vh); } return new Promise((r) => composite.toBlob(r, 'image/png')); @@ -134,8 +161,13 @@ await send(await canvasOnlyBlob()); } - async function onContextMenu(e: MouseEvent) { + function onContextMenu(e: MouseEvent) { e.preventDefault(); + openToolbarPopover.set(popoverOpen ? null : POPOVER_ID); + } + + async function captureFullWebview() { + openToolbarPopover.set(null); // Strip hover styles from the button itself so the full-viewer capture // doesn't bake in the highlight produced by the right-click hover. capturing = true; @@ -147,34 +179,61 @@ } } - let showHoverTip = $derived(!tooltipFile); + function onDocClick(e: MouseEvent) { + if (!popoverOpen) return; + if (wrapper && !wrapper.contains(e.target as Node)) { + openToolbarPopover.set(null); + } + } + + let showHoverTip = $derived(!tooltipFile && !popoverOpen); - + + {#if popoverOpen} {/if} - +
diff --git a/webviews/viewer/src/components/viewer/WireframeButton.svelte b/webviews/viewer/src/components/viewer/WireframeButton.svelte index 607e43c..858233b 100644 --- a/webviews/viewer/src/components/viewer/WireframeButton.svelte +++ b/webviews/viewer/src/components/viewer/WireframeButton.svelte @@ -1,24 +1,17 @@