diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index c2d8b5d69..5bc4cdeee 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -3,6 +3,7 @@ import { nextTick } from 'vue' import Load3D from '@/components/load3d/Load3D.vue' import Load3DAnimation from '@/components/load3d/Load3DAnimation.vue' import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue' +import { createExportMenuOptions } from '@/extensions/core/load3d/exportMenuHelper' import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' @@ -297,6 +298,8 @@ useExtensionService().registerExtension({ await nextTick() useLoad3dService().waitForLoad3d(node, (load3d) => { + node.getExtraMenuOptions = createExportMenuOptions(load3d) + let cameraState = node.properties['Camera Info'] const config = new Load3DConfiguration(load3d) @@ -542,6 +545,8 @@ useExtensionService().registerExtension({ const onExecuted = node.onExecuted useLoad3dService().waitForLoad3d(node, (load3d) => { + node.getExtraMenuOptions = createExportMenuOptions(load3d) + const config = new Load3DConfiguration(load3d) const modelWidget = node.widgets?.find((w) => w.name === 'model_file') diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 6de946a2e..3935e5455 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -1,6 +1,6 @@ import * as THREE from 'three' -import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { CameraManager } from './CameraManager' @@ -22,6 +22,7 @@ import { type MaterialMode, type UpDirection } from './interfaces' +import { app } from '@/scripts/app' class Load3d { renderer: THREE.WebGLRenderer @@ -51,6 +52,13 @@ class Load3d { targetAspectRatio: number = 1 isViewerMode: boolean = false + // Context menu tracking + private rightMouseDownX: number = 0 + private rightMouseDownY: number = 0 + private rightMouseMoved: boolean = false + private readonly dragThreshold: number = 5 + private contextMenuAbortController: AbortController | null = null + constructor( container: Element | HTMLElement, options: Load3DOptions = { @@ -164,6 +172,8 @@ class Load3d { this.STATUS_MOUSE_ON_SCENE = false this.STATUS_MOUSE_ON_VIEWER = false + this.initContextMenu() + this.handleResize() this.startAnimation() @@ -172,6 +182,65 @@ class Load3d { }, 100) } + /** + * Initialize context menu on the Three.js canvas + * Detects right-click vs right-drag to show menu only on click + */ + private initContextMenu(): void { + const canvas = this.renderer.domElement + + this.contextMenuAbortController = new AbortController() + const { signal } = this.contextMenuAbortController + + const mousedownHandler = (e: MouseEvent) => { + if (e.button === 2) { + this.rightMouseDownX = e.clientX + this.rightMouseDownY = e.clientY + this.rightMouseMoved = false + } + } + + const mousemoveHandler = (e: MouseEvent) => { + if (e.buttons === 2) { + const dx = Math.abs(e.clientX - this.rightMouseDownX) + const dy = Math.abs(e.clientY - this.rightMouseDownY) + + if (dx > this.dragThreshold || dy > this.dragThreshold) { + this.rightMouseMoved = true + } + } + } + + const contextmenuHandler = (e: MouseEvent) => { + const wasDragging = this.rightMouseMoved + + this.rightMouseMoved = false + + if (wasDragging) { + return + } + + e.preventDefault() + e.stopPropagation() + + this.showNodeContextMenu(e) + } + + canvas.addEventListener('mousedown', mousedownHandler, { signal }) + canvas.addEventListener('mousemove', mousemoveHandler, { signal }) + canvas.addEventListener('contextmenu', contextmenuHandler, { signal }) + } + + private showNodeContextMenu(event: MouseEvent): void { + const menuOptions = app.canvas.getNodeMenuOptions(this.node) + + new LiteGraph.ContextMenu(menuOptions, { + event, + title: this.node.type, + extra: this.node + }) + } + getEventManager(): EventManager { return this.eventManager } @@ -621,6 +690,11 @@ class Load3d { } public remove(): void { + if (this.contextMenuAbortController) { + this.contextMenuAbortController.abort() + this.contextMenuAbortController = null + } + this.renderer.forceContextLoss() const canvas = this.renderer.domElement const event = new Event('webglcontextlost', { diff --git a/src/extensions/core/load3d/exportMenuHelper.ts b/src/extensions/core/load3d/exportMenuHelper.ts new file mode 100644 index 000000000..a47ec5eb5 --- /dev/null +++ b/src/extensions/core/load3d/exportMenuHelper.ts @@ -0,0 +1,62 @@ +import { t } from '@/i18n' +import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' +import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces' +import { useToastStore } from '@/platform/updates/common/toastStore' +import Load3d from '@/extensions/core/load3d/Load3d' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' + +const EXPORT_FORMATS = [ + { label: 'GLB', value: 'glb' }, + { label: 'OBJ', value: 'obj' }, + { label: 'STL', value: 'stl' } +] as const + +export function createExportMenuOptions( + load3d: Load3d +): ( + canvas: LGraphCanvas, + options: (IContextMenuValue | null)[] +) => (IContextMenuValue | null)[] { + return function ( + _canvas: LGraphCanvas, + options: (IContextMenuValue | null)[] + ): (IContextMenuValue | null)[] { + options.push(null, { + content: 'Save', + has_submenu: true, + callback: (_value, _options, event, prev_menu) => { + const submenuOptions: IContextMenuValue[] = EXPORT_FORMATS.map( + (format) => ({ + content: format.label, + callback: () => { + void (async () => { + try { + await load3d.exportModel(format.value) + useToastStore().add({ + severity: 'success', + summary: t('toastMessages.exportSuccess', { + format: format.label + }) + }) + } catch (error) { + console.error('Export failed:', error) + useToastStore().addAlert( + t('toastMessages.failedToExportModel', { + format: format.label + }) + ) + } + })() + } + }) + ) + + new LiteGraph.ContextMenu(submenuOptions, { + event, + parentMenu: prev_menu + }) + } + }) + return options + } +} diff --git a/src/extensions/core/saveMesh.ts b/src/extensions/core/saveMesh.ts index 3c84cf942..a7ffbac89 100644 --- a/src/extensions/core/saveMesh.ts +++ b/src/extensions/core/saveMesh.ts @@ -1,6 +1,7 @@ import { nextTick } from 'vue' import Load3D from '@/components/load3d/Load3D.vue' +import { createExportMenuOptions } from '@/extensions/core/load3d/exportMenuHelper' import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' @@ -60,6 +61,10 @@ useExtensionService().registerExtension({ const load3d = useLoad3dService().getLoad3d(node) + if (load3d) { + node.getExtraMenuOptions = createExportMenuOptions(load3d) + } + const modelWidget = node.widgets?.find((w) => w.name === 'image') if (load3d && modelWidget) { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index a1f4a608a..567919d2b 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1472,6 +1472,7 @@ "failedToApplyTexture": "Failed to apply texture", "no3dSceneToExport": "No 3D scene to export", "failedToExportModel": "Failed to export model as {format}", + "exportSuccess": "Successfully exported model as {format}", "fileLoadError": "Unable to find workflow in {fileName}", "dropFileError": "Unable to process dropped item: {error}", "interrupted": "Execution has been interrupted",