From 6597f4a7109643080efc723664ed4b29e5e2f30f Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Thu, 16 Apr 2026 09:58:50 +0200 Subject: [PATCH 1/8] feat: add toggleable bounding box with colored axes and dimension labels Show the characteristic dimensions of the loaded meshes at a glance. A top toolbar button toggles a wireframe cube whose edges are colored per axis (X red, Y green, Z blue), with corner dots, a "0" origin marker, and X/Y/Z dimension labels anchored in 3D via vtkPixelSpaceCallbackMapper. The setting persists across sessions. --- package.json | 6 + src/WebviewVisu.ts | 2 + webviews/viewer/index.html | 2 +- .../viewer/src/components/layout/App.svelte | 6 + .../src/components/layout/TopToolbar.svelte | 10 + .../viewer/BoundingBoxButton.svelte | 28 ++ .../viewer/BoundingBoxLabels.svelte | 45 +++ .../viewer/src/icons/BoundingBoxIcon.svelte | 20 ++ webviews/viewer/src/lib/Controller.ts | 1 + webviews/viewer/src/lib/core/VtkApp.ts | 23 +- .../src/lib/interaction/CameraManager.ts | 259 +++++++++++++++++- .../viewer/src/lib/settings/GlobalSettings.ts | 1 + webviews/viewer/src/lib/state.ts | 5 + webviews/viewer/src/main.ts | 6 + 14 files changed, 407 insertions(+), 7 deletions(-) create mode 100644 webviews/viewer/src/components/layout/TopToolbar.svelte create mode 100644 webviews/viewer/src/components/viewer/BoundingBoxButton.svelte create mode 100644 webviews/viewer/src/components/viewer/BoundingBoxLabels.svelte create mode 100644 webviews/viewer/src/icons/BoundingBoxIcon.svelte diff --git a/package.json b/package.json index deb8c15..7935142 100644 --- a/package.json +++ b/package.json @@ -274,6 +274,12 @@ "default": true, "markdownDescription": "Show the orientation axes widget in the bottom-right corner of the viewer." }, + "vs-code-aster.viewer.showBoundingBox": { + "order": 17, + "type": "boolean", + "default": false, + "markdownDescription": "Show a bounding box with X/Y/Z tick marks and values around the loaded meshes in the viewer." + }, "vs-code-aster.enableTelemetry": { "order": 100, "type": "boolean", diff --git a/src/WebviewVisu.ts b/src/WebviewVisu.ts index 4322eb0..8e52a65 100644 --- a/src/WebviewVisu.ts +++ b/src/WebviewVisu.ts @@ -119,6 +119,7 @@ export class WebviewVisu implements vscode.Disposable { 'edgeMode', 'groupTransparency', 'showOrientationWidget', + 'showBoundingBox', ]; for (const key of settingKeys) { if (e.settings[key] !== undefined) { @@ -178,6 +179,7 @@ export class WebviewVisu implements vscode.Disposable { edgeMode: config.get('viewer.edgeMode', 'threshold'), groupTransparency: config.get('viewer.groupTransparency', 0.2), showOrientationWidget: config.get('viewer.showOrientationWidget', true), + showBoundingBox: config.get('viewer.showBoundingBox', false), }; this.panel.webview.postMessage({ type: 'init', diff --git a/webviews/viewer/index.html b/webviews/viewer/index.html index 318a9bc..8782a87 100644 --- a/webviews/viewer/index.html +++ b/webviews/viewer/index.html @@ -6,7 +6,7 @@ MeshViewer - +
diff --git a/webviews/viewer/src/components/layout/App.svelte b/webviews/viewer/src/components/layout/App.svelte index e689b8d..3d03e61 100644 --- a/webviews/viewer/src/components/layout/App.svelte +++ b/webviews/viewer/src/components/layout/App.svelte @@ -2,7 +2,9 @@ import { groupHierarchy } from '../../lib/state'; import Sidebar from './Sidebar.svelte'; import TopActions from './TopActions.svelte'; + import TopToolbar from './TopToolbar.svelte'; import ZoomWidget from '../viewer/ZoomWidget.svelte'; + import BoundingBoxLabels from '../viewer/BoundingBoxLabels.svelte'; import Popup from '../popups/Popup.svelte'; import HelpPopup from '../popups/HelpPopup.svelte'; import SettingsPopup from '../popups/SettingsPopup.svelte'; @@ -35,7 +37,11 @@ }} /> + + + + {/if} {#if openPopup} diff --git a/webviews/viewer/src/components/layout/TopToolbar.svelte b/webviews/viewer/src/components/layout/TopToolbar.svelte new file mode 100644 index 0000000..347ddbb --- /dev/null +++ b/webviews/viewer/src/components/layout/TopToolbar.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte b/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte new file mode 100644 index 0000000..e5140aa --- /dev/null +++ b/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte @@ -0,0 +1,28 @@ + + + diff --git a/webviews/viewer/src/components/viewer/BoundingBoxLabels.svelte b/webviews/viewer/src/components/viewer/BoundingBoxLabels.svelte new file mode 100644 index 0000000..6e6bf95 --- /dev/null +++ b/webviews/viewer/src/components/viewer/BoundingBoxLabels.svelte @@ -0,0 +1,45 @@ + + +{#if dims} +
+ 0 +
+
+ X: {fmt(dims.x)} +
+
+ Y: {fmt(dims.y)} +
+
+ Z: {fmt(dims.z)} +
+{/if} diff --git a/webviews/viewer/src/icons/BoundingBoxIcon.svelte b/webviews/viewer/src/icons/BoundingBoxIcon.svelte new file mode 100644 index 0000000..e2695f0 --- /dev/null +++ b/webviews/viewer/src/icons/BoundingBoxIcon.svelte @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/webviews/viewer/src/lib/Controller.ts b/webviews/viewer/src/lib/Controller.ts index 22daf27..233960b 100644 --- a/webviews/viewer/src/lib/Controller.ts +++ b/webviews/viewer/src/lib/Controller.ts @@ -88,6 +88,7 @@ export class Controller { for (const group of Object.values(this._groups)) { group.applyThemeColor(); } + CameraManager.Instance.refreshBoundingBoxTheme(); } getGroupNames(): string[] { diff --git a/webviews/viewer/src/lib/core/VtkApp.ts b/webviews/viewer/src/lib/core/VtkApp.ts index 9337d6c..6256f4d 100644 --- a/webviews/viewer/src/lib/core/VtkApp.ts +++ b/webviews/viewer/src/lib/core/VtkApp.ts @@ -14,9 +14,15 @@ export class VtkApp { } private _readEditorBackground(): number[] { - const raw = getComputedStyle(document.body) - .getPropertyValue('--vscode-editor-background') - .trim(); + return this._readVscodeColor('--vscode-editor-background', [0.4, 0.6, 1.0]); + } + + readEditorForeground(): number[] { + return this._readVscodeColor('--vscode-editor-foreground', [0.85, 0.85, 0.85]); + } + + private _readVscodeColor(variable: string, fallback: number[]): number[] { + const raw = getComputedStyle(document.body).getPropertyValue(variable).trim(); const match = raw.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); if (match) { return [ @@ -25,7 +31,7 @@ export class VtkApp { parseInt(match[3], 16) / 255, ]; } - return [0.4, 0.6, 1.0]; + return fallback; } updateBackground(): void { @@ -74,9 +80,16 @@ export class VtkApp { this.renderer.getActiveCamera().setWindowCenter(-offset, 0); this.renderWindow.render(); + const centerX = sidebarWidth + (window.innerWidth - sidebarWidth) / 2; + const zoomWidget = document.getElementById('zoomWidget'); if (zoomWidget) { - zoomWidget.style.left = `${sidebarWidth + (window.innerWidth - sidebarWidth) / 2}px`; + zoomWidget.style.left = `${centerX}px`; + } + + const topToolbar = document.getElementById('topToolbar'); + if (topToolbar) { + topToolbar.style.left = `${centerX}px`; } } diff --git a/webviews/viewer/src/lib/interaction/CameraManager.ts b/webviews/viewer/src/lib/interaction/CameraManager.ts index 184f651..0c66083 100644 --- a/webviews/viewer/src/lib/interaction/CameraManager.ts +++ b/webviews/viewer/src/lib/interaction/CameraManager.ts @@ -1,6 +1,7 @@ import { VtkApp } from '../core/VtkApp'; import { AxesCreator } from './AxesCreator'; -import { zoomRatio, isAtDefaultZoom } from '../state'; +import { GlobalSettings } from '../settings/GlobalSettings'; +import { zoomRatio, isAtDefaultZoom, boundingBoxDimensions } from '../state'; import type { Group } from '../data/Group'; export class CameraManager { @@ -11,6 +12,8 @@ export class CameraManager { nodesGroups: Record = {}; faceGroups: Record = {}; private orientationWidget: any; + private boundingBoxActors: any[] = []; + private boundingBoxDotsActor: any = null; static get Instance(): CameraManager { if (!this._i) { @@ -40,6 +43,7 @@ export class CameraManager { this._updateZoomIndicator(this.initialDistance); this.createAxisMarker(); + this.createBoundingBox(groups); this.activateSizeUpdate(); } @@ -107,6 +111,17 @@ export class CameraManager { VtkApp.Instance.getRenderWindow().render(); } + setBoundingBoxVisible(visible: boolean): void { + for (const a of this.boundingBoxActors) a.setVisibility(visible); + VtkApp.Instance.getRenderWindow().render(); + } + + refreshBoundingBoxTheme(): void { + if (!this.boundingBoxDotsActor) return; + const c = VtkApp.Instance.readEditorForeground(); + this.boundingBoxDotsActor.getProperty().setColor(c[0], c[1], c[2]); + } + setCameraAxis(axis: string): void { if (!this.camera) { return; @@ -155,4 +170,246 @@ export class CameraManager { this.orientationWidget = widget; return axes; } + + private static readonly AXIS_COLORS = { + x: [0.937, 0.267, 0.267], + y: [0.133, 0.773, 0.369], + z: [0.231, 0.51, 0.965], + }; + + private createBoundingBox(groups: Record): void { + let xmin = Infinity; + let xmax = -Infinity; + let ymin = Infinity; + let ymax = -Infinity; + let zmin = Infinity; + let zmax = -Infinity; + + for (const group of Object.values(groups)) { + if (!group.isFaceGroup || group.fileGroup !== null) continue; + const b = group.actor.getBounds(); + if (b[0] < xmin) xmin = b[0]; + if (b[1] > xmax) xmax = b[1]; + if (b[2] < ymin) ymin = b[2]; + if (b[3] > ymax) ymax = b[3]; + if (b[4] < zmin) zmin = b[4]; + if (b[5] > zmax) zmax = b[5]; + } + + if (!isFinite(xmin)) return; + + const bounds = [xmin, xmax, ymin, ymax, zmin, zmax]; + boundingBoxDimensions.set({ x: xmax - xmin, y: ymax - ymin, z: zmax - zmin }); + + const renderer = VtkApp.Instance.getRenderer(); + const initiallyVisible = GlobalSettings.Instance.showBoundingBox; + + for (const a of this.boundingBoxActors) renderer.removeActor(a); + this.boundingBoxActors = []; + + const register = (actor: any) => { + renderer.addActor(actor); + actor.setVisibility(initiallyVisible); + this.boundingBoxActors.push(actor); + }; + + register(this.createAxisLines(bounds, 'x', CameraManager.AXIS_COLORS.x)); + register(this.createAxisLines(bounds, 'y', CameraManager.AXIS_COLORS.y)); + register(this.createAxisLines(bounds, 'z', CameraManager.AXIS_COLORS.z)); + + const dots = this.createBoundingBoxDots(bounds); + if (dots) { + register(dots); + this.boundingBoxDotsActor = dots; + } + + const labels = this.createBoundingBoxLabelActor(bounds); + if (labels) register(labels); + } + + private createAxisLines(bounds: number[], axis: 'x' | 'y' | 'z', color: number[]): any { + const [xmin, xmax, ymin, ymax, zmin, zmax] = bounds; + let edges: number[][][]; + if (axis === 'x') { + edges = [ + [ + [xmin, ymin, zmin], + [xmax, ymin, zmin], + ], + [ + [xmin, ymax, zmin], + [xmax, ymax, zmin], + ], + [ + [xmin, ymin, zmax], + [xmax, ymin, zmax], + ], + [ + [xmin, ymax, zmax], + [xmax, ymax, zmax], + ], + ]; + } else if (axis === 'y') { + edges = [ + [ + [xmin, ymin, zmin], + [xmin, ymax, zmin], + ], + [ + [xmax, ymin, zmin], + [xmax, ymax, zmin], + ], + [ + [xmin, ymin, zmax], + [xmin, ymax, zmax], + ], + [ + [xmax, ymin, zmax], + [xmax, ymax, zmax], + ], + ]; + } else { + edges = [ + [ + [xmin, ymin, zmin], + [xmin, ymin, zmax], + ], + [ + [xmax, ymin, zmin], + [xmax, ymin, zmax], + ], + [ + [xmin, ymax, zmin], + [xmin, ymax, zmax], + ], + [ + [xmax, ymax, zmin], + [xmax, ymax, zmax], + ], + ]; + } + + const points = new Float64Array(edges.length * 6); + const lines = new Uint32Array(edges.length * 3); + edges.forEach(([p1, p2], i) => { + points[i * 6] = p1[0]; + points[i * 6 + 1] = p1[1]; + points[i * 6 + 2] = p1[2]; + points[i * 6 + 3] = p2[0]; + points[i * 6 + 4] = p2[1]; + points[i * 6 + 5] = p2[2]; + lines[i * 3] = 2; + lines[i * 3 + 1] = i * 2; + lines[i * 3 + 2] = i * 2 + 1; + }); + + const polyData = vtk.Common.DataModel.vtkPolyData.newInstance(); + polyData.getPoints().setData(points, 3); + polyData.getLines().setData(lines, 1); + + const mapper = vtk.Rendering.Core.vtkMapper.newInstance(); + mapper.setInputData(polyData); + const actor = vtk.Rendering.Core.vtkActor.newInstance(); + actor.setMapper(mapper); + actor.getProperty().setColor(color[0], color[1], color[2]); + actor.getProperty().setLineWidth(2); + actor.getProperty().setLighting(false); + actor.setPickable(false); + return actor; + } + + private createBoundingBoxDots(bounds: number[]): any { + const [xmin, xmax, ymin, ymax, zmin, zmax] = bounds; + const dx = xmax - xmin; + const dy = ymax - ymin; + const dz = zmax - zmin; + const diagonal = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (diagonal < 1e-9) return null; + const radius = diagonal * 0.006; + + const corners: number[][] = [ + [xmin, ymin, zmin], + [xmax, ymin, zmin], + [xmin, ymax, zmin], + [xmax, ymax, zmin], + [xmin, ymin, zmax], + [xmax, ymin, zmax], + [xmin, ymax, zmax], + [xmax, ymax, zmax], + ]; + + const append = vtk.Filters.General.vtkAppendPolyData.newInstance(); + corners.forEach((center, i) => { + const sphere = vtk.Filters.Sources.vtkSphereSource.newInstance({ + center, + radius, + thetaResolution: 16, + phiResolution: 16, + }); + if (i === 0) append.setInputData(sphere.getOutputData()); + else append.addInputData(sphere.getOutputData()); + }); + + const mapper = vtk.Rendering.Core.vtkMapper.newInstance(); + mapper.setInputData(append.getOutputData()); + const actor = vtk.Rendering.Core.vtkActor.newInstance(); + actor.setMapper(mapper); + const c = VtkApp.Instance.readEditorForeground(); + actor.getProperty().setColor(c[0], c[1], c[2]); + actor.getProperty().setLighting(false); + actor.setPickable(false); + return actor; + } + + private createBoundingBoxLabelActor(bounds: number[]): any { + const [xmin, xmax, ymin, ymax, zmin, zmax] = bounds; + const dx = Math.max(xmax - xmin, 1e-6); + const dy = Math.max(ymax - ymin, 1e-6); + const dz = Math.max(zmax - zmin, 1e-6); + const m = 0.08; + const c = 0.03; + const anchors = [ + [xmin - 0.04 * dx, ymin - 0.04 * dy, zmin - 0.04 * dz], + [xmax + m * dx, ymin - c * dy, zmin - c * dz], + [xmin - c * dx, ymax + m * dy, zmin - c * dz], + [xmin - c * dx, ymin - c * dy, zmax + m * dz], + ]; + + const pts = new Float64Array(anchors.length * 3); + anchors.forEach((p, i) => { + pts[i * 3] = p[0]; + pts[i * 3 + 1] = p[1]; + pts[i * 3 + 2] = p[2]; + }); + const polyData = vtk.Common.DataModel.vtkPolyData.newInstance(); + polyData.getPoints().setData(pts, 3); + + const mapper = vtk.Rendering.Core.vtkPixelSpaceCallbackMapper.newInstance(); + mapper.setInputData(polyData); + mapper.setCallback( + ( + coordsList: number[][], + _camera: unknown, + _aspect: unknown, + _zBuf: unknown, + viewportSize: number[] + ) => { + const dpr = window.devicePixelRatio || 1; + const hCanvas = viewportSize ? viewportSize[1] : window.innerHeight * dpr; + const ids = ['bboxLabelOrigin', 'bboxLabelX', 'bboxLabelY', 'bboxLabelZ']; + for (let i = 0; i < coordsList.length && i < ids.length; i++) { + const el = document.getElementById(ids[i]); + if (el) { + el.style.left = `${coordsList[i][0] / dpr}px`; + el.style.top = `${(hCanvas - coordsList[i][1]) / dpr}px`; + } + } + } + ); + + const actor = vtk.Rendering.Core.vtkActor.newInstance(); + actor.setMapper(mapper); + actor.setPickable(false); + return actor; + } } diff --git a/webviews/viewer/src/lib/settings/GlobalSettings.ts b/webviews/viewer/src/lib/settings/GlobalSettings.ts index 8c37640..e266077 100644 --- a/webviews/viewer/src/lib/settings/GlobalSettings.ts +++ b/webviews/viewer/src/lib/settings/GlobalSettings.ts @@ -41,6 +41,7 @@ export class GlobalSettings { edgeOpacity = 0.7; groupTransparency = 0.2; showOrientationWidget = true; + showBoundingBox = false; get isDark(): boolean { return ( diff --git a/webviews/viewer/src/lib/state.ts b/webviews/viewer/src/lib/state.ts index 7ad02bd..b16e53f 100644 --- a/webviews/viewer/src/lib/state.ts +++ b/webviews/viewer/src/lib/state.ts @@ -14,6 +14,7 @@ export interface Settings { edgeThresholdMultiplier: number; groupTransparency: number; showOrientationWidget: boolean; + showBoundingBox: boolean; } export const groupHierarchy = writable({}); @@ -27,6 +28,7 @@ export const settings = writable({ edgeThresholdMultiplier: 1, groupTransparency: 0.2, showOrientationWidget: true, + showBoundingBox: false, }); // Map> — groups NOT shown in sidebar (hidden) @@ -35,3 +37,6 @@ export const sidebarHiddenGroups = writable>>(new Map()) export const loadingProgress = tweened(0, { duration: 300, easing: cubicOut }); export const loadingMessage = writable(''); export const errorMessage = writable(''); + +export type BoundingBoxDimensions = { x: number; y: number; z: number } | null; +export const boundingBoxDimensions = writable(null); diff --git a/webviews/viewer/src/main.ts b/webviews/viewer/src/main.ts index 2306025..87fb4c4 100644 --- a/webviews/viewer/src/main.ts +++ b/webviews/viewer/src/main.ts @@ -38,18 +38,24 @@ window.addEventListener('message', async (e) => { GlobalSettings.Instance.groupTransparency = s.groupTransparency; if (s.showOrientationWidget !== undefined) GlobalSettings.Instance.showOrientationWidget = s.showOrientationWidget; + if (s.showBoundingBox !== undefined) + GlobalSettings.Instance.showBoundingBox = s.showBoundingBox; settings.update((cur) => ({ hiddenObjectOpacity: s.hiddenObjectOpacity ?? cur.hiddenObjectOpacity, edgeMode: (s.edgeMode ?? cur.edgeMode) as EdgeMode, edgeThresholdMultiplier: s.edgeThresholdMultiplier ?? cur.edgeThresholdMultiplier, groupTransparency: s.groupTransparency ?? cur.groupTransparency, showOrientationWidget: s.showOrientationWidget ?? cur.showOrientationWidget, + showBoundingBox: s.showBoundingBox ?? cur.showBoundingBox, })); VisibilityManager.Instance.applyHiddenObjectOpacity(); CameraManager.Instance.refreshEdgeVisibility(); if (s.showOrientationWidget === false) { CameraManager.Instance.setOrientationWidgetVisible(false); } + if (s.showBoundingBox === true) { + CameraManager.Instance.setBoundingBoxVisible(true); + } } break; } From 94d95f14915cdb194e0f4bcfa185dfbb262099a3 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Thu, 16 Apr 2026 10:20:09 +0200 Subject: [PATCH 2/8] feat: add wireframe rendering toggle to the top toolbar Quick-toggle between solid surface and wireframe representation for inspecting mesh density without navigating to the edge settings. The mode is re-applied on mesh load so it persists across reloads. --- package.json | 6 ++++ src/WebviewVisu.ts | 2 ++ .../src/components/layout/TopToolbar.svelte | 3 ++ .../components/viewer/WireframeButton.svelte | 28 +++++++++++++++++++ .../viewer/src/icons/WireframeIcon.svelte | 18 ++++++++++++ .../src/lib/interaction/CameraManager.ts | 12 ++++++++ .../viewer/src/lib/settings/GlobalSettings.ts | 1 + webviews/viewer/src/lib/state.ts | 2 ++ webviews/viewer/src/main.ts | 5 ++++ 9 files changed, 77 insertions(+) create mode 100644 webviews/viewer/src/components/viewer/WireframeButton.svelte create mode 100644 webviews/viewer/src/icons/WireframeIcon.svelte diff --git a/package.json b/package.json index 7935142..071758e 100644 --- a/package.json +++ b/package.json @@ -280,6 +280,12 @@ "default": false, "markdownDescription": "Show a bounding box with X/Y/Z tick marks and values around the loaded meshes in the viewer." }, + "vs-code-aster.viewer.showWireframe": { + "order": 18, + "type": "boolean", + "default": false, + "markdownDescription": "Render meshes in wireframe mode instead of solid surfaces." + }, "vs-code-aster.enableTelemetry": { "order": 100, "type": "boolean", diff --git a/src/WebviewVisu.ts b/src/WebviewVisu.ts index 8e52a65..a7f5ce8 100644 --- a/src/WebviewVisu.ts +++ b/src/WebviewVisu.ts @@ -120,6 +120,7 @@ export class WebviewVisu implements vscode.Disposable { 'groupTransparency', 'showOrientationWidget', 'showBoundingBox', + 'showWireframe', ]; for (const key of settingKeys) { if (e.settings[key] !== undefined) { @@ -180,6 +181,7 @@ export class WebviewVisu implements vscode.Disposable { groupTransparency: config.get('viewer.groupTransparency', 0.2), showOrientationWidget: config.get('viewer.showOrientationWidget', true), showBoundingBox: config.get('viewer.showBoundingBox', false), + showWireframe: config.get('viewer.showWireframe', false), }; this.panel.webview.postMessage({ type: 'init', diff --git a/webviews/viewer/src/components/layout/TopToolbar.svelte b/webviews/viewer/src/components/layout/TopToolbar.svelte index 347ddbb..68acf3f 100644 --- a/webviews/viewer/src/components/layout/TopToolbar.svelte +++ b/webviews/viewer/src/components/layout/TopToolbar.svelte @@ -1,5 +1,6 @@
+
+
diff --git a/webviews/viewer/src/components/viewer/WireframeButton.svelte b/webviews/viewer/src/components/viewer/WireframeButton.svelte new file mode 100644 index 0000000..7955e53 --- /dev/null +++ b/webviews/viewer/src/components/viewer/WireframeButton.svelte @@ -0,0 +1,28 @@ + + + diff --git a/webviews/viewer/src/icons/WireframeIcon.svelte b/webviews/viewer/src/icons/WireframeIcon.svelte new file mode 100644 index 0000000..a917468 --- /dev/null +++ b/webviews/viewer/src/icons/WireframeIcon.svelte @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/webviews/viewer/src/lib/interaction/CameraManager.ts b/webviews/viewer/src/lib/interaction/CameraManager.ts index 0c66083..c2a4965 100644 --- a/webviews/viewer/src/lib/interaction/CameraManager.ts +++ b/webviews/viewer/src/lib/interaction/CameraManager.ts @@ -45,6 +45,10 @@ export class CameraManager { this.createAxisMarker(); this.createBoundingBox(groups); this.activateSizeUpdate(); + + if (GlobalSettings.Instance.showWireframe) { + this.setWireframeMode(true); + } } private activateSizeUpdate(): void { @@ -122,6 +126,14 @@ export class CameraManager { this.boundingBoxDotsActor.getProperty().setColor(c[0], c[1], c[2]); } + setWireframeMode(wireframe: boolean): void { + const rep = wireframe ? 1 : 2; + for (const group of Object.values(this.faceGroups)) { + group.actor.getProperty().setRepresentation(rep); + } + VtkApp.Instance.getRenderWindow().render(); + } + setCameraAxis(axis: string): void { if (!this.camera) { return; diff --git a/webviews/viewer/src/lib/settings/GlobalSettings.ts b/webviews/viewer/src/lib/settings/GlobalSettings.ts index e266077..1a5dc65 100644 --- a/webviews/viewer/src/lib/settings/GlobalSettings.ts +++ b/webviews/viewer/src/lib/settings/GlobalSettings.ts @@ -42,6 +42,7 @@ export class GlobalSettings { groupTransparency = 0.2; showOrientationWidget = true; showBoundingBox = false; + showWireframe = false; get isDark(): boolean { return ( diff --git a/webviews/viewer/src/lib/state.ts b/webviews/viewer/src/lib/state.ts index b16e53f..a125489 100644 --- a/webviews/viewer/src/lib/state.ts +++ b/webviews/viewer/src/lib/state.ts @@ -15,6 +15,7 @@ export interface Settings { groupTransparency: number; showOrientationWidget: boolean; showBoundingBox: boolean; + showWireframe: boolean; } export const groupHierarchy = writable({}); @@ -29,6 +30,7 @@ export const settings = writable({ groupTransparency: 0.2, showOrientationWidget: true, showBoundingBox: false, + showWireframe: false, }); // Map> — groups NOT shown in sidebar (hidden) diff --git a/webviews/viewer/src/main.ts b/webviews/viewer/src/main.ts index 87fb4c4..3059c48 100644 --- a/webviews/viewer/src/main.ts +++ b/webviews/viewer/src/main.ts @@ -40,6 +40,7 @@ window.addEventListener('message', async (e) => { GlobalSettings.Instance.showOrientationWidget = s.showOrientationWidget; if (s.showBoundingBox !== undefined) GlobalSettings.Instance.showBoundingBox = s.showBoundingBox; + if (s.showWireframe !== undefined) GlobalSettings.Instance.showWireframe = s.showWireframe; settings.update((cur) => ({ hiddenObjectOpacity: s.hiddenObjectOpacity ?? cur.hiddenObjectOpacity, edgeMode: (s.edgeMode ?? cur.edgeMode) as EdgeMode, @@ -47,6 +48,7 @@ window.addEventListener('message', async (e) => { groupTransparency: s.groupTransparency ?? cur.groupTransparency, showOrientationWidget: s.showOrientationWidget ?? cur.showOrientationWidget, showBoundingBox: s.showBoundingBox ?? cur.showBoundingBox, + showWireframe: s.showWireframe ?? cur.showWireframe, })); VisibilityManager.Instance.applyHiddenObjectOpacity(); CameraManager.Instance.refreshEdgeVisibility(); @@ -56,6 +58,9 @@ window.addEventListener('message', async (e) => { if (s.showBoundingBox === true) { CameraManager.Instance.setBoundingBoxVisible(true); } + if (s.showWireframe === true) { + CameraManager.Instance.setWireframeMode(true); + } } break; } From 5d45693c2dfe6c8829bcf59fda3293540698f4d1 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Thu, 16 Apr 2026 12:08:13 +0200 Subject: [PATCH 3/8] feat: add screenshot button to top toolbar Saves a PNG next to the source file and copies it to clipboard. Left click captures the 3D canvas; right click composites the full webview (canvas + UI overlays via html-to-image). A slide-in tooltip confirms the filename and clipboard copy. --- package-lock.json | 8 ++ package.json | 13 +- src/MedEditorProvider.ts | 1 + src/VisuManager.ts | 1 + src/WebviewVisu.ts | 10 ++ .../src/components/layout/TopToolbar.svelte | 5 +- .../components/viewer/ScreenshotButton.svelte | 132 ++++++++++++++++++ .../viewer/src/icons/ScreenshotIcon.svelte | 17 +++ 8 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 webviews/viewer/src/components/viewer/ScreenshotButton.svelte create mode 100644 webviews/viewer/src/icons/ScreenshotIcon.svelte diff --git a/package-lock.json b/package-lock.json index 1b1a5ff..2cfc0ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@vscode/test-electron": "^2.5.2", "esbuild": "^0.25.5", "eslint": "^9.25.1", + "html-to-image": "^1.11.13", "husky": "^9.1.7", "npm-run-all": "^4.1.5", "prettier": "^3.8.1", @@ -3433,6 +3434,13 @@ "dev": true, "license": "ISC" }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", diff --git a/package.json b/package.json index 071758e..5400414 100644 --- a/package.json +++ b/package.json @@ -138,9 +138,15 @@ "viewType": "vs-code-aster.medViewer", "displayName": "MED Mesh Viewer", "selector": [ - { "filenamePattern": "*.med" }, - { "filenamePattern": "*.mmed" }, - { "filenamePattern": "*.rmed" } + { + "filenamePattern": "*.med" + }, + { + "filenamePattern": "*.mmed" + }, + { + "filenamePattern": "*.rmed" + } ], "priority": "default" } @@ -318,6 +324,7 @@ "@vscode/test-electron": "^2.5.2", "esbuild": "^0.25.5", "eslint": "^9.25.1", + "html-to-image": "^1.11.13", "husky": "^9.1.7", "npm-run-all": "^4.1.5", "prettier": "^3.8.1", diff --git a/src/MedEditorProvider.ts b/src/MedEditorProvider.ts index 4f072d5..fe026db 100644 --- a/src/MedEditorProvider.ts +++ b/src/MedEditorProvider.ts @@ -76,6 +76,7 @@ export class MedEditorProvider implements vscode.CustomReadonlyEditorProvider { this.activeViewers.delete(medPath); diff --git a/src/VisuManager.ts b/src/VisuManager.ts index 94a0cba..9aad1b9 100644 --- a/src/VisuManager.ts +++ b/src/VisuManager.ts @@ -128,6 +128,7 @@ export class VisuManager { commName ); + visu.sourceDir = path.dirname(commUri.fsPath); this.views.set(key, { commUri, objUris, visu }); // Send telemetry once per opening of this .comm (non-blocking). diff --git a/src/WebviewVisu.ts b/src/WebviewVisu.ts index a7f5ce8..b7c28fe 100644 --- a/src/WebviewVisu.ts +++ b/src/WebviewVisu.ts @@ -14,6 +14,7 @@ export class WebviewVisu implements vscode.Disposable { private readyReceived = false; private deferredInit?: { fileContexts: string[]; objFilenames: string[] }; + public sourceDir?: string; public get webview(): vscode.Webview { return this.panel.webview; @@ -128,6 +129,15 @@ export class WebviewVisu implements vscode.Disposable { } } break; + case 'saveScreenshot': { + if (this.sourceDir) { + const base64 = (e.dataUrl as string).replace(/^data:image\/png;base64,/, ''); + const buffer = Buffer.from(base64, 'base64'); + const filePath = path.join(this.sourceDir, 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); diff --git a/webviews/viewer/src/components/layout/TopToolbar.svelte b/webviews/viewer/src/components/layout/TopToolbar.svelte index 68acf3f..9ef07eb 100644 --- a/webviews/viewer/src/components/layout/TopToolbar.svelte +++ b/webviews/viewer/src/components/layout/TopToolbar.svelte @@ -1,13 +1,16 @@
+
+
diff --git a/webviews/viewer/src/components/viewer/ScreenshotButton.svelte b/webviews/viewer/src/components/viewer/ScreenshotButton.svelte new file mode 100644 index 0000000..42dc9e1 --- /dev/null +++ b/webviews/viewer/src/components/viewer/ScreenshotButton.svelte @@ -0,0 +1,132 @@ + + + diff --git a/webviews/viewer/src/icons/ScreenshotIcon.svelte b/webviews/viewer/src/icons/ScreenshotIcon.svelte new file mode 100644 index 0000000..d22129a --- /dev/null +++ b/webviews/viewer/src/icons/ScreenshotIcon.svelte @@ -0,0 +1,17 @@ + + + + + + + From b7ac6a94c9cf4f8e620b0cfd327aad6a559f1ce4 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Thu, 16 Apr 2026 12:08:38 +0200 Subject: [PATCH 4/8] fix: unify toolbar tooltips and fix sidebar tooltip z-order Replace native title attributes on toolbar buttons with the same group-hover inline tooltip pattern used elsewhere in the UI. Raise the sidebar stacking context (z-20) so its filter/clear tooltips render above the top toolbar. --- webviews/viewer/src/components/layout/Sidebar.svelte | 2 +- .../viewer/src/components/sidebar/ActionButtons.svelte | 4 ++-- .../viewer/src/components/viewer/BoundingBoxButton.svelte | 8 ++++++-- .../viewer/src/components/viewer/WireframeButton.svelte | 8 ++++++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/webviews/viewer/src/components/layout/Sidebar.svelte b/webviews/viewer/src/components/layout/Sidebar.svelte index a07090e..670e236 100644 --- a/webviews/viewer/src/components/layout/Sidebar.svelte +++ b/webviews/viewer/src/components/layout/Sidebar.svelte @@ -13,7 +13,7 @@ }); -
+
{#each Object.entries($groupHierarchy) as [key, data]} @@ -36,7 +36,7 @@ diff --git a/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte b/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte index e5140aa..9a6beb0 100644 --- a/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte +++ b/webviews/viewer/src/components/viewer/BoundingBoxButton.svelte @@ -18,11 +18,15 @@ diff --git a/webviews/viewer/src/components/viewer/WireframeButton.svelte b/webviews/viewer/src/components/viewer/WireframeButton.svelte index 7955e53..607e43c 100644 --- a/webviews/viewer/src/components/viewer/WireframeButton.svelte +++ b/webviews/viewer/src/components/viewer/WireframeButton.svelte @@ -18,11 +18,15 @@ From 05771c53b6c09c7d23e21b3ff50c81a3b6d6874a Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Thu, 16 Apr 2026 14:02:42 +0200 Subject: [PATCH 5/8] feat: document features added since 1.5.0 Update the README with diagnostics, terminal reuse, direct .med opening, and the new viewer toolbar (bounding box, wireframe, screenshot). Add a Toolbar tab in the viewer help popup with icon mockups and descriptions for each toolbar feature. --- README.md | 22 ++++++---- .../src/components/popups/HelpPopup.svelte | 41 ++++++++++++++++++- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 76bf4ba..49c515e 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,11 @@ There are two ways to open the form : 1. Open a `.export` file. 2. Click on the "play" icon `Run with code_aster` in the top-right corner of the file. -It will open a terminal and execute the following command : `cave run [file].export`. +It will open a terminal and execute the following command : `cave run [file].export`. Subsequent runs reuse the same terminal. + +**Diagnostics** + +Warnings (``), errors (``, ``), Python tracebacks, and fatal errors from code_aster automatically appear in the VS Code **Problems panel** after a run — no `F mess` entry required in the `.export` file. Diagnostics are attached to the originating `.comm` line when possible and cleared between runs. **Personnalize alias to run code-aster** @@ -184,19 +188,20 @@ It’s powered by **VTK.js**, and supports both mesh visualization and node-base #### Opening the visualizer -The visualizer is very easy to open : - -1. Open a `.comm` file. -2. Click on the "eye" icon `Open visualizer` in the top-right corner of the file. +There are two ways to open the visualizer : -The visualizer is now open ! +- **From a `.comm` file** : click on the "eye" icon `Open visualizer` in the top-right corner of the file. +- **From a `.med` file** : click any `.med`, `.mmed`, or `.rmed` file in the explorer — it opens directly in the viewer, no `.comm` file needed. Files with non-standard MED extensions (e.g. `.71`) are auto-detected and can be registered in one click. #### Features - Load geometry files (`.med`) directly into the viewer - Highlight face and node groups using the sidebar - Highlight groups quickly by selecting their names from your command file (`.comm`) -- Control the camera with by rotating or panning it +- 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 #### Usage tips @@ -211,6 +216,9 @@ The visualizer is now open ! - Hold `Shift` + `Left click` and move your mouse to pan the camera - 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 - File management : - Mesh files (`.*med` files) are converted to `.obj` files, which are stored in a hidden folder called `.visu_data/` in your workspace diff --git a/webviews/viewer/src/components/popups/HelpPopup.svelte b/webviews/viewer/src/components/popups/HelpPopup.svelte index 59d87b8..fc42a72 100644 --- a/webviews/viewer/src/components/popups/HelpPopup.svelte +++ b/webviews/viewer/src/components/popups/HelpPopup.svelte @@ -3,6 +3,7 @@