From 06feb2048f02197f858c68eaf8f06549811c453e Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Fri, 6 Mar 2026 14:14:04 +0100 Subject: [PATCH 01/21] Various changes to begin work on v1.5.0 - Added a show/hide button for objects - Added colors for objects - Now uses user theme for UI - Focusing a comm file will now focus its corresponding webview too - Added file names to the webview titles --- src/VisuManager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/VisuManager.ts b/src/VisuManager.ts index f0dd0aa..348e57a 100644 --- a/src/VisuManager.ts +++ b/src/VisuManager.ts @@ -118,6 +118,8 @@ export class VisuManager { const commName = path.basename(commUri.fsPath, path.extname(commUri.fsPath)); + const commName = path.basename(commUri.fsPath, path.extname(commUri.fsPath)); + const visu = new WebviewVisu( 'meshViewer', testDir, From 7d65773871945b4fb3d3c8c73c16506f60a768fb Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Fri, 6 Mar 2026 18:26:25 +0100 Subject: [PATCH 02/21] Improve zoom and rendering options --- resources/visu_vtk/js/ui/CustomDropdown.js | 118 +++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 resources/visu_vtk/js/ui/CustomDropdown.js diff --git a/resources/visu_vtk/js/ui/CustomDropdown.js b/resources/visu_vtk/js/ui/CustomDropdown.js new file mode 100644 index 0000000..9ab6070 --- /dev/null +++ b/resources/visu_vtk/js/ui/CustomDropdown.js @@ -0,0 +1,118 @@ +/** + * A reusable custom dropdown that matches the VS Code Aster UI style. + * The panel is appended to document.body to avoid overflow clipping. + */ +class CustomDropdown { + /** + * @param {HTMLElement} trigger - Element that opens/closes the panel on click + * @param {{ value: string, label: string }[]} options + * @param {(value: string) => void} onSelect - Called when the user picks an option + * @param {() => string|null} getValue - Returns the currently selected value (shown with a checkmark) + * @param {{ align?: 'left'|'right' }} [opts] + */ + constructor(trigger, options, onSelect, getValue, opts = {}) { + this._trigger = trigger; + this._options = options; + this._onSelect = onSelect; + this._getValue = getValue; + this._align = opts.align ?? 'left'; + this._panel = null; + + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + this._panel ? this._close() : this._open(); + }); + document.addEventListener('click', () => this._close()); + } + + _open() { + const currentValue = this._getValue?.(); + + const panel = document.createElement('div'); + panel.style.cssText = ` + position: fixed; + z-index: 9999; + background: var(--ui-popup-bg); + border: 1px solid var(--ui-border); + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + padding: 3px 0; + overflow: hidden; + `; + panel.addEventListener('click', (e) => e.stopPropagation()); + + const showCheckmarks = this._getValue != null; + + this._options.forEach(({ value, label }) => { + const isSelected = value === currentValue; + + const item = document.createElement('div'); + item.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + font-size: 0.75rem; + cursor: pointer; + color: var(--ui-fg); + white-space: nowrap; + ${this._align === 'right' ? 'justify-content: flex-end;' : ''} + ${isSelected ? 'font-weight: 600;' : ''} + `; + + const labelEl = document.createElement('span'); + labelEl.textContent = label; + + if (showCheckmarks) { + const check = document.createElement('span'); + check.textContent = '✓'; + check.style.cssText = ` + font-size: 0.6rem; + flex-shrink: 0; + width: 10px; + opacity: ${isSelected ? 1 : 0}; + `; + item.appendChild(check); + } + + item.appendChild(labelEl); + + item.addEventListener('mouseenter', () => { + item.style.background = 'var(--ui-element-bg-hover)'; + }); + item.addEventListener('mouseleave', () => { + item.style.background = ''; + }); + item.addEventListener('click', () => { + this._onSelect(value); + this._close(); + }); + + panel.appendChild(item); + }); + + document.body.appendChild(panel); + + // Position after append so dimensions are known + const rect = this._trigger.getBoundingClientRect(); + const panelW = Math.max(panel.offsetWidth, rect.width); + const panelH = panel.offsetHeight; + panel.style.minWidth = `${panelW}px`; + + let left = rect.left + rect.width / 2 - panelW / 2; + left = Math.max(4, Math.min(left, window.innerWidth - panelW - 4)); + panel.style.left = `${left}px`; + + const openUp = window.innerHeight - rect.bottom < panelH + 8; + panel.style.top = openUp + ? `${rect.top - panelH - 4}px` + : `${rect.bottom + 4}px`; + + this._panel = panel; + } + + _close() { + this._panel?.remove(); + this._panel = null; + } +} From 0c2ca68bab9470c98f432ea99a50c8f83324b49a Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Tue, 10 Mar 2026 16:20:24 +0100 Subject: [PATCH 03/21] Switch viewer to Svelte --- .../src/components/ActionButtons.svelte | 51 +++++ resources/visu_vtk/src/components/App.svelte | 38 ++++ .../src/components/AxisButtons.svelte | 33 ++++ .../src/components/GroupButton.svelte | 49 +++++ .../src/components/ObjectSection.svelte | 101 ++++++++++ .../visu_vtk/src/components/Sidebar.svelte | 33 ++++ .../visu_vtk/src/components/TopActions.svelte | 45 +++++ .../visu_vtk/src/components/ZoomWidget.svelte | 65 ++++++ .../src/components/popups/GroupsPopup.svelte | 143 ++++++++++++++ .../src/components/popups/HelpPopup.svelte | 72 +++++++ .../src/components/popups/Popup.svelte | 18 ++ .../components/popups/SettingsPopup.svelte | 186 ++++++++++++++++++ .../visu_vtk/src/icons/ChevronIcon.svelte | 7 + resources/visu_vtk/src/icons/ClearIcon.svelte | 9 + resources/visu_vtk/src/icons/EyeIcon.svelte | 8 + .../visu_vtk/src/icons/EyeOffIcon.svelte | 10 + resources/visu_vtk/src/icons/FaceIcon.svelte | 6 + .../visu_vtk/src/icons/FilterIcon.svelte | 7 + resources/visu_vtk/src/icons/NodeIcon.svelte | 6 + .../visu_vtk/src/icons/ObjectIcon.svelte | 6 + .../visu_vtk/src/icons/QuestionIcon.svelte | 9 + resources/visu_vtk/src/icons/ResetIcon.svelte | 8 + .../visu_vtk/src/icons/SettingsIcon.svelte | 8 + resources/visu_vtk/src/icons/ZoomIcon.svelte | 10 + resources/visu_vtk/src/lib/Controller.ts | 76 +++++++ .../src/lib/commands/VisibilityManager.ts | 170 ++++++++++++++++ resources/visu_vtk/src/lib/core/VtkApp.ts | 82 ++++++++ .../visu_vtk/src/lib/data/CreateGroups.ts | 76 +++++++ resources/visu_vtk/src/lib/data/Group.ts | 95 +++++++++ resources/visu_vtk/src/lib/data/ObjLoader.ts | 101 ++++++++++ .../src/lib/data/create/NodeActorCreator.ts | 79 ++++++++ .../src/lib/interaction/AxesCreator.ts | 82 ++++++++ .../src/lib/interaction/CameraManager.ts | 150 ++++++++++++++ .../src/lib/settings/GlobalSettings.ts | 109 ++++++++++ resources/visu_vtk/src/lib/state.ts | 27 +++ .../lib/ui/CustomDropdown.ts} | 74 +++---- resources/visu_vtk/src/lib/vtk.d.ts | 1 + resources/visu_vtk/src/main.ts | 56 ++++++ resources/visu_vtk/svelte.config.js | 5 + resources/visu_vtk/tsconfig.json | 16 ++ resources/visu_vtk/vite.config.ts | 14 ++ 41 files changed, 2099 insertions(+), 42 deletions(-) create mode 100644 resources/visu_vtk/src/components/ActionButtons.svelte create mode 100644 resources/visu_vtk/src/components/App.svelte create mode 100644 resources/visu_vtk/src/components/AxisButtons.svelte create mode 100644 resources/visu_vtk/src/components/GroupButton.svelte create mode 100644 resources/visu_vtk/src/components/ObjectSection.svelte create mode 100644 resources/visu_vtk/src/components/Sidebar.svelte create mode 100644 resources/visu_vtk/src/components/TopActions.svelte create mode 100644 resources/visu_vtk/src/components/ZoomWidget.svelte create mode 100644 resources/visu_vtk/src/components/popups/GroupsPopup.svelte create mode 100644 resources/visu_vtk/src/components/popups/HelpPopup.svelte create mode 100644 resources/visu_vtk/src/components/popups/Popup.svelte create mode 100644 resources/visu_vtk/src/components/popups/SettingsPopup.svelte create mode 100644 resources/visu_vtk/src/icons/ChevronIcon.svelte create mode 100644 resources/visu_vtk/src/icons/ClearIcon.svelte create mode 100644 resources/visu_vtk/src/icons/EyeIcon.svelte create mode 100644 resources/visu_vtk/src/icons/EyeOffIcon.svelte create mode 100644 resources/visu_vtk/src/icons/FaceIcon.svelte create mode 100644 resources/visu_vtk/src/icons/FilterIcon.svelte create mode 100644 resources/visu_vtk/src/icons/NodeIcon.svelte create mode 100644 resources/visu_vtk/src/icons/ObjectIcon.svelte create mode 100644 resources/visu_vtk/src/icons/QuestionIcon.svelte create mode 100644 resources/visu_vtk/src/icons/ResetIcon.svelte create mode 100644 resources/visu_vtk/src/icons/SettingsIcon.svelte create mode 100644 resources/visu_vtk/src/icons/ZoomIcon.svelte create mode 100644 resources/visu_vtk/src/lib/Controller.ts create mode 100644 resources/visu_vtk/src/lib/commands/VisibilityManager.ts create mode 100644 resources/visu_vtk/src/lib/core/VtkApp.ts create mode 100644 resources/visu_vtk/src/lib/data/CreateGroups.ts create mode 100644 resources/visu_vtk/src/lib/data/Group.ts create mode 100644 resources/visu_vtk/src/lib/data/ObjLoader.ts create mode 100644 resources/visu_vtk/src/lib/data/create/NodeActorCreator.ts create mode 100644 resources/visu_vtk/src/lib/interaction/AxesCreator.ts create mode 100644 resources/visu_vtk/src/lib/interaction/CameraManager.ts create mode 100644 resources/visu_vtk/src/lib/settings/GlobalSettings.ts create mode 100644 resources/visu_vtk/src/lib/state.ts rename resources/visu_vtk/{js/ui/CustomDropdown.js => src/lib/ui/CustomDropdown.ts} (62%) create mode 100644 resources/visu_vtk/src/lib/vtk.d.ts create mode 100644 resources/visu_vtk/src/main.ts create mode 100644 resources/visu_vtk/svelte.config.js create mode 100644 resources/visu_vtk/tsconfig.json create mode 100644 resources/visu_vtk/vite.config.ts diff --git a/resources/visu_vtk/src/components/ActionButtons.svelte b/resources/visu_vtk/src/components/ActionButtons.svelte new file mode 100644 index 0000000..980f626 --- /dev/null +++ b/resources/visu_vtk/src/components/ActionButtons.svelte @@ -0,0 +1,51 @@ + + +
+
+ + +
+
+
+ + +
+
diff --git a/resources/visu_vtk/src/components/App.svelte b/resources/visu_vtk/src/components/App.svelte new file mode 100644 index 0000000..a34aad7 --- /dev/null +++ b/resources/visu_vtk/src/components/App.svelte @@ -0,0 +1,38 @@ + + +{#if hasData} + { openPopup = 'groups'; }} /> +{/if} + + { openPopup = 'settings'; }} + onOpenHelp={() => { openPopup = 'help'; }} +/> + + + +{#if openPopup} + { openPopup = null; }}> + {#if openPopup === 'help'} + { openPopup = null; }} /> + {:else if openPopup === 'settings'} + { openPopup = null; }} /> + {:else if openPopup === 'groups'} + { openPopup = null; }} /> + {/if} + +{/if} diff --git a/resources/visu_vtk/src/components/AxisButtons.svelte b/resources/visu_vtk/src/components/AxisButtons.svelte new file mode 100644 index 0000000..4b0cbe1 --- /dev/null +++ b/resources/visu_vtk/src/components/AxisButtons.svelte @@ -0,0 +1,33 @@ + + +
+ + + +
diff --git a/resources/visu_vtk/src/components/GroupButton.svelte b/resources/visu_vtk/src/components/GroupButton.svelte new file mode 100644 index 0000000..eae2b92 --- /dev/null +++ b/resources/visu_vtk/src/components/GroupButton.svelte @@ -0,0 +1,49 @@ + + +{#if !isHidden} + +{/if} diff --git a/resources/visu_vtk/src/components/ObjectSection.svelte b/resources/visu_vtk/src/components/ObjectSection.svelte new file mode 100644 index 0000000..6352a93 --- /dev/null +++ b/resources/visu_vtk/src/components/ObjectSection.svelte @@ -0,0 +1,101 @@ + + + + + + {objectName} + + + + +{#if isHidden} +
+ {groupCount} groups +
+{:else} + {#if !collapsed} +
+ {#each faces as groupName} + + {/each} + {#each nodes as groupName} + + {/each} + {#if hiddenGroupCount > 0} +
+ {hiddenGroupCount} hidden +
+ {/if} +
+ {:else} +
+ {groupCount} groups +
+ {/if} +{/if} diff --git a/resources/visu_vtk/src/components/Sidebar.svelte b/resources/visu_vtk/src/components/Sidebar.svelte new file mode 100644 index 0000000..d7b483b --- /dev/null +++ b/resources/visu_vtk/src/components/Sidebar.svelte @@ -0,0 +1,33 @@ + + +
+
+ {#each Object.entries($groupHierarchy) as [key, data]} + + {/each} +
+ + + +
+ + +
diff --git a/resources/visu_vtk/src/components/TopActions.svelte b/resources/visu_vtk/src/components/TopActions.svelte new file mode 100644 index 0000000..53da7ee --- /dev/null +++ b/resources/visu_vtk/src/components/TopActions.svelte @@ -0,0 +1,45 @@ + + +
+ + +
diff --git a/resources/visu_vtk/src/components/ZoomWidget.svelte b/resources/visu_vtk/src/components/ZoomWidget.svelte new file mode 100644 index 0000000..dce26a8 --- /dev/null +++ b/resources/visu_vtk/src/components/ZoomWidget.svelte @@ -0,0 +1,65 @@ + + +
+ +
{ (e.currentTarget as HTMLElement).style.background = 'color-mix(in srgb, var(--ui-fg) 7%, transparent)'; }} + onmouseout={(e) => { (e.currentTarget as HTMLElement).style.background = ''; }} + role="button" + tabindex="0" + > + {zoomText} +
+ {#if !$isAtDefaultZoom} + + {/if} +
diff --git a/resources/visu_vtk/src/components/popups/GroupsPopup.svelte b/resources/visu_vtk/src/components/popups/GroupsPopup.svelte new file mode 100644 index 0000000..7227b17 --- /dev/null +++ b/resources/visu_vtk/src/components/popups/GroupsPopup.svelte @@ -0,0 +1,143 @@ + + +
e.stopPropagation()} + role="document" +> +
+ Sidebar groups + + Choose which groups are shown in the sidebar. Hidden groups remain visible in the 3D view. + +
+ +
+ {#each objects as obj (obj.key)} + {@const allOff = allUnchecked(obj.key, obj.allGroups)} +
+
+
+ + {obj.name} +
+ +
+ + {#each obj.allGroups as group} + + {/each} +
+ {/each} +
+ +
+ + +
+
diff --git a/resources/visu_vtk/src/components/popups/HelpPopup.svelte b/resources/visu_vtk/src/components/popups/HelpPopup.svelte new file mode 100644 index 0000000..5c56a9c --- /dev/null +++ b/resources/visu_vtk/src/components/popups/HelpPopup.svelte @@ -0,0 +1,72 @@ + + +
e.stopPropagation()} + role="document" +> + Help + +
+
+ Group highlighting +
    +
  • Click on a group name in the sidebar to highlight or unhighlight it
  • +
  • + Click on the + + button in the sidebar to reset highlight status for all groups +
  • +
  • + Click on the + + button in the sidebar to choose which groups are easily accessible in the sidebar +
  • +
  • Objects becomes transparent when you highlight their groups, helping visualize details more clearly
  • +
+
+ +
+ Camera control +
    +
  • Hold Left click and move your mouse to rotate the camera
  • +
  • Hold Ctrl + Left click and move your mouse to rotate the camera around an axis
  • +
  • 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 +
  • +
+
+ +
+ 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/resources/visu_vtk/src/components/popups/Popup.svelte b/resources/visu_vtk/src/components/popups/Popup.svelte new file mode 100644 index 0000000..9d8fe7a --- /dev/null +++ b/resources/visu_vtk/src/components/popups/Popup.svelte @@ -0,0 +1,18 @@ + + + diff --git a/resources/visu_vtk/src/components/popups/SettingsPopup.svelte b/resources/visu_vtk/src/components/popups/SettingsPopup.svelte new file mode 100644 index 0000000..0074015 --- /dev/null +++ b/resources/visu_vtk/src/components/popups/SettingsPopup.svelte @@ -0,0 +1,186 @@ + + +
e.stopPropagation()} + role="document" +> + Settings + +
+
+ +
{ (e.currentTarget as HTMLElement).style.background = 'var(--ui-element-bg-hover)'; }} + onmouseout={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--ui-element-bg)'; }} + role="button" + tabindex="0" + > + {edgeModeLabel} + +
+ {edgeModeDesc} + {#if showThresholdSection} +
+
+ + {edgeThresholdDisplay}× +
+ + Higher values show edges from farther away. +
+ {/if} +
+ +
+
+ + {hiddenOpacityPct}% +
+ + + When hiding an object with the eye button, it can remain slightly visible as a ghost. + +
+
+ +
+ + +
+
diff --git a/resources/visu_vtk/src/icons/ChevronIcon.svelte b/resources/visu_vtk/src/icons/ChevronIcon.svelte new file mode 100644 index 0000000..e084886 --- /dev/null +++ b/resources/visu_vtk/src/icons/ChevronIcon.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/resources/visu_vtk/src/icons/ClearIcon.svelte b/resources/visu_vtk/src/icons/ClearIcon.svelte new file mode 100644 index 0000000..de72505 --- /dev/null +++ b/resources/visu_vtk/src/icons/ClearIcon.svelte @@ -0,0 +1,9 @@ + + + + + + diff --git a/resources/visu_vtk/src/icons/EyeIcon.svelte b/resources/visu_vtk/src/icons/EyeIcon.svelte new file mode 100644 index 0000000..b1ef35a --- /dev/null +++ b/resources/visu_vtk/src/icons/EyeIcon.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/resources/visu_vtk/src/icons/EyeOffIcon.svelte b/resources/visu_vtk/src/icons/EyeOffIcon.svelte new file mode 100644 index 0000000..130ad8f --- /dev/null +++ b/resources/visu_vtk/src/icons/EyeOffIcon.svelte @@ -0,0 +1,10 @@ + + + + + + + diff --git a/resources/visu_vtk/src/icons/FaceIcon.svelte b/resources/visu_vtk/src/icons/FaceIcon.svelte new file mode 100644 index 0000000..b2ebf6c --- /dev/null +++ b/resources/visu_vtk/src/icons/FaceIcon.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/resources/visu_vtk/src/icons/FilterIcon.svelte b/resources/visu_vtk/src/icons/FilterIcon.svelte new file mode 100644 index 0000000..f5c9864 --- /dev/null +++ b/resources/visu_vtk/src/icons/FilterIcon.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/resources/visu_vtk/src/icons/NodeIcon.svelte b/resources/visu_vtk/src/icons/NodeIcon.svelte new file mode 100644 index 0000000..effefe5 --- /dev/null +++ b/resources/visu_vtk/src/icons/NodeIcon.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/resources/visu_vtk/src/icons/ObjectIcon.svelte b/resources/visu_vtk/src/icons/ObjectIcon.svelte new file mode 100644 index 0000000..fc0d680 --- /dev/null +++ b/resources/visu_vtk/src/icons/ObjectIcon.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/resources/visu_vtk/src/icons/QuestionIcon.svelte b/resources/visu_vtk/src/icons/QuestionIcon.svelte new file mode 100644 index 0000000..d87029c --- /dev/null +++ b/resources/visu_vtk/src/icons/QuestionIcon.svelte @@ -0,0 +1,9 @@ + + + + + + diff --git a/resources/visu_vtk/src/icons/ResetIcon.svelte b/resources/visu_vtk/src/icons/ResetIcon.svelte new file mode 100644 index 0000000..4435773 --- /dev/null +++ b/resources/visu_vtk/src/icons/ResetIcon.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/resources/visu_vtk/src/icons/SettingsIcon.svelte b/resources/visu_vtk/src/icons/SettingsIcon.svelte new file mode 100644 index 0000000..1885533 --- /dev/null +++ b/resources/visu_vtk/src/icons/SettingsIcon.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/resources/visu_vtk/src/icons/ZoomIcon.svelte b/resources/visu_vtk/src/icons/ZoomIcon.svelte new file mode 100644 index 0000000..7fd3230 --- /dev/null +++ b/resources/visu_vtk/src/icons/ZoomIcon.svelte @@ -0,0 +1,10 @@ + + + + + + + diff --git a/resources/visu_vtk/src/lib/Controller.ts b/resources/visu_vtk/src/lib/Controller.ts new file mode 100644 index 0000000..4c16924 --- /dev/null +++ b/resources/visu_vtk/src/lib/Controller.ts @@ -0,0 +1,76 @@ +import { VtkApp } from './core/VtkApp'; +import { CreateGroups } from './data/CreateGroups'; +import { VisibilityManager } from './commands/VisibilityManager'; +import { CameraManager } from './interaction/CameraManager'; +import { groupHierarchy as groupHierarchyStore } from './state'; +import type { Group } from './data/Group'; + +export class Controller { + private static _i: Controller; + private _scene: HTMLElement | null = null; + private _vsCodeApi: any = null; + private _groups: Record | null = null; + private _groupHierarchy: Record | null = null; + + static get Instance(): Controller { + if (!this._i) { + this._i = new Controller(); + } + return this._i; + } + + init(scene: HTMLElement, vsCodeApiEntry: any): void { + this._scene = scene; + this._vsCodeApi = vsCodeApiEntry; + VtkApp.Instance.init(scene); + } + + getScene(): HTMLElement | null { + return this._scene; + } + + getVSCodeAPI(): any { + return this._vsCodeApi; + } + + loadFiles(fileContexts: string[], fileNames: string[]): void { + const lfr = new CreateGroups(fileContexts, fileNames); + lfr.do(); + this._vsCodeApi.postMessage({ + type: 'groups', + groupList: this.getGroupNames(), + }); + } + + saveGroups(groups: Record, groupHierarchy: Record): void { + this._groups = groups; + this._groupHierarchy = groupHierarchy; + + groupHierarchyStore.set(groupHierarchy); + + this.initManagers(); + + this._vsCodeApi.postMessage({ + type: 'debugPanel', + text: 'Actors and hierarchy saved', + }); + } + + private initManagers(): void { + if (!this._groups || !this._groupHierarchy) return; + VisibilityManager.Instance.init(this._groups, this._groupHierarchy); + CameraManager.Instance.init(this._groups); + } + + refreshThemeColors(): void { + if (!this._groups) return; + for (const group of Object.values(this._groups)) { + group.applyThemeColor(); + } + } + + getGroupNames(): string[] { + if (!this._groups) { return []; } + return Object.keys(this._groups).filter((key) => !key.includes('all_')); + } +} diff --git a/resources/visu_vtk/src/lib/commands/VisibilityManager.ts b/resources/visu_vtk/src/lib/commands/VisibilityManager.ts new file mode 100644 index 0000000..81dc2f0 --- /dev/null +++ b/resources/visu_vtk/src/lib/commands/VisibilityManager.ts @@ -0,0 +1,170 @@ +import { Controller } from '../Controller'; +import { VtkApp } from '../core/VtkApp'; +import { GlobalSettings } from '../settings/GlobalSettings'; +import { highlightedGroups, hiddenObjects } from '../state'; +import type { Group } from '../data/Group'; + +export class VisibilityManager { + private static _i: VisibilityManager; + groups: Record = {}; + private visibleGroupsByObject: Record = {}; + hiddenObjects: Record = {}; + private highlightedGroupsSet: Set = new Set(); + + static get Instance(): VisibilityManager { + if (!this._i) { + this._i = new VisibilityManager(); + } + return this._i; + } + + init(groups: Record, objects: Record): void { + this.groups = groups; + this.visibleGroupsByObject = {}; + this.hiddenObjects = {}; + this.highlightedGroupsSet = new Set(); + + for (const object in objects) { + this.visibleGroupsByObject[object] = 0; + this.hiddenObjects[object] = false; + } + + hiddenObjects.set(new Set()); + highlightedGroups.set(new Map()); + } + + setVisibility(groupName: string, visible?: boolean): { visible: boolean; color: number[]; isFaceGroup: boolean } | undefined { + const post = (text: string) => { + Controller.Instance.getVSCodeAPI().postMessage({ type: 'debugPanel', text }); + }; + + const group = this.groups[groupName]; + if (!group) { + post(`setVisibility: group "${groupName}" has no group defined`); + return; + } + const object = group.fileGroup; + if (!object) { + post(`setVisibility: group "${groupName}" has no parent object`); + return; + } + const actor = group.actor; + if (!actor) { + post(`setVisibility: no actor found for group "${groupName}"`); + return; + } + + const color = group.getColor(); + const isFaceGroup = group.isFaceGroup; + + const wasHighlighted = this.highlightedGroupsSet.has(groupName); + const isHighlighted = typeof visible === 'boolean' ? visible : !wasHighlighted; + + if (isHighlighted) { + this.highlightedGroupsSet.add(groupName); + highlightedGroups.update((map) => { map.set(groupName, color); return map; }); + } else { + this.highlightedGroupsSet.delete(groupName); + highlightedGroups.update((map) => { map.delete(groupName); return map; }); + } + + if (!this.hiddenObjects[object]) { + group.setVisibility(isHighlighted); + } + + if (wasHighlighted !== isHighlighted) { + const visibleGroupsCount = this.visibleGroupsByObject[object]; + if (!this.hiddenObjects[object]) { + if ( + (visibleGroupsCount === 0 && isHighlighted) || + (visibleGroupsCount === 1 && !isHighlighted) + ) { + this.setTransparence(isHighlighted, object); + } + } + this.visibleGroupsByObject[object] += isHighlighted ? 1 : -1; + } + + VtkApp.Instance.getRenderWindow().render(); + + return { visible: isHighlighted, color, isFaceGroup }; + } + + toggleObjectVisibility(object: string): boolean { + const nowVisible = this.hiddenObjects[object]; + this.hiddenObjects[object] = !nowVisible; + + hiddenObjects.update((s) => { + if (nowVisible) s.delete(object); + else s.add(object); + return s; + }); + + const fileGroup = this.groups[object]; + if (fileGroup) { + if (nowVisible) { + fileGroup.actor.setVisibility(true); + const opacity = this.visibleGroupsByObject[object] > 0 ? 0.2 : 1.0; + fileGroup.setOpacity(opacity); + } else { + const hiddenOpacity = GlobalSettings.Instance.hiddenObjectOpacity; + if (hiddenOpacity === 0) { + fileGroup.actor.setVisibility(false); + } else { + fileGroup.actor.setVisibility(true); + fileGroup.setOpacity(hiddenOpacity); + } + } + } + + for (const [groupName, group] of Object.entries(this.groups)) { + if (group.fileGroup === object) { + group.actor.setVisibility(nowVisible && this.highlightedGroupsSet.has(groupName)); + } + } + + VtkApp.Instance.getRenderWindow().render(); + return nowVisible; + } + + setTransparence(transparent: boolean, object: string): void { + if (!this.groups || this.hiddenObjects[object]) { return; } + const meshOpacity = transparent ? 0.2 : 1; + const group = this.groups[object]; + group.setOpacity(meshOpacity); + } + + applyHiddenObjectOpacity(): void { + const hiddenOpacity = GlobalSettings.Instance.hiddenObjectOpacity; + for (const object in this.hiddenObjects) { + if (!this.hiddenObjects[object]) continue; + const fileGroup = this.groups[object]; + if (!fileGroup) continue; + if (hiddenOpacity === 0) { + fileGroup.actor.setVisibility(false); + } else { + fileGroup.actor.setVisibility(true); + fileGroup.setOpacity(hiddenOpacity); + } + } + VtkApp.Instance.getRenderWindow().render(); + } + + clear(): void { + for (const [groupName, group] of Object.entries(this.groups)) { + if (!group.actor) { continue; } + if (group.fileGroup === null) { continue; } + group.setVisibility(false); + } + + this.highlightedGroupsSet.clear(); + highlightedGroups.set(new Map()); + + for (const object in this.visibleGroupsByObject) { + this.setTransparence(false, object); + this.visibleGroupsByObject[object] = 0; + } + + VtkApp.Instance.getRenderWindow().render(); + } +} diff --git a/resources/visu_vtk/src/lib/core/VtkApp.ts b/resources/visu_vtk/src/lib/core/VtkApp.ts new file mode 100644 index 0000000..ae2eda2 --- /dev/null +++ b/resources/visu_vtk/src/lib/core/VtkApp.ts @@ -0,0 +1,82 @@ +import { Controller } from '../Controller'; + +export class VtkApp { + private static _i: VtkApp; + private fullScreenRenderer: any; + renderer: any; + renderWindow: any; + + static get Instance(): VtkApp { + if (!this._i) { + this._i = new VtkApp(); + } + return this._i; + } + + private _readEditorBackground(): number[] { + const raw = getComputedStyle(document.body).getPropertyValue('--vscode-editor-background').trim(); + const match = raw.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); + if (match) { + return [parseInt(match[1], 16) / 255, parseInt(match[2], 16) / 255, parseInt(match[3], 16) / 255]; + } + return [0.4, 0.6, 1.0]; + } + + updateBackground(): void { + this.renderer.setBackground(this._readEditorBackground()); + } + + init(scene: HTMLElement): void { + if (!window.vtk) { return; } + + this.fullScreenRenderer = vtk.Rendering.Misc.vtkFullScreenRenderWindow.newInstance({ + rootContainer: scene, + background: this._readEditorBackground(), + }); + + this.renderer = this.fullScreenRenderer.getRenderer(); + this.renderWindow = this.fullScreenRenderer.getRenderWindow(); + + this.updateCameraOffset(); + + const controls = document.getElementById('controls'); + if (controls) { + new ResizeObserver(() => this.updateCameraOffset()).observe(controls); + } + window.addEventListener('resize', () => this.updateCameraOffset()); + + new MutationObserver(() => { + this.updateBackground(); + Controller.Instance.refreshThemeColors(); + this.renderWindow.render(); + }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); + + Controller.Instance.getVSCodeAPI().postMessage({ + type: 'debugPanel', + text: 'vtkAppInitialized', + }); + } + + updateCameraOffset(): void { + const controls = document.getElementById('controls'); + const sidebarActions = document.getElementById('sidebarActions'); + if (!controls) return; + const sidebarWidth = controls.offsetWidth - (sidebarActions?.offsetWidth ?? 0); + const offset = sidebarWidth / window.innerWidth; + this.renderer.getActiveCamera().setWindowCenter(-offset, 0); + this.renderWindow.render(); + + const zoomWidget = document.getElementById('zoomWidget'); + if (zoomWidget) { + zoomWidget.style.left = `${sidebarWidth + (window.innerWidth - sidebarWidth) / 2}px`; + } + } + + getRenderer(): any { + return this.renderer; + } + + getRenderWindow(): any { + return this.renderWindow; + } +} diff --git a/resources/visu_vtk/src/lib/data/CreateGroups.ts b/resources/visu_vtk/src/lib/data/CreateGroups.ts new file mode 100644 index 0000000..7bb3958 --- /dev/null +++ b/resources/visu_vtk/src/lib/data/CreateGroups.ts @@ -0,0 +1,76 @@ +import { GlobalSettings } from '../settings/GlobalSettings'; +import { ObjLoader } from './ObjLoader'; +import { FaceActorCreator } from './create/FaceActorCreator'; +import { NodeActorCreator } from './create/NodeActorCreator'; +import { Group } from './Group'; +import { Controller } from '../Controller'; +import { VtkApp } from '../core/VtkApp'; + +export class CreateGroups { + private fileContexts: string[]; + private fileNames: string[]; + groups: Record = {}; + + constructor(fileContexts: string[], fileNames: string[]) { + this.fileContexts = fileContexts; + this.fileNames = fileNames; + } + + do(): void { + const result = ObjLoader.loadFiles(this.fileContexts, this.fileNames); + const post = (text: string) => { + Controller.Instance.getVSCodeAPI().postMessage({ type: 'debugPanel', text }); + }; + + if (!result) { return; } + + const { vertices, cells, cellIndexToGroup, nodes, nodeIndexToGroup, faceGroups, nodeGroups, groupHierarchy } = result; + + const faceActorCreator = new FaceActorCreator(vertices, cells, cellIndexToGroup); + const nodeActorCreator = new NodeActorCreator(vertices, nodes, nodeIndexToGroup); + + for (const fileGroup in groupHierarchy) { + const groupId = faceGroups.indexOf(fileGroup); + + const _oc = GlobalSettings.Instance.objectColors; + const objColor = _oc[GlobalSettings.Instance.objIndex % _oc.length]; + (groupHierarchy[fileGroup] as any).color = objColor; + + const { actor, colorIndex: fileColorIndex, isObjectActor: fileIsObj, cellCount: fileCellCount } = faceActorCreator.create(fileGroup, groupId); + + const groupInstance = new Group(actor, fileGroup, true, null, null, fileColorIndex, fileIsObj, fileCellCount); + this.groups[fileGroup] = groupInstance; + + const size = this.computeSize(actor); + + for (const faceGroup of groupHierarchy[fileGroup].faces) { + const faceGroupId = faceGroups.indexOf(`${fileGroup}::${faceGroup}`); + const { actor: faceActor, colorIndex: faceColorIndex, isObjectActor: faceIsObj, cellCount: faceCellCount } = faceActorCreator.create(faceGroup, faceGroupId); + const subGroup = new Group(faceActor, faceGroup, true, fileGroup, size, faceColorIndex, faceIsObj, faceCellCount); + this.groups[`${fileGroup}::${faceGroup}`] = subGroup; + } + + for (const nodeGroup of groupHierarchy[fileGroup].nodes) { + const nodeGroupId = nodeGroups.indexOf(`${fileGroup}::${nodeGroup}`); + const { actor: nodeActor, colorIndex: nodeColorIndex } = nodeActorCreator.create(nodeGroupId); + const subGroup = new Group(nodeActor, nodeGroup, false, fileGroup, size, nodeColorIndex, false); + this.groups[`${fileGroup}::${nodeGroup}`] = subGroup; + } + } + + VtkApp.Instance.getRenderer().resetCamera(); + VtkApp.Instance.getRenderWindow().render(); + post(`actors : ${Object.keys(this.groups).length}`); + + Controller.Instance.saveGroups(this.groups, groupHierarchy); + } + + private computeSize(actor: any): number { + const bounds = actor.getBounds(); + const dx = bounds[1] - bounds[0]; + const dy = bounds[3] - bounds[2]; + const dz = bounds[5] - bounds[4]; + const size = Math.sqrt(dx * dx + dy * dy + dz * dz); + return Math.max(size, 1e-3); + } +} diff --git a/resources/visu_vtk/src/lib/data/Group.ts b/resources/visu_vtk/src/lib/data/Group.ts new file mode 100644 index 0000000..0248a9d --- /dev/null +++ b/resources/visu_vtk/src/lib/data/Group.ts @@ -0,0 +1,95 @@ +import { GlobalSettings } from '../settings/GlobalSettings'; + +export class Group { + actor: any; + name: string; + isFaceGroup: boolean; + fileGroup: string | null; + size: number | null; + colorIndex: number | null; + isObjectActor: boolean; + cellCount: number | null; + private _edgeT?: number; + + constructor( + actor: any, + name: string, + isFaceGroup: boolean, + fileGroup: string | null = null, + parentSize: number | null = null, + colorIndex: number | null = null, + isObjectActor = false, + cellCount: number | null = null, + ) { + this.actor = actor; + this.name = name; + this.isFaceGroup = isFaceGroup; + this.fileGroup = fileGroup; + this.size = parentSize; + this.colorIndex = colorIndex; + this.isObjectActor = isObjectActor; + this.cellCount = cellCount; + } + + applyThemeColor(): void { + if (this.colorIndex === null) return; + const colors = this.isObjectActor + ? GlobalSettings.Instance.objectColors + : GlobalSettings.Instance.meshGroupColors; + const color = colors[this.colorIndex % colors.length]; + this.actor.getProperty().setColor(color); + this._applyEdgeColor(); + } + + updateEdgeVisibility(currentDistance: number, initialDistance: number): void { + if (this.cellCount === null) return; + const prop = this.actor.getProperty(); + const mode = GlobalSettings.Instance.edgeMode; + + if (mode === 'hide') { + prop.setEdgeVisibility(false); + return; + } + if (mode === 'show') { + prop.setEdgeVisibility(true); + prop.setEdgeColor(0, 0, 0); + return; + } + + const threshold = initialDistance * Math.sqrt(15000 / this.cellCount) * GlobalSettings.Instance.edgeThresholdMultiplier; + + if (mode === 'threshold') { + prop.setEdgeVisibility(currentDistance < threshold); + prop.setEdgeColor(0, 0, 0); + return; + } + + prop.setEdgeVisibility(true); + this._edgeT = Math.min(1, Math.max(0, threshold / currentDistance)); + this._applyEdgeColor(); + } + + private _applyEdgeColor(): void { + const t = this._edgeT ?? 0; + const [r, g, b] = this.actor.getProperty().getColor(); + this.actor.getProperty().setEdgeColor(r * (1 - t), g * (1 - t), b * (1 - t)); + } + + setSize(distance: number): void { + const decay = (this.size ?? 1) / 5; + const scale = Math.max(30 * (1 / Math.sqrt(1 + distance / decay)), 0); + this.actor.getProperty().setPointSize(scale); + } + + getColor(): number[] { + return this.actor.getProperty().getColor(); + } + + setVisibility(visible: boolean): void { + this.actor.setVisibility(visible); + } + + setOpacity(opacity: number): void { + this.actor.getProperty().setOpacity(opacity); + } +} diff --git a/resources/visu_vtk/src/lib/data/ObjLoader.ts b/resources/visu_vtk/src/lib/data/ObjLoader.ts new file mode 100644 index 0000000..dcb7fa8 --- /dev/null +++ b/resources/visu_vtk/src/lib/data/ObjLoader.ts @@ -0,0 +1,101 @@ +import { Controller } from '../Controller'; + +export interface ObjLoaderResult { + vertices: { x: number; y: number; z: number }[]; + cells: number[][]; + cellIndexToGroup: number[]; + nodes: number[]; + nodeIndexToGroup: number[]; + faceGroups: string[]; + nodeGroups: string[]; + groupHierarchy: Record; +} + +export class ObjLoader { + static loadFiles(fileContexts: string[], fileNames: string[]): ObjLoaderResult { + const vertices: { x: number; y: number; z: number }[] = []; + const cells: number[][] = []; + const cellIndexToGroup: number[] = []; + const nodes: number[] = []; + const nodeIndexToGroup: number[] = []; + const faceGroups: string[] = []; + const nodeGroups: string[] = []; + const groupHierarchy: Record = {}; + + let nbVertices = 0; + let groupId = -1; + let nodeGroupId = -1; + + for (let i = 0; i < fileContexts.length; i++) { + try { + groupId++; + const skinName = 'all_' + fileNames[i]; + + groupHierarchy[skinName] = { faces: [], nodes: [] }; + faceGroups.push(skinName); + nbVertices = vertices.length; + + const lines = fileContexts[i].split('\n').map((l) => l.replace('\r', '')); + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]; + const ss = line.split(' ').filter((p) => p.length !== 0); + if (ss.length === 0) { continue; } + + switch (ss[0]) { + case 'v': + vertices.push({ + x: Number.parseFloat(ss[1]), + y: Number.parseFloat(ss[2]), + z: Number.parseFloat(ss[3]), + }); + break; + + case 'f': { + const faceIndices = ss.slice(1).map((p) => Number.parseInt(p) - 1 + nbVertices); + cells.push(faceIndices); + cellIndexToGroup.push(groupId); + break; + } + + case 'g': { + groupId++; + const faceGroupName = ss[1] || `group${groupId}`; + faceGroups.push(`${skinName}::${faceGroupName}`); + groupHierarchy[skinName].faces.push(faceGroupName); + break; + } + + case 'ng': { + nodeGroupId++; + const nodeGroupName = ss[1] || `nodeGroup${nodeGroupId}`; + nodeGroups.push(`${skinName}::${nodeGroupName}`); + groupHierarchy[skinName].nodes.push(nodeGroupName); + break; + } + + case 'p': { + const nodeIndex = parseInt(ss[1]); + nodes.push(nodeIndex - 1 + nbVertices); + nodeIndexToGroup.push(nodeGroupId); + break; + } + } + } + } catch (fileError: any) { + Controller.Instance.getVSCodeAPI().postMessage({ + type: 'debugPanel', + text: `ERROR: ${fileError.message}`, + }); + throw fileError; + } + } + + Controller.Instance.getVSCodeAPI().postMessage({ + type: 'debugPanel', + text: `TOTAL: ${vertices.length} vertices, ${cells.length} cells, ${nodes.length} nodes`, + }); + + return { vertices, cells, cellIndexToGroup, nodes, nodeIndexToGroup, faceGroups, nodeGroups, groupHierarchy }; + } +} diff --git a/resources/visu_vtk/src/lib/data/create/NodeActorCreator.ts b/resources/visu_vtk/src/lib/data/create/NodeActorCreator.ts new file mode 100644 index 0000000..67f958f --- /dev/null +++ b/resources/visu_vtk/src/lib/data/create/NodeActorCreator.ts @@ -0,0 +1,79 @@ +import { GlobalSettings } from '../../settings/GlobalSettings'; +import { VtkApp } from '../../core/VtkApp'; + +export class NodeActorCreator { + private vertices: { x: number; y: number; z: number }[]; + private nodes: number[]; + private nodeIndexToGroup: number[]; + + constructor( + vertices: { x: number; y: number; z: number }[], + nodes: number[], + nodeIndexToGroup: number[], + ) { + this.vertices = vertices; + this.nodes = nodes; + this.nodeIndexToGroup = nodeIndexToGroup; + } + + create(groupId: number): { actor: any; colorIndex: number } { + const polyData = this.prepare(groupId); + + const mapper = vtk.Rendering.Core.vtkMapper.newInstance(); + mapper.setInputData(polyData); + + const actor = vtk.Rendering.Core.vtkActor.newInstance(); + actor.setMapper(mapper); + + const colorIndex = this.setProperty(actor); + VtkApp.Instance.getRenderer().addActor(actor); + + return { actor, colorIndex }; + } + + private prepare(groupId: number): any { + const pd = vtk.Common.DataModel.vtkPolyData.newInstance(); + + const nodeIndices = this.nodeIndexToGroup + .map((g, idx) => (g === groupId ? idx : -1)) + .filter((idx) => idx !== -1); + + if (nodeIndices.length > 0) { + const pts = vtk.Common.Core.vtkPoints.newInstance(); + const data: number[] = []; + + for (const idx of nodeIndices) { + const v = this.vertices[this.nodes[idx]]; + if (v) { + data.push(v.x, v.y, v.z); + } + } + + pts.setData(Float32Array.from(data), 3); + pd.setPoints(pts); + + const numPoints = data.length / 3; + const verts = vtk.Common.Core.vtkCellArray.newInstance(); + const vertData: number[] = []; + + for (let i = 0; i < numPoints; i++) { + vertData.push(1, i); + } + + verts.setData(Uint32Array.from(vertData)); + pd.setVerts(verts); + } + + return pd; + } + + private setProperty(actor: any): number { + const prop = actor.getProperty(); + const colorIndex = GlobalSettings.Instance.grpIndex; + prop.setRepresentation(0); + prop.setOpacity(1); + actor.setVisibility(false); + prop.setColor(GlobalSettings.Instance.getColorForGroup()); + return colorIndex; + } +} diff --git a/resources/visu_vtk/src/lib/interaction/AxesCreator.ts b/resources/visu_vtk/src/lib/interaction/AxesCreator.ts new file mode 100644 index 0000000..92e0a19 --- /dev/null +++ b/resources/visu_vtk/src/lib/interaction/AxesCreator.ts @@ -0,0 +1,82 @@ +export class AxesCreator { + private axisLength = 1.0; + private axisRadius = 0.02; + private sphereRadius = 0.08; + + private colors = { + x: [1, 0, 0], + y: [0.251, 0.529, 0.376], + z: [0, 0, 1], + }; + + static createCustomAxesActor(): any { + const instance = new AxesCreator(); + + const axisRadius = instance.axisRadius; + const sphereRadius = 0.1; + const sphereTheta = 12; + const spherePhi = 12; + + const addColor = (polyData: any, color: number[]) => { + const scalars = vtk.Common.Core.vtkDataArray.newInstance({ + numberOfComponents: 3, + values: new Uint8Array(polyData.getPoints().getNumberOfPoints() * 3), + name: 'color', + }); + + const colors = scalars.getData(); + const rgb = color.map((c) => Math.round(c * 255)); + for (let i = 0; i < colors.length; i += 3) { + colors[i] = rgb[0]; + colors[i + 1] = rgb[1]; + colors[i + 2] = rgb[2]; + } + + polyData.getPointData().setScalars(scalars); + }; + + const xAxisSource = vtk.Filters.General.vtkAppendPolyData.newInstance(); + xAxisSource.setInputData(vtk.Filters.Sources.vtkCylinderSource.newInstance({ + radius: axisRadius, resolution: 20, direction: [1, 0, 0], center: [0.5, 0, 0], + }).getOutputData()); + xAxisSource.addInputData(vtk.Filters.Sources.vtkSphereSource.newInstance({ + radius: sphereRadius, center: [1, 0, 0], thetaResolution: sphereTheta, phiResolution: spherePhi, + }).getOutputData()); + const xAxis = xAxisSource.getOutputData(); + addColor(xAxis, instance.colors.x); + + const yAxisSource = vtk.Filters.General.vtkAppendPolyData.newInstance(); + yAxisSource.setInputData(vtk.Filters.Sources.vtkCylinderSource.newInstance({ + radius: axisRadius, resolution: 20, direction: [0, 1, 0], center: [0, 0.5, 0], + }).getOutputData()); + yAxisSource.addInputData(vtk.Filters.Sources.vtkSphereSource.newInstance({ + radius: sphereRadius, center: [0, 1, 0], thetaResolution: sphereTheta, phiResolution: spherePhi, + }).getOutputData()); + const yAxis = yAxisSource.getOutputData(); + addColor(yAxis, instance.colors.y); + + const zAxisSource = vtk.Filters.General.vtkAppendPolyData.newInstance(); + zAxisSource.setInputData(vtk.Filters.Sources.vtkCylinderSource.newInstance({ + radius: axisRadius, resolution: 20, direction: [0, 0, 1], center: [0, 0, 0.5], + }).getOutputData()); + zAxisSource.addInputData(vtk.Filters.Sources.vtkSphereSource.newInstance({ + radius: sphereRadius, center: [0, 0, 1], thetaResolution: sphereTheta, phiResolution: spherePhi, + }).getOutputData()); + const zAxis = zAxisSource.getOutputData(); + addColor(zAxis, instance.colors.z); + + const axesSource = vtk.Filters.General.vtkAppendPolyData.newInstance(); + axesSource.setInputData(xAxis); + axesSource.addInputData(yAxis); + axesSource.addInputData(zAxis); + + const axesMapper = vtk.Rendering.Core.vtkMapper.newInstance(); + axesMapper.setInputData(axesSource.getOutputData()); + + const axesActor = vtk.Rendering.Core.vtkActor.newInstance(); + axesActor.setMapper(axesMapper); + axesActor.getProperty().setLighting(false); + + return axesActor; + } +} diff --git a/resources/visu_vtk/src/lib/interaction/CameraManager.ts b/resources/visu_vtk/src/lib/interaction/CameraManager.ts new file mode 100644 index 0000000..498d48f --- /dev/null +++ b/resources/visu_vtk/src/lib/interaction/CameraManager.ts @@ -0,0 +1,150 @@ +import { VtkApp } from '../core/VtkApp'; +import { AxesCreator } from './AxesCreator'; +import { zoomRatio, isAtDefaultZoom } from '../state'; +import type { Group } from '../data/Group'; + +export class CameraManager { + private static _i: CameraManager; + private camera: any; + private initialDistance = 0; + private lastDistance = 0; + nodesGroups: Record = {}; + faceGroups: Record = {}; + private orientationWidget: any; + private axesActor: any; + + static get Instance(): CameraManager { + if (!this._i) { + this._i = new CameraManager(); + } + return this._i; + } + + init(groups: Record): void { + this.nodesGroups = {}; + this.faceGroups = {}; + + const renderer = VtkApp.Instance.getRenderer(); + this.camera = renderer.getActiveCamera(); + this.initialDistance = this.camera.getDistance(); + this.lastDistance = this.initialDistance; + + for (const [groupName, group] of Object.entries(groups)) { + if (!group.isFaceGroup) { + this.nodesGroups[groupName] = group; + group.setSize(this.lastDistance); + } else if (group.cellCount !== null) { + this.faceGroups[groupName] = group; + group.updateEdgeVisibility(this.lastDistance, this.initialDistance); + } + } + + this._updateZoomIndicator(this.initialDistance); + this.axesActor = this.createAxisMarker(); + this.activateSizeUpdate(); + } + + private activateSizeUpdate(): void { + this.camera.onModified(() => { + const currentDistance = this.camera.getDistance(); + this._updateZoomIndicator(currentDistance); + if (Math.abs(currentDistance - this.lastDistance) > 1e-2) { + for (const nodeGroup of Object.values(this.nodesGroups)) { + nodeGroup.setSize(currentDistance); + } + for (const faceGroup of Object.values(this.faceGroups)) { + faceGroup.updateEdgeVisibility(currentDistance, this.initialDistance); + } + this.lastDistance = currentDistance; + } + }); + } + + private _updateZoomIndicator(currentDistance: number): void { + const ratio = this.initialDistance / currentDistance; + let text: string; + if (ratio >= 10) text = `${Math.round(ratio)}×`; + else if (ratio >= 1) text = `${ratio.toFixed(1)}×`; + else text = `${ratio.toFixed(2)}×`; + + zoomRatio.set(ratio); + isAtDefaultZoom.set(Math.abs(ratio - 1) < 0.01); + + const zoomIndicator = document.getElementById('zoomIndicator'); + if (zoomIndicator) zoomIndicator.textContent = text; + } + + resetZoom(): void { + VtkApp.Instance.getRenderer().resetCamera(); + VtkApp.Instance.updateCameraOffset(); + } + + setZoom(ratio: number): void { + const focalPoint = this.camera.getFocalPoint(); + const position = this.camera.getPosition(); + const dx = position[0] - focalPoint[0]; + const dy = position[1] - focalPoint[1]; + const dz = position[2] - focalPoint[2]; + const currentDist = Math.sqrt(dx * dx + dy * dy + dz * dz); + const scale = (this.initialDistance / ratio) / currentDist; + this.camera.setPosition( + focalPoint[0] + dx * scale, + focalPoint[1] + dy * scale, + focalPoint[2] + dz * scale, + ); + VtkApp.Instance.getRenderer().resetCameraClippingRange(); + VtkApp.Instance.updateCameraOffset(); + } + + refreshEdgeVisibility(): void { + for (const faceGroup of Object.values(this.faceGroups)) { + faceGroup.updateEdgeVisibility(this.lastDistance, this.initialDistance); + } + VtkApp.Instance.getRenderWindow().render(); + } + + setCameraAxis(axis: string): void { + if (!this.camera) { return; } + + const focalPoint = this.camera.getFocalPoint(); + const distance = this.camera.getDistance(); + + let newPosition = [0, 0, 0]; + let viewUp = [0, 0, 1]; + + switch (axis.toLowerCase()) { + case 'x': + newPosition = [focalPoint[0] + distance, focalPoint[1], focalPoint[2]]; + break; + case 'y': + newPosition = [focalPoint[0], focalPoint[1] + distance, focalPoint[2]]; + break; + case 'z': + newPosition = [focalPoint[0], focalPoint[1], focalPoint[2] + distance]; + viewUp = [0, 1, 0]; + break; + default: + return; + } + + this.camera.setPosition(...newPosition); + this.camera.setViewUp(viewUp); + VtkApp.Instance.getRenderer().resetCameraClippingRange(); + VtkApp.Instance.getRenderWindow().render(); + } + + private createAxisMarker(): any { + const axes = AxesCreator.createCustomAxesActor(); + + const widget = vtk.Interaction.Widgets.vtkOrientationMarkerWidget.newInstance({ + actor: axes, + interactor: VtkApp.Instance.getRenderWindow().getInteractor(), + }); + widget.setEnabled(true); + widget.setViewportCorner(vtk.Interaction.Widgets.vtkOrientationMarkerWidget.Corners.BOTTOM_RIGHT); + widget.setViewportSize(0.15); + + this.orientationWidget = widget; + return axes; + } +} diff --git a/resources/visu_vtk/src/lib/settings/GlobalSettings.ts b/resources/visu_vtk/src/lib/settings/GlobalSettings.ts new file mode 100644 index 0000000..0976ef6 --- /dev/null +++ b/resources/visu_vtk/src/lib/settings/GlobalSettings.ts @@ -0,0 +1,109 @@ +import type { EdgeMode } from '../state'; + +export class GlobalSettings { + private static _instance: GlobalSettings; + + static get Instance(): GlobalSettings { + if (!this._instance) { + this._instance = new GlobalSettings(); + } + return this._instance; + } + + grpIndex = 0; + objIndex = 0; + + backgroundColor = [0.6627, 0.7960, 0.910]; + ambientLightColor = [0.2, 0.2, 0.2]; + lightColor = [0.6667, 0.6667, 0.6667]; + surfaceInsideColor = [1, 1, 0]; + surfaceOutsideColor = [0.537, 0.529, 0.529]; + localSelectedColor = [1, 1, 1]; + surfaceTransparentColor = [0.553, 0.749, 0.42]; + surfaceRenderOrder = 0; + wireframeColor = [0, 0, 0]; + wireframeOpacity = 0.35; + wireframeAlpha = 1; + wireframeRenderOrder = 10; + wireframeSelectedColor = [1, 1, 1]; + wireframeSelectedRenderOrder = 11; + drawLineColor = [1, 0, 0]; + drawLineHelperRenderOrder = 12; + selectHelperColor = [1, 1, 1]; + selectionPointColor = [1, 0, 0]; + + hiddenObjectOpacity = 0; + edgeMode: EdgeMode = 'threshold'; + edgeThresholdMultiplier = 1; + + get isDark(): boolean { + return document.body.classList.contains('vscode-dark') || + document.body.classList.contains('vscode-high-contrast'); + } + + private _meshGroupColors = [ + [0.902, 0.098, 0.294], + [0.235, 0.706, 0.294], + [1, 0.882, 0.098], + [0.941, 0.196, 0.902], + [0.961, 0.510, 0.192], + [0.569, 0.118, 0.706], + [0.275, 0.941, 0.941], + [0.737, 0.965, 0.047], + [0.980, 0.745, 0.745], + [0, 0.502, 0.502], + [0.902, 0.745, 1 ], + [0.604, 0.388, 0.141], + [0.263, 0.388, 0.847], + [1, 0.980, 0.784], + [0.502, 0, 0 ], + [0.667, 1, 0.764], + [0.502, 0.502, 0 ], + [1, 0.847, 0.694], + [0, 0, 0.463], + [0.502, 0.502, 0.502], + ]; + + get meshGroupColors(): number[][] { + if (this.isDark) return this._meshGroupColors; + return this._meshGroupColors.map(([r, g, b]) => [r * 0.72, g * 0.72, b * 0.72]); + } + + getColorForGroup(): number[] { + const idx = this.grpIndex % this._meshGroupColors.length; + this.grpIndex++; + return this.meshGroupColors[idx]; + } + + private _objectColorsLight = [ + [0.400, 0.620, 0.820], + [0.280, 0.700, 0.580], + [0.880, 0.560, 0.280], + [0.600, 0.400, 0.800], + [0.380, 0.720, 0.420], + [0.820, 0.380, 0.440], + [0.380, 0.680, 0.820], + [0.820, 0.620, 0.380], + ]; + + private _objectColorsDark = [ + [0.440, 0.720, 0.980], + [0.240, 0.880, 0.700], + [0.980, 0.660, 0.320], + [0.720, 0.500, 0.980], + [0.400, 0.900, 0.500], + [0.980, 0.420, 0.520], + [0.360, 0.800, 0.980], + [0.980, 0.740, 0.440], + ]; + + get objectColors(): number[][] { + return this.isDark ? this._objectColorsDark : this._objectColorsLight; + } + + getColorForObject(): number[] { + const idx = this.objIndex % this.objectColors.length; + this.objIndex++; + return this.objectColors[idx]; + } +} diff --git a/resources/visu_vtk/src/lib/state.ts b/resources/visu_vtk/src/lib/state.ts new file mode 100644 index 0000000..87d4c4f --- /dev/null +++ b/resources/visu_vtk/src/lib/state.ts @@ -0,0 +1,27 @@ +import { writable } from 'svelte/store'; + +export type GroupHierarchy = Record; +export type HighlightedGroups = Map; +export type HiddenObjects = Set; + +export type EdgeMode = 'gradual' | 'threshold' | 'show' | 'hide'; + +export interface Settings { + hiddenObjectOpacity: number; + edgeMode: EdgeMode; + edgeThresholdMultiplier: number; +} + +export const groupHierarchy = writable({}); +export const highlightedGroups = writable(new Map()); +export const hiddenObjects = writable(new Set()); +export const zoomRatio = writable(1); +export const isAtDefaultZoom = writable(true); +export const settings = writable({ + hiddenObjectOpacity: 0, + edgeMode: 'threshold', + edgeThresholdMultiplier: 1, +}); + +// Map> — groups NOT shown in sidebar (hidden) +export const sidebarHiddenGroups = writable>>(new Map()); diff --git a/resources/visu_vtk/js/ui/CustomDropdown.js b/resources/visu_vtk/src/lib/ui/CustomDropdown.ts similarity index 62% rename from resources/visu_vtk/js/ui/CustomDropdown.js rename to resources/visu_vtk/src/lib/ui/CustomDropdown.ts index 9ab6070..2b2db30 100644 --- a/resources/visu_vtk/js/ui/CustomDropdown.js +++ b/resources/visu_vtk/src/lib/ui/CustomDropdown.ts @@ -1,22 +1,28 @@ -/** - * A reusable custom dropdown that matches the VS Code Aster UI style. - * The panel is appended to document.body to avoid overflow clipping. - */ -class CustomDropdown { - /** - * @param {HTMLElement} trigger - Element that opens/closes the panel on click - * @param {{ value: string, label: string }[]} options - * @param {(value: string) => void} onSelect - Called when the user picks an option - * @param {() => string|null} getValue - Returns the currently selected value (shown with a checkmark) - * @param {{ align?: 'left'|'right' }} [opts] - */ - constructor(trigger, options, onSelect, getValue, opts = {}) { +export interface DropdownOption { + value: string; + label: string; +} + +export class CustomDropdown { + private _trigger: HTMLElement; + private _options: DropdownOption[]; + private _onSelect: (value: string) => void; + private _getValue: (() => string | null) | null; + private _align: 'left' | 'right'; + private _panel: HTMLElement | null = null; + + constructor( + trigger: HTMLElement, + options: DropdownOption[], + onSelect: (value: string) => void, + getValue: (() => string | null) | null = null, + opts: { align?: 'left' | 'right' } = {}, + ) { this._trigger = trigger; this._options = options; this._onSelect = onSelect; this._getValue = getValue; this._align = opts.align ?? 'left'; - this._panel = null; trigger.addEventListener('click', (e) => { e.stopPropagation(); @@ -25,8 +31,9 @@ class CustomDropdown { document.addEventListener('click', () => this._close()); } - _open() { + private _open(): void { const currentValue = this._getValue?.(); + const showCheckmarks = this._getValue != null; const panel = document.createElement('div'); panel.style.cssText = ` @@ -35,17 +42,14 @@ class CustomDropdown { background: var(--ui-popup-bg); border: 1px solid var(--ui-border); border-radius: 4px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + box-shadow: 0 4px 16px rgba(0,0,0,0.25); padding: 3px 0; overflow: hidden; `; panel.addEventListener('click', (e) => e.stopPropagation()); - const showCheckmarks = this._getValue != null; - - this._options.forEach(({ value, label }) => { + for (const { value, label } of this._options) { const isSelected = value === currentValue; - const item = document.createElement('div'); item.style.cssText = ` display: flex; @@ -60,40 +64,26 @@ class CustomDropdown { ${isSelected ? 'font-weight: 600;' : ''} `; - const labelEl = document.createElement('span'); - labelEl.textContent = label; - if (showCheckmarks) { const check = document.createElement('span'); check.textContent = '✓'; - check.style.cssText = ` - font-size: 0.6rem; - flex-shrink: 0; - width: 10px; - opacity: ${isSelected ? 1 : 0}; - `; + check.style.cssText = `font-size: 0.6rem; flex-shrink: 0; width: 10px; opacity: ${isSelected ? 1 : 0};`; item.appendChild(check); } + const labelEl = document.createElement('span'); + labelEl.textContent = label; item.appendChild(labelEl); - item.addEventListener('mouseenter', () => { - item.style.background = 'var(--ui-element-bg-hover)'; - }); - item.addEventListener('mouseleave', () => { - item.style.background = ''; - }); - item.addEventListener('click', () => { - this._onSelect(value); - this._close(); - }); + item.addEventListener('mouseenter', () => { item.style.background = 'var(--ui-element-bg-hover)'; }); + item.addEventListener('mouseleave', () => { item.style.background = ''; }); + item.addEventListener('click', () => { this._onSelect(value); this._close(); }); panel.appendChild(item); - }); + } document.body.appendChild(panel); - // Position after append so dimensions are known const rect = this._trigger.getBoundingClientRect(); const panelW = Math.max(panel.offsetWidth, rect.width); const panelH = panel.offsetHeight; @@ -111,7 +101,7 @@ class CustomDropdown { this._panel = panel; } - _close() { + private _close(): void { this._panel?.remove(); this._panel = null; } diff --git a/resources/visu_vtk/src/lib/vtk.d.ts b/resources/visu_vtk/src/lib/vtk.d.ts new file mode 100644 index 0000000..07b18ba --- /dev/null +++ b/resources/visu_vtk/src/lib/vtk.d.ts @@ -0,0 +1 @@ +declare const vtk: any; diff --git a/resources/visu_vtk/src/main.ts b/resources/visu_vtk/src/main.ts new file mode 100644 index 0000000..dfd514c --- /dev/null +++ b/resources/visu_vtk/src/main.ts @@ -0,0 +1,56 @@ +import { mount } from 'svelte'; +import App from './components/App.svelte'; +import { Controller } from './lib/Controller'; +import { VisibilityManager } from './lib/commands/VisibilityManager'; +import { GlobalSettings } from './lib/settings/GlobalSettings'; +import { settings } from './lib/state'; +import type { EdgeMode } from './lib/state'; +import './app.css'; + +declare function acquireVsCodeApi(): { + postMessage(msg: unknown): void; + getState(): unknown; + setState(state: unknown): void; +}; + +const vscode = acquireVsCodeApi(); +const scene = document.getElementById('scene')!; + +mount(App, { target: document.getElementById('app')! }); + +Controller.Instance.init(scene, vscode); + +window.addEventListener('message', async (e) => { + const { type, body } = e.data; + + switch (type) { + case 'init': { + Controller.Instance.loadFiles(body.fileContexts, body.objFilenames); + if (body.settings) { + const s = body.settings; + if (s.hiddenObjectOpacity !== undefined) { + GlobalSettings.Instance.hiddenObjectOpacity = s.hiddenObjectOpacity; + } + if (s.edgeMode !== undefined) { + GlobalSettings.Instance.edgeMode = s.edgeMode as EdgeMode; + } + if (s.edgeThresholdMultiplier !== undefined) { + GlobalSettings.Instance.edgeThresholdMultiplier = s.edgeThresholdMultiplier; + } + settings.update((cur) => ({ + hiddenObjectOpacity: s.hiddenObjectOpacity ?? cur.hiddenObjectOpacity, + edgeMode: (s.edgeMode ?? cur.edgeMode) as EdgeMode, + edgeThresholdMultiplier: s.edgeThresholdMultiplier ?? cur.edgeThresholdMultiplier, + })); + VisibilityManager.Instance.applyHiddenObjectOpacity(); + } + break; + } + + case 'displayGroup': + VisibilityManager.Instance.setVisibility(body.group, body.visible); + break; + } +}); + +vscode.postMessage({ type: 'ready' }); diff --git a/resources/visu_vtk/svelte.config.js b/resources/visu_vtk/svelte.config.js new file mode 100644 index 0000000..4c6b24b --- /dev/null +++ b/resources/visu_vtk/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/resources/visu_vtk/tsconfig.json b/resources/visu_vtk/tsconfig.json new file mode 100644 index 0000000..c7942f0 --- /dev/null +++ b/resources/visu_vtk/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/resources/visu_vtk/vite.config.ts b/resources/visu_vtk/vite.config.ts new file mode 100644 index 0000000..e1f4c35 --- /dev/null +++ b/resources/visu_vtk/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import tailwindcss from '@tailwindcss/vite'; +import { resolve } from 'path'; + +export default defineConfig({ + root: resolve(__dirname), + base: './', + build: { + outDir: resolve(__dirname, 'dist'), + emptyOutDir: true, + }, + plugins: [svelte(), tailwindcss()], +}); From 3bc690e30414e98a0cd5e35eb25befe7f1b228b4 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Wed, 11 Mar 2026 16:27:55 +0100 Subject: [PATCH 04/21] Fix bugs --- .../src/components/ActionButtons.svelte | 10 ++---- .../src/components/AxisButtons.svelte | 12 ++----- .../src/components/GroupButton.svelte | 10 ++---- .../src/components/ObjectSection.svelte | 1 + .../visu_vtk/src/components/Sidebar.svelte | 7 ++++ .../visu_vtk/src/components/TopActions.svelte | 10 ++---- .../visu_vtk/src/components/ZoomWidget.svelte | 20 ++++++------ .../src/components/popups/GroupsPopup.svelte | 18 ++++------- .../src/components/popups/HelpPopup.svelte | 8 ++--- .../src/components/popups/Popup.svelte | 4 ++- .../components/popups/SettingsPopup.svelte | 32 ++++++++----------- resources/visu_vtk/src/lib/core/VtkApp.ts | 6 ++-- .../visu_vtk/src/lib/ui/CustomDropdown.ts | 8 ++--- resources/visu_vtk/vite.config.ts | 7 ++++ webviews/viewer/src/app.css | 20 ++++++++++++ 15 files changed, 89 insertions(+), 84 deletions(-) diff --git a/resources/visu_vtk/src/components/ActionButtons.svelte b/resources/visu_vtk/src/components/ActionButtons.svelte index 980f626..ff9ab74 100644 --- a/resources/visu_vtk/src/components/ActionButtons.svelte +++ b/resources/visu_vtk/src/components/ActionButtons.svelte @@ -17,12 +17,10 @@ >
{/if}
diff --git a/resources/visu_vtk/src/components/popups/GroupsPopup.svelte b/resources/visu_vtk/src/components/popups/GroupsPopup.svelte index 7227b17..b8775e7 100644 --- a/resources/visu_vtk/src/components/popups/GroupsPopup.svelte +++ b/resources/visu_vtk/src/components/popups/GroupsPopup.svelte @@ -68,9 +68,9 @@
e.stopPropagation()} role="document" >
@@ -90,10 +90,8 @@ {obj.name}
diff --git a/resources/visu_vtk/src/components/popups/SettingsPopup.svelte b/resources/visu_vtk/src/components/popups/SettingsPopup.svelte index 0074015..65df064 100644 --- a/resources/visu_vtk/src/components/popups/SettingsPopup.svelte +++ b/resources/visu_vtk/src/components/popups/SettingsPopup.svelte @@ -34,13 +34,13 @@ $effect(() => { if (!edgeModeSelectEl) return; - const dd = new CustomDropdown( + const dropdown = new CustomDropdown( edgeModeSelectEl, edgeModeOptions, (value) => applyEdgeMode(value as EdgeMode), () => GlobalSettings.Instance.edgeMode, ); - return () => {}; + return () => dropdown.close(); }); function applyEdgeMode(mode: EdgeMode) { @@ -100,22 +100,20 @@
e.stopPropagation()} role="document" > Settings
- + Edge display
{ (e.currentTarget as HTMLElement).style.background = 'var(--ui-element-bg-hover)'; }} - onmouseout={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--ui-element-bg)'; }} + class="w-full text-xs px-2 py-1.5 rounded-sm cursor-pointer flex items-center justify-between gap-2 select-none bg-ui-elem hover:bg-ui-elem-hover" + style="color: var(--ui-fg); border: 1px solid var(--ui-border)" role="button" tabindex="0" > @@ -126,10 +124,11 @@ {#if showThresholdSection}
- + {edgeThresholdDisplay}×
- + {hiddenOpacityPct}%
+
+{/if} + Date: Thu, 12 Mar 2026 15:29:14 +0100 Subject: [PATCH 07/21] Revamp help popup --- .../src/components/ObjectSection.svelte | 2 +- .../src/components/popups/HelpPopup.svelte | 240 ++++++++++++++---- .../visu_vtk/src/icons/MouseLeftIcon.svelte | 11 + .../visu_vtk/src/icons/MouseRightIcon.svelte | 11 + .../visu_vtk/src/icons/MouseScrollIcon.svelte | 10 + resources/visu_vtk/vite.config.ts | 7 + 6 files changed, 227 insertions(+), 54 deletions(-) create mode 100644 resources/visu_vtk/src/icons/MouseLeftIcon.svelte create mode 100644 resources/visu_vtk/src/icons/MouseRightIcon.svelte create mode 100644 resources/visu_vtk/src/icons/MouseScrollIcon.svelte diff --git a/resources/visu_vtk/src/components/ObjectSection.svelte b/resources/visu_vtk/src/components/ObjectSection.svelte index ac3ab86..5b3bdd6 100644 --- a/resources/visu_vtk/src/components/ObjectSection.svelte +++ b/resources/visu_vtk/src/components/ObjectSection.svelte @@ -79,7 +79,7 @@ onmouseleave={(e) => (e.currentTarget as HTMLElement).style.background = ''} onclick={hideAllOthers} > - Hide all others + Hide all other objects
{/if} diff --git a/resources/visu_vtk/src/components/popups/HelpPopup.svelte b/resources/visu_vtk/src/components/popups/HelpPopup.svelte index c05dd0d..188a78a 100644 --- a/resources/visu_vtk/src/components/popups/HelpPopup.svelte +++ b/resources/visu_vtk/src/components/popups/HelpPopup.svelte @@ -1,70 +1,204 @@ + +
- Help - -
-
- Group highlighting -
    -
  • Click on a group name in the sidebar to highlight or unhighlight it
  • -
  • - Click on the - - button in the sidebar to reset highlight status for all groups -
  • -
  • - Click on the - - button in the sidebar to choose which groups are easily accessible in the sidebar -
  • -
  • Objects becomes transparent when you highlight their groups, helping visualize details more clearly
  • -
-
+ -
- Camera control -
    -
  • Hold Left click and move your mouse to rotate the camera
  • -
  • Hold Ctrl + Left click and move your mouse to rotate the camera around an axis
  • -
  • 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 -
  • -
-
+
+
+ + {#if activeTab === 'Camera'} +
+ Drag to rotate + Ctrl+Drag to rotate around a single axis + Shift+Drag to pan + Zoom in / out + + + 2.0× + + + + + + Open the zoom level selector to choose a preset + + + + 2.0× + + + + + + Reset zoom to 1× — appears only when zoom ≠ 1× + + X + Y + Z + + Align camera along that axis — buttons at the bottom of the sidebar +
+ + {:else if activeTab === 'Objects'} +
+ + + mesh + + + Click the name to collapse or expand its groups + + + + mesh + + + Click to show or hide an entire object + + + + mesh + + + Right-click for more options — e.g. hide all other objects +
+ + {:else if activeTab === 'Groups'} +
+ + + group_A + + Click a group to highlight it — the object becomes transparent so the group stands out. Click again to unhighlight. + + + + + + + Click to choose which groups appear in the sidebar — hidden groups remain visible in the 3D view + + + + + + + Click to clear all highlights +
+ + {:else if activeTab === 'Settings'} +

+ Open settings with in the top-right corner. +

+
+ Edge display + Choose when mesh edges are visible: always, never, or only when zooming in. Threshold mode shows edges abruptly at a zoom level; gradual mode fades them in (may impact performance on dense meshes) + Hidden opacity + How transparent hidden objects appear — at 0% they are fully invisible, above 0% they remain as ghosts +
+ + {:else if activeTab === 'Files'} +
+

.med mesh files are automatically converted to .obj when opened.

+

Converted files are cached in a hidden .visu_data/ folder in your workspace and reused on subsequent opens.

+
+ + {:else if activeTab === 'About'} +
+

This extension is made by Simvia.

+

Source code and issue tracker are available on GitHub.

+
+ {/if} -
- 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/resources/visu_vtk/src/icons/MouseLeftIcon.svelte b/resources/visu_vtk/src/icons/MouseLeftIcon.svelte new file mode 100644 index 0000000..30a5ec9 --- /dev/null +++ b/resources/visu_vtk/src/icons/MouseLeftIcon.svelte @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/resources/visu_vtk/src/icons/MouseRightIcon.svelte b/resources/visu_vtk/src/icons/MouseRightIcon.svelte new file mode 100644 index 0000000..c9ba62c --- /dev/null +++ b/resources/visu_vtk/src/icons/MouseRightIcon.svelte @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/resources/visu_vtk/src/icons/MouseScrollIcon.svelte b/resources/visu_vtk/src/icons/MouseScrollIcon.svelte new file mode 100644 index 0000000..69ee9f0 --- /dev/null +++ b/resources/visu_vtk/src/icons/MouseScrollIcon.svelte @@ -0,0 +1,10 @@ + + + + + + + diff --git a/resources/visu_vtk/vite.config.ts b/resources/visu_vtk/vite.config.ts index c3692bb..cb6d765 100644 --- a/resources/visu_vtk/vite.config.ts +++ b/resources/visu_vtk/vite.config.ts @@ -2,8 +2,15 @@ import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import tailwindcss from '@tailwindcss/vite'; import { resolve } from 'path'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const pkg = require('../../package.json'); export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, root: resolve(__dirname), base: './', build: { From d44976d54a97860b61d662a467e0e913d879bbc3 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Fri, 13 Mar 2026 09:37:32 +0100 Subject: [PATCH 08/21] Update settings popup --- .../src/components/popups/HelpPopup.svelte | 20 +- .../components/popups/SettingsPopup.svelte | 443 ++++++++++++++---- .../src/lib/commands/VisibilityManager.ts | 14 +- resources/visu_vtk/src/lib/data/Group.ts | 13 +- .../src/lib/interaction/CameraManager.ts | 5 + .../src/lib/settings/GlobalSettings.ts | 5 + resources/visu_vtk/src/lib/state.ts | 4 + resources/visu_vtk/src/main.ts | 21 +- 8 files changed, 399 insertions(+), 126 deletions(-) diff --git a/resources/visu_vtk/src/components/popups/HelpPopup.svelte b/resources/visu_vtk/src/components/popups/HelpPopup.svelte index 188a78a..05ce597 100644 --- a/resources/visu_vtk/src/components/popups/HelpPopup.svelte +++ b/resources/visu_vtk/src/components/popups/HelpPopup.svelte @@ -1,3 +1,7 @@ + + +{#snippet tip(text: string)} + + + + +{/snippet} +
- Settings - -
-
- Edge display -
+ + +
+
+ {#if activeTab === "Edges"} +
+
+ Edge rendering mode + {@render tip( + "Choose when mesh edges are visible: always, never, or only when zooming in. Threshold mode shows edges abruptly at a zoom level; gradual mode fades them in (may impact performance on dense meshes).", + )} +
+
+ {edgeModeLabel} + +
+ + {edgeModeDesc} + + {#if showThresholdSection} +
+
+
+ + {@render tip( + "Higher values show edges at a lower zoom level, from farther away.", + )} +
+ + {edgeThresholdDisplay.toPrecision( + edgeThresholdDisplay < 1 ? 2 : 3, + )}× + +
+ + Higher values show edges from farther away. +
+ {/if} +
+ {:else if activeTab === "Visibility"} +
+
+
+
+ + {@render tip( + "At 0% hidden objects are fully invisible. Above 0% they remain as faint ghosts.", + )} +
+ {hiddenOpacityPct}% +
+ + When hiding an object with the eye button, it can remain slightly + visible as a ghost. +
+
+
+
+ + {@render tip( + "When a sub-group is highlighted, the parent mesh fades to this opacity so the selected group stands out.", + )} +
+ {groupTransparencyPct}% +
+ + Opacity of the parent mesh when a sub-group is highlighted. +
+
+ {:else if activeTab === "Display"} +
+
+ +
+
+ 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. +
- - Higher values show edges from farther away.
{/if}
-
-
- - {hiddenOpacityPct}% -
- - - When hiding an object with the eye button, it can remain slightly visible as a ghost. - +
+ +
- -
- - -
diff --git a/resources/visu_vtk/src/lib/commands/VisibilityManager.ts b/resources/visu_vtk/src/lib/commands/VisibilityManager.ts index 3287278..ce82a5f 100644 --- a/resources/visu_vtk/src/lib/commands/VisibilityManager.ts +++ b/resources/visu_vtk/src/lib/commands/VisibilityManager.ts @@ -104,7 +104,7 @@ export class VisibilityManager { if (fileGroup) { if (nowVisible) { fileGroup.actor.setVisibility(true); - const opacity = this.visibleGroupsByObject[object] > 0 ? 0.2 : 1.0; + const opacity = this.visibleGroupsByObject[object] > 0 ? GlobalSettings.Instance.groupTransparency : 1.0; fileGroup.setOpacity(opacity); } else { const hiddenOpacity = GlobalSettings.Instance.hiddenObjectOpacity; @@ -129,11 +129,21 @@ export class VisibilityManager { setTransparence(transparent: boolean, object: string): void { if (!this.groups || this.hiddenObjects[object]) { return; } - const meshOpacity = transparent ? 0.2 : 1; + const meshOpacity = transparent ? GlobalSettings.Instance.groupTransparency : 1; const group = this.groups[object]; group.setOpacity(meshOpacity); } + applyGroupTransparency(): void { + for (const object in this.visibleGroupsByObject) { + if (this.hiddenObjects[object]) continue; + if (this.visibleGroupsByObject[object] > 0) { + this.groups[object]?.setOpacity(GlobalSettings.Instance.groupTransparency); + } + } + VtkApp.Instance.getRenderWindow().render(); + } + hideAllOthers(object: string): void { for (const key in this.hiddenObjects) { if (key === object) continue; diff --git a/resources/visu_vtk/src/lib/data/Group.ts b/resources/visu_vtk/src/lib/data/Group.ts index 0248a9d..340182a 100644 --- a/resources/visu_vtk/src/lib/data/Group.ts +++ b/resources/visu_vtk/src/lib/data/Group.ts @@ -52,7 +52,7 @@ export class Group { } if (mode === 'show') { prop.setEdgeVisibility(true); - prop.setEdgeColor(0, 0, 0); + this._applyFlatEdgeColor(prop); return; } @@ -60,7 +60,7 @@ export class Group { if (mode === 'threshold') { prop.setEdgeVisibility(currentDistance < threshold); - prop.setEdgeColor(0, 0, 0); + this._applyFlatEdgeColor(prop); return; } @@ -69,10 +69,17 @@ export class Group { this._applyEdgeColor(); } + private _applyFlatEdgeColor(prop: any): void { + const op = GlobalSettings.Instance.edgeOpacity; + const [r, g, b] = prop.getColor(); + prop.setEdgeColor(r * (1 - op), g * (1 - op), b * (1 - op)); + } + private _applyEdgeColor(): void { const t = this._edgeT ?? 0; + const op = GlobalSettings.Instance.edgeOpacity; const [r, g, b] = this.actor.getProperty().getColor(); - this.actor.getProperty().setEdgeColor(r * (1 - t), g * (1 - t), b * (1 - t)); + this.actor.getProperty().setEdgeColor(r * (1 - t * op), g * (1 - t * op), b * (1 - t * op)); } setSize(distance: number): void { diff --git a/resources/visu_vtk/src/lib/interaction/CameraManager.ts b/resources/visu_vtk/src/lib/interaction/CameraManager.ts index 498d48f..0bd12c1 100644 --- a/resources/visu_vtk/src/lib/interaction/CameraManager.ts +++ b/resources/visu_vtk/src/lib/interaction/CameraManager.ts @@ -103,6 +103,11 @@ export class CameraManager { VtkApp.Instance.getRenderWindow().render(); } + setOrientationWidgetVisible(visible: boolean): void { + this.orientationWidget.setEnabled(visible); + VtkApp.Instance.getRenderWindow().render(); + } + setCameraAxis(axis: string): void { if (!this.camera) { return; } diff --git a/resources/visu_vtk/src/lib/settings/GlobalSettings.ts b/resources/visu_vtk/src/lib/settings/GlobalSettings.ts index 0976ef6..6f6aea3 100644 --- a/resources/visu_vtk/src/lib/settings/GlobalSettings.ts +++ b/resources/visu_vtk/src/lib/settings/GlobalSettings.ts @@ -35,6 +35,11 @@ export class GlobalSettings { hiddenObjectOpacity = 0; edgeMode: EdgeMode = 'threshold'; edgeThresholdMultiplier = 1; + specular = 0.3; + specularPower = 15; + ambientIntensity = 0.1; + groupTransparency = 0.2; + showOrientationWidget = true; get isDark(): boolean { return document.body.classList.contains('vscode-dark') || diff --git a/resources/visu_vtk/src/lib/state.ts b/resources/visu_vtk/src/lib/state.ts index 87d4c4f..f1cc5f5 100644 --- a/resources/visu_vtk/src/lib/state.ts +++ b/resources/visu_vtk/src/lib/state.ts @@ -10,6 +10,8 @@ export interface Settings { hiddenObjectOpacity: number; edgeMode: EdgeMode; edgeThresholdMultiplier: number; + groupTransparency: number; + showOrientationWidget: boolean; } export const groupHierarchy = writable({}); @@ -21,6 +23,8 @@ export const settings = writable({ hiddenObjectOpacity: 0, edgeMode: 'threshold', edgeThresholdMultiplier: 1, + groupTransparency: 0.2, + showOrientationWidget: true, }); // Map> — groups NOT shown in sidebar (hidden) diff --git a/resources/visu_vtk/src/main.ts b/resources/visu_vtk/src/main.ts index dfd514c..0f61415 100644 --- a/resources/visu_vtk/src/main.ts +++ b/resources/visu_vtk/src/main.ts @@ -2,6 +2,7 @@ import { mount } from 'svelte'; import App from './components/App.svelte'; import { Controller } from './lib/Controller'; import { VisibilityManager } from './lib/commands/VisibilityManager'; +import { CameraManager } from './lib/interaction/CameraManager'; import { GlobalSettings } from './lib/settings/GlobalSettings'; import { settings } from './lib/state'; import type { EdgeMode } from './lib/state'; @@ -28,21 +29,23 @@ window.addEventListener('message', async (e) => { Controller.Instance.loadFiles(body.fileContexts, body.objFilenames); if (body.settings) { const s = body.settings; - if (s.hiddenObjectOpacity !== undefined) { - GlobalSettings.Instance.hiddenObjectOpacity = s.hiddenObjectOpacity; - } - if (s.edgeMode !== undefined) { - GlobalSettings.Instance.edgeMode = s.edgeMode as EdgeMode; - } - if (s.edgeThresholdMultiplier !== undefined) { - GlobalSettings.Instance.edgeThresholdMultiplier = s.edgeThresholdMultiplier; - } + if (s.hiddenObjectOpacity !== undefined) GlobalSettings.Instance.hiddenObjectOpacity = s.hiddenObjectOpacity; + if (s.edgeMode !== undefined) GlobalSettings.Instance.edgeMode = s.edgeMode as EdgeMode; + if (s.edgeThresholdMultiplier !== undefined) GlobalSettings.Instance.edgeThresholdMultiplier = s.edgeThresholdMultiplier; + if (s.groupTransparency !== undefined) GlobalSettings.Instance.groupTransparency = s.groupTransparency; + if (s.showOrientationWidget !== undefined) GlobalSettings.Instance.showOrientationWidget = s.showOrientationWidget; 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, })); VisibilityManager.Instance.applyHiddenObjectOpacity(); + CameraManager.Instance.refreshEdgeVisibility(); + if (s.showOrientationWidget === false) { + CameraManager.Instance.setOrientationWidgetVisible(false); + } } break; } From 66ec0d5c46a9f668f514f11517e44334f23389e6 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Fri, 13 Mar 2026 10:44:50 +0100 Subject: [PATCH 09/21] Add pre-commits and CI checks for linting and formatting --- .../src/components/LoadingScreen.svelte | 2 + .../src/components/ObjectSection.svelte | 59 +++-- .../src/components/popups/HelpPopup.svelte | 222 +++++++++++++----- .../components/popups/SettingsPopup.svelte | 149 ++++++------ .../src/lib/commands/VisibilityManager.ts | 32 ++- resources/visu_vtk/src/lib/data/Group.ts | 7 +- .../src/lib/interaction/AxesCreator.ts | 80 +++++-- .../src/lib/interaction/CameraManager.ts | 15 +- .../src/lib/settings/GlobalSettings.ts | 63 ++--- resources/visu_vtk/src/lib/vtk.d.ts | 4 + resources/visu_vtk/src/main.ts | 12 +- 11 files changed, 403 insertions(+), 242 deletions(-) diff --git a/resources/visu_vtk/src/components/LoadingScreen.svelte b/resources/visu_vtk/src/components/LoadingScreen.svelte index 7193a44..2044fc3 100644 --- a/resources/visu_vtk/src/components/LoadingScreen.svelte +++ b/resources/visu_vtk/src/components/LoadingScreen.svelte @@ -1,3 +1,5 @@ + +
{ e.preventDefault(); closeContextMenu(); }} + oncontextmenu={(e) => { + e.preventDefault(); + closeContextMenu(); + }} role="presentation" >
@@ -64,29 +53,47 @@ style={activeTab === 'About' ? 'background: var(--ui-element-bg); color: var(--vscode-textLink-foreground, #0078d4); font-weight: 600;' : 'color: var(--ui-text-secondary);'} - onmouseenter={(e) => { if (activeTab !== 'About') (e.currentTarget as HTMLElement).style.background = 'var(--ui-element-bg)'; }} - onmouseleave={(e) => { if (activeTab !== 'About') (e.currentTarget as HTMLElement).style.background = ''; }} - onclick={() => activeTab = 'About'} + onmouseenter={(e) => { + if (activeTab !== 'About') + (e.currentTarget as HTMLElement).style.background = 'var(--ui-element-bg)'; + }} + onmouseleave={(e) => { + if (activeTab !== 'About') (e.currentTarget as HTMLElement).style.background = ''; + }} + onclick={() => (activeTab = 'About')} > About - v{__APP_VERSION__} + v{__APP_VERSION__}
- {#if activeTab === 'Camera'}
Drag to rotate - Ctrl+Drag to rotate around a single axis - Shift+Drag to pan + Ctrl+Drag to rotate around a single axis + Shift+Drag to pan Zoom in / out - - + + 2.0× @@ -95,12 +102,20 @@ Open the zoom level selector to choose a preset - - + + 2.0× - + @@ -112,73 +127,134 @@ Align camera along that axis — buttons at the bottom of the sidebar
- {:else if activeTab === 'Objects'}
- + - mesh - + mesh + Click the name to collapse or expand its groups - + mesh - + Click to show or hide an entire object - + mesh - + Right-click for more options — e.g. hide all other objects
- {:else if activeTab === 'Groups'}
- + group_A - Click a group to highlight it — the object becomes transparent so the group stands out. Click again to unhighlight. + Click a group to highlight it — the object becomes transparent so the group + stands out. Click again to unhighlight. - - + + - + - Click to choose which groups appear in the sidebar — hidden groups remain visible in the 3D view + Click to choose which groups appear in the sidebar — hidden groups remain + visible in the 3D view - - + + - + Click to clear all highlights
- {:else if activeTab === 'Files'}
-

.med mesh files are automatically converted to .obj when opened.

-

Converted files are cached in a hidden .visu_data/ folder in your workspace and reused on subsequent opens.

+

+ .med mesh files are automatically converted to .obj when opened. +

+

+ Converted files are cached in a hidden .visu_data/ folder in your workspace and + reused on subsequent opens. +

- {:else if activeTab === 'About'}
-

This extension is made by Simvia.

-

Source code and issue tracker are available on GitHub.

+

+ This extension is made by Simvia. +

+

+ Source code and issue tracker are available on GitHub. +

{/if} -
@@ -192,3 +268,19 @@
+ + diff --git a/resources/visu_vtk/src/components/popups/SettingsPopup.svelte b/resources/visu_vtk/src/components/popups/SettingsPopup.svelte index d27bdfc..57decad 100644 --- a/resources/visu_vtk/src/components/popups/SettingsPopup.svelte +++ b/resources/visu_vtk/src/components/popups/SettingsPopup.svelte @@ -1,52 +1,45 @@ {#snippet tip(text: string)} - + + {text} + {/snippet} @@ -219,29 +206,27 @@ style="width: min(36rem, 80vw); height: min(22rem, 70vh); color: var(--ui-fg); border: 1px solid var(--ui-border)" role="document" > -
+
- + {edgeThresholdDisplay.toPrecision(edgeThresholdDisplay < 1 ? 2 : 3)}×
@@ -293,7 +279,7 @@ class="w-full cursor-pointer focus:outline-none accent-(--vscode-textLink-foreground,#0078d4)" oninput={onEdgeThresholdInput} /> - Higher values show edges from farther away.
@@ -311,7 +297,7 @@ 'At 0% hidden objects are fully invisible. Above 0% they remain as faint ghosts.' )}
- {hiddenOpacityPct}%
@@ -325,7 +311,7 @@ class="w-full cursor-pointer focus:outline-none [accent-color:var(--vscode-textLink-foreground,#0078d4)]" oninput={onHiddenOpacityInput} /> - When hiding an object with the eye button, it can remain slightly visible as a ghost.
@@ -339,7 +325,7 @@ 'When a sub-group is highlighted, the parent mesh fades to this opacity so the selected group stands out.' )}
- {groupTransparencyPct}%
@@ -353,7 +339,7 @@ class="w-full cursor-pointer focus:outline-none [accent-color:var(--vscode-textLink-foreground,#0078d4)]" oninput={onGroupTransparencyInput} /> - Opacity of the parent mesh when a sub-group is highlighted. @@ -365,10 +351,9 @@ role="switch" aria-label="Show orientation widget" aria-checked={$settings.showOrientationWidget} - class="relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-150 focus:outline-none cursor-pointer" - style={$settings.showOrientationWidget - ? 'background: var(--vscode-textLink-foreground, #0078d4)' - : 'background: var(--ui-border)'} + class="relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-150 focus:outline-none cursor-pointer {$settings.showOrientationWidget + ? 'bg-ui-link' + : 'bg-ui-border'}" onclick={toggleOrientationWidget} > - Show the axes widget in the bottom-right corner. @@ -396,15 +381,13 @@
@@ -70,9 +73,7 @@ - + 2.0× @@ -84,14 +85,10 @@ - + 2.0× - + @@ -121,7 +118,8 @@ > mesh - @@ -197,8 +195,7 @@ href="https://simvia.tech" target="_blank" rel="noopener noreferrer" - class="text-ui-link underline" - >SimviaSimvia.

@@ -206,8 +203,7 @@ href="https://github.com/simvia-tech/vs-code-aster" target="_blank" rel="noopener noreferrer" - class="text-ui-link underline" - >GitHubGitHub.

diff --git a/resources/visu_vtk/src/components/popups/SettingsPopup.svelte b/resources/visu_vtk/src/components/popups/SettingsPopup.svelte index 325300b..acd0288 100644 --- a/resources/visu_vtk/src/components/popups/SettingsPopup.svelte +++ b/resources/visu_vtk/src/components/popups/SettingsPopup.svelte @@ -204,16 +204,13 @@ class="absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 w-[min(36rem,80vw)] h-[min(22rem,70vh)] rounded shadow-lg flex text-ui-fg border border-ui-border" role="document" > -
-