diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6f38b111b..073a9c5cf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,8 @@ jobs: - uses: actions/checkout@v4 - name: Pinned GitHub Action refs match CMakeLists.txt run: ./scripts/sync-doc-versions-from-cmake.sh --check + - name: File association packaging files present + run: chmod +x ./scripts/verify-file-associations.sh && ./scripts/verify-file-associations.sh #################################################################### # Asset Scan (runs first, before all builds) @@ -485,11 +487,33 @@ jobs: name: QtMeshEditor-${{github.ref_name}}-bin-Windows path: ${{github.workspace}}/bin + - name: Bundle Windows file-association helper + run: | + New-Item -ItemType Directory -Force -Path "${{github.workspace}}/bin/scripts" | Out-Null + Copy-Item "${{github.workspace}}/scripts/register-windows-file-associations.ps1" "${{github.workspace}}/bin/scripts/" + shell: powershell + - name: Compress File if: github.event_name == 'release' && github.event.action == 'published' run: Compress-Archive ${{github.workspace}}/bin QtMeshEditor-${{github.ref_name}}-bin-Windows.zip shell: powershell + - name: Build Windows installer (Inno Setup) + if: github.event_name == 'release' && github.event.action == 'published' + shell: powershell + run: | + choco install innosetup -y --no-progress + $isccPath = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" + if (-not (Test-Path $isccPath)) { + Write-Error "Inno Setup installation failed or ISCC.exe not found at $isccPath" + exit 1 + } + $version = "${{ github.ref_name }}".TrimStart("v") + $iss = Get-Content "${{github.workspace}}/packaging/windows/QtMeshEditor.iss" -Raw + $iss = $iss.Replace("@PROJECT_VERSION@", $version) + Set-Content -Path "${{github.workspace}}/packaging/windows/QtMeshEditor.build.iss" -Value $iss + & $isccPath "${{github.workspace}}/packaging/windows/QtMeshEditor.build.iss" + - name: Smoke-test CLI from zip if: github.event_name == 'release' && github.event.action == 'published' shell: powershell @@ -528,6 +552,17 @@ jobs: overwrite: false verbose: true + - name: Upload Windows installer to release + if: github.event_name == 'release' && github.event.action == 'published' + uses: xresloader/upload-to-github-release@main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + file: QtMeshEditor-${{github.ref_name}}-setup-Windows.exe + update_latest_release: true + overwrite: false + verbose: true + #################################################################### # Linux Deploy #################################################################### @@ -771,8 +806,14 @@ jobs: mkdir -p ./pack-deb/usr/share/qtmesheditor/platforms/ mkdir -p ./pack-deb/usr/lib/qtmesheditor/ mkdir -p ./pack-deb/usr/share/doc/qtmesheditor/ + mkdir -p ./pack-deb/usr/share/applications/ + mkdir -p ./pack-deb/usr/share/mime/packages/ mkdir ./pack-deb/DEBIAN/ cp ./bin/DEBIAN-control ./pack-deb/DEBIAN/control + cp ./packaging/linux/qtmesheditor.desktop ./pack-deb/usr/share/applications/ + cp ./packaging/linux/qtmesheditor-mimetypes.xml ./pack-deb/usr/share/mime/packages/ + cp ./packaging/linux/DEBIAN-postinst ./pack-deb/DEBIAN/postinst + chmod 755 ./pack-deb/DEBIAN/postinst cp ./bin/QtMeshEditor ./pack-deb/usr/share/qtmesheditor/qtmesheditor # Create proper launcher script @@ -2055,6 +2096,17 @@ jobs: sed -i "s/version '.*'/version '$VERSION'/" "$CASK_FILE" sed -i "s/sha256 '.*'/sha256 '$SHA256'/" "$CASK_FILE" + # Ensure Finder picks up CFBundleDocumentTypes after install (#664). + if ! grep -q 'lsregister' "$CASK_FILE"; then + { + echo '' + echo ' postflight do' + echo ' system_command "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",' + echo ' args: ["-f", "-R", "#{appdir}/QtMeshEditor.app"]' + echo ' end' + } >> "$CASK_FILE" + fi + echo "Updated cask file:" cat "$CASK_FILE" diff --git a/README.md b/README.md index 2a4f7e87e..767c8055b 100755 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Automate your 3D asset pipeline — scan, validate, convert, fix, and merge 3D assets with GUI + CLI + CI/CD support. +**Open 3D files from your OS** — after install, double-click `.fbx`, `.glb`, `.gltf`, `.obj`, `.dae`, `.stl`, `.ply`, `.mesh`, and PS1 `.rsd`/`.tmd` in Finder, Explorer, or your Linux file manager (or use **Open With**). QtMeshEditor loads the model in the running window; a second launch focuses the existing instance instead of spawning another process. Portable Windows ZIP users can run `bin/scripts/register-windows-file-associations.ps1` to register handlers without admin rights. + [![GitHub stars](https://img.shields.io/github/stars/fernandotonon/QtMeshEditor.svg?style=social&label=Star&maxAge=2592000)](https://GitHub.com/fernandotonon/QtMeshEditor/stargazers) Star if you like it! [![Github All Releases](https://img.shields.io/github/downloads/fernandotonon/QtMeshEditor/total.svg)]() diff --git a/packaging/linux/DEBIAN-postinst b/packaging/linux/DEBIAN-postinst new file mode 100644 index 000000000..dc8465b9b --- /dev/null +++ b/packaging/linux/DEBIAN-postinst @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +if command -v update-mime-database >/dev/null 2>&1; then + update-mime-database /usr/share/mime >/dev/null 2>&1 || true +fi +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database /usr/share/applications >/dev/null 2>&1 || true +fi diff --git a/packaging/linux/qtmesheditor-mimetypes.xml b/packaging/linux/qtmesheditor-mimetypes.xml new file mode 100644 index 000000000..7530c5c35 --- /dev/null +++ b/packaging/linux/qtmesheditor-mimetypes.xml @@ -0,0 +1,28 @@ + + + + Ogre mesh + + + + PlayStation RSD mesh + + + + PlayStation TMD mesh + + + + QtMeshEditor scene + + + + + Autodesk FBX model + + + + PLY model + + + diff --git a/packaging/linux/qtmesheditor.desktop b/packaging/linux/qtmesheditor.desktop new file mode 100644 index 000000000..89a9a6dc9 --- /dev/null +++ b/packaging/linux/qtmesheditor.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=QtMeshEditor +Comment=Open and inspect 3D models (FBX, glTF, OBJ, and more) +GenericName=3D Model Viewer +Keywords=3D;mesh;FBX;glTF;model;animation;viewer; +Exec=qtmesheditor %F +Icon=qtmesheditor +Terminal=false +Type=Application +Categories=Graphics;Viewer; +MimeType=model/gltf+json;model/gltf-binary;model/obj;model/stl;application/vnd.collada+xml;application/vnd.ms-fbx;application/x-ogre-mesh;application/x-ps1-rsd;application/x-ps1-tmd;application/x-qtmesheditor-scene;model/vnd.ply; diff --git a/packaging/macos/homebrew-cask-postflight.rb b/packaging/macos/homebrew-cask-postflight.rb new file mode 100644 index 000000000..ab6bf92e5 --- /dev/null +++ b/packaging/macos/homebrew-cask-postflight.rb @@ -0,0 +1,7 @@ +# Append to homebrew-qtmesheditor/Casks/qtmesheditor.rb so Finder picks up +# CFBundleDocumentTypes without a reboot: +# +# postflight do +# system_command "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", +# args: ["-f", "-R", "#{appdir}/QtMeshEditor.app"] +# end diff --git a/packaging/windows/QtMeshEditor.iss b/packaging/windows/QtMeshEditor.iss new file mode 100644 index 000000000..eebb95e9d --- /dev/null +++ b/packaging/windows/QtMeshEditor.iss @@ -0,0 +1,93 @@ +; Inno Setup script — per-user install with HKCU file associations (#664) +#define MyAppName "QtMeshEditor" +#define MyAppVersion "@PROJECT_VERSION@" +#define MyAppPublisher "Fernando Tonon" +#define MyAppURL "https://github.com/fernandotonon/QtMeshEditor" +#define MyAppExeName "QtMeshEditor.exe" + +[Setup] +AppId={{A4B8D6F2-3C1E-4F9A-9B2D-664E8A3F9B2D} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL}/issues +AppUpdatesURL={#MyAppURL}/releases +DefaultDirName={localappdata}\Programs\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +OutputBaseFilename=QtMeshEditor-{#MyAppVersion}-setup-Windows +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "fileassoc"; Description: "Register QtMeshEditor as an ""Open with"" handler for 3D model files"; GroupDescription: "File associations:"; Flags: checkedonce + +[Files] +Source: "..\..\bin\*"; DestDir: "{app}\bin"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\bin\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\bin\{#MyAppExeName}" + +[Registry] +; Open With only — do not replace the user's default ProgID for each extension. +Root: HKCU; Subkey: "Software\Classes\.fbx\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.fbx"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.fbx"; ValueType: string; ValueName: ""; ValueData: "FBX 3D Model"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.fbx\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.fbx\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +Root: HKCU; Subkey: "Software\Classes\.glb\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.glb"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.glb"; ValueType: string; ValueName: ""; ValueData: "glTF Binary Model"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.glb\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.glb\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +Root: HKCU; Subkey: "Software\Classes\.gltf\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.gltf"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.gltf"; ValueType: string; ValueName: ""; ValueData: "glTF Model"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.gltf\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.gltf\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +Root: HKCU; Subkey: "Software\Classes\.obj\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.obj"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.obj"; ValueType: string; ValueName: ""; ValueData: "Wavefront OBJ Model"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.obj\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.obj\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +Root: HKCU; Subkey: "Software\Classes\.dae\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.dae"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.dae"; ValueType: string; ValueName: ""; ValueData: "Collada Model"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.dae\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.dae\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +Root: HKCU; Subkey: "Software\Classes\.stl\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.stl"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.stl"; ValueType: string; ValueName: ""; ValueData: "STL Model"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.stl\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.stl\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +Root: HKCU; Subkey: "Software\Classes\.ply\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.ply"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.ply"; ValueType: string; ValueName: ""; ValueData: "PLY Model"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.ply\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.ply\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +Root: HKCU; Subkey: "Software\Classes\.mesh\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.mesh"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.mesh"; ValueType: string; ValueName: ""; ValueData: "Ogre Mesh"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.mesh\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.mesh\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +Root: HKCU; Subkey: "Software\Classes\.rsd\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.rsd"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.rsd"; ValueType: string; ValueName: ""; ValueData: "PlayStation RSD Mesh"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.rsd\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.rsd\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +Root: HKCU; Subkey: "Software\Classes\.tmd\OpenWithProgids"; ValueType: string; ValueName: "QtMeshEditor.Model.tmd"; ValueData: ""; Tasks: fileassoc; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.tmd"; ValueType: string; ValueName: ""; ValueData: "PlayStation TMD Mesh"; Tasks: fileassoc; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.tmd\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\bin\{#MyAppExeName},0"; Tasks: fileassoc +Root: HKCU; Subkey: "Software\Classes\QtMeshEditor.Model.tmd\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\bin\{#MyAppExeName}"" ""%1"""; Tasks: fileassoc + +[Run] +Filename: "{app}\bin\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent diff --git a/scripts/register-windows-file-associations.ps1 b/scripts/register-windows-file-associations.ps1 new file mode 100644 index 000000000..95b2387f2 --- /dev/null +++ b/scripts/register-windows-file-associations.ps1 @@ -0,0 +1,63 @@ +# Register QtMeshEditor as a per-user "Open with" handler for common 3D formats. +# Does not change the user's default app for each extension. +# Usage (from extracted zip or installed bin folder): +# pwsh -File scripts/register-windows-file-associations.ps1 +# pwsh -File scripts/register-windows-file-associations.ps1 -BinDir "C:\path\to\bin" + +param( + [string]$BinDir +) + +$ErrorActionPreference = "Stop" + +function Resolve-QtMeshEditorBinDir { + param([string]$Requested) + if ($Requested) { return $Requested } + + $portableBin = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") -ErrorAction SilentlyContinue).Path + $repoBin = Join-Path $PSScriptRoot "..\bin" + if ($portableBin -and (Test-Path (Join-Path $portableBin "QtMeshEditor.exe"))) { + return $portableBin + } + if (Test-Path (Join-Path $repoBin "QtMeshEditor.exe")) { + return (Resolve-Path -LiteralPath $repoBin).Path + } + return $repoBin +} + +$BinDir = Resolve-QtMeshEditorBinDir -Requested $BinDir +$exe = Join-Path $BinDir "QtMeshEditor.exe" +if (-not (Test-Path $exe)) { + Write-Error "QtMeshEditor.exe not found at $exe" +} + +$extensions = @{ + ".fbx" = "FBX 3D Model" + ".glb" = "glTF Binary Model" + ".gltf" = "glTF Model" + ".obj" = "Wavefront OBJ Model" + ".dae" = "Collada Model" + ".stl" = "STL Model" + ".ply" = "PLY Model" + ".mesh" = "Ogre Mesh" + ".rsd" = "PlayStation RSD Mesh" + ".tmd" = "PlayStation TMD Mesh" +} + +foreach ($entry in $extensions.GetEnumerator()) { + $ext = $entry.Key + $label = $entry.Value + $progId = "QtMeshEditor.Model.$($ext.TrimStart('.'))" + $openWithKey = "HKCU:\Software\Classes\$ext\OpenWithProgids" + New-Item -Path $openWithKey -Force | Out-Null + New-ItemProperty -Path $openWithKey -Name $progId -PropertyType String -Value "" -Force | Out-Null + New-Item -Path "HKCU:\Software\Classes\$progId" -Force | Out-Null + Set-ItemProperty -Path "HKCU:\Software\Classes\$progId" -Name "(default)" -Value $label + New-Item -Path "HKCU:\Software\Classes\$progId\DefaultIcon" -Force | Out-Null + Set-ItemProperty -Path "HKCU:\Software\Classes\$progId\DefaultIcon" -Name "(default)" -Value "$exe,0" + New-Item -Path "HKCU:\Software\Classes\$progId\shell\open\command" -Force | Out-Null + Set-ItemProperty -Path "HKCU:\Software\Classes\$progId\shell\open\command" -Name "(default)" -Value "`"$exe`" `"%1`"" + Write-Host "Registered $ext in OpenWithProgids -> $progId" +} + +Write-Host "Done. QtMeshEditor should appear in Explorer 'Open with' for registered extensions." diff --git a/scripts/update-winget.sh b/scripts/update-winget.sh index 809edfdd0..d53644b42 100755 --- a/scripts/update-winget.sh +++ b/scripts/update-winget.sh @@ -24,7 +24,7 @@ echo "=== Updating WinGet manifest for ${PKG_ID} v${VERSION} ===" # Compute SHA256 echo "Downloading and computing SHA256..." -SHA256=$(curl -sL "${ZIP_URL}" | shasum -a 256 | cut -d' ' -f1 | tr 'a-f' 'A-F') +SHA256=$(curl -fsSL "${ZIP_URL}" | shasum -a 256 | cut -d' ' -f1 | tr 'a-f' 'A-F') if [ -z "${SHA256}" ]; then echo "ERROR: Failed to compute SHA256. Is the release published?" exit 1 @@ -90,6 +90,17 @@ NestedInstallerFiles: PortableCommandAlias: qtmesheditor - RelativeFilePath: bin\\qtmesh.exe PortableCommandAlias: qtmesh +FileExtensions: + - fbx + - glb + - gltf + - obj + - dae + - stl + - ply + - mesh + - rsd + - tmd Installers: - Architecture: x64 InstallerUrl: ${ZIP_URL} diff --git a/scripts/verify-file-associations.sh b/scripts/verify-file-associations.sh new file mode 100755 index 000000000..800fb4fb4 --- /dev/null +++ b/scripts/verify-file-associations.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Smoke-check that file-association packaging files exist and list expected extensions. +# Full OS verification still requires manual VM tests (issue #669). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FAIL=0 + +check_contains() { + local file="$1" + local needle="$2" + if ! grep -Fq -- "$needle" "$file"; then + echo "verify-file-associations: missing '$needle' in $file" >&2 + FAIL=1 + fi +} + +echo "=== macOS Info.plist ===" +PLIST="$ROOT/src/Info.plist.in" +for ext in fbx glb gltf obj dae stl ply mesh rsd tmd; do + check_contains "$PLIST" "${ext}" +done +check_contains "$PLIST" "CFBundleDocumentTypes" +check_contains "$PLIST" "UTExportedTypeDeclarations" + +echo "=== Linux desktop + MIME ===" +DESKTOP="$ROOT/packaging/linux/qtmesheditor.desktop" +MIME="$ROOT/packaging/linux/qtmesheditor-mimetypes.xml" +check_contains "$DESKTOP" "Exec=qtmesheditor %F" +check_contains "$DESKTOP" "MimeType=" +check_contains "$MIME" "application/x-ogre-mesh" +check_contains "$MIME" "application/vnd.ms-fbx" + +echo "=== Windows packaging ===" +check_contains "$ROOT/packaging/windows/QtMeshEditor.iss" "OpenWithProgids" +check_contains "$ROOT/packaging/windows/QtMeshEditor.iss" "QtMeshEditor.Model.fbx" +check_contains "$ROOT/scripts/register-windows-file-associations.ps1" "OpenWithProgids" +check_contains "$ROOT/scripts/register-windows-file-associations.ps1" "QtMeshEditor.Model." + +echo "=== Qt launch handler ===" +check_contains "$ROOT/src/AppLaunchHandler.h" "kServerName" +check_contains "$ROOT/src/main.cpp" "AppLaunchHandler" + +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi +echo "verify-file-associations: OK" diff --git a/snap/gui/qtmesheditor.desktop b/snap/gui/qtmesheditor.desktop index f4d452211..bf29809f7 100644 --- a/snap/gui/qtmesheditor.desktop +++ b/snap/gui/qtmesheditor.desktop @@ -1,9 +1,11 @@ [Desktop Entry] Name=QtMeshEditor -Comment=Free 3D asset tool — FBX/glTF/materials/MCP CLI -Keywords=3D;mesh;FBX;glTF;model;material;animation;Ogre;editor;development; -Exec=qtmesheditor %U +Comment=Open and inspect 3D models (FBX, glTF, OBJ, and more) +GenericName=3D Model Viewer +Keywords=3D;mesh;FBX;glTF;model;material;animation;viewer; +Exec=qtmesheditor %F Icon=${SNAP}/meta/gui/icon.png Terminal=false Type=Application -Categories=Graphics; +Categories=Graphics;Viewer; +MimeType=model/gltf+json;model/gltf-binary;model/obj;model/stl;application/vnd.collada+xml;application/vnd.ms-fbx;application/x-ogre-mesh;application/x-ps1-rsd;application/x-ps1-tmd;application/x-qtmesheditor-scene;model/vnd.ply; diff --git a/src/AppLaunchHandler.cpp b/src/AppLaunchHandler.cpp new file mode 100644 index 000000000..e4d185925 --- /dev/null +++ b/src/AppLaunchHandler.cpp @@ -0,0 +1,207 @@ +#include "AppLaunchHandler.h" + +#include "Manager.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +bool isCliSubcommand(const QString& arg) +{ + static const QStringList kSubcommands = { + QStringLiteral("info"), QStringLiteral("fix"), QStringLiteral("convert"), + QStringLiteral("anim"), QStringLiteral("validate"), QStringLiteral("lod"), + QStringLiteral("pose"), QStringLiteral("turntable"), QStringLiteral("scan"), + QStringLiteral("material"), QStringLiteral("pack-textures"), + QStringLiteral("normal-from-height"), QStringLiteral("memory"), + QStringLiteral("analyze"), QStringLiteral("vertex-cache"), + QStringLiteral("decimate"), QStringLiteral("atlas"), QStringLiteral("atlas-apply"), + QStringLiteral("optimize"), QStringLiteral("bake-vertex-colors"), + QStringLiteral("vat"), QStringLiteral("uv"), QStringLiteral("retopo"), + QStringLiteral("skin"), QStringLiteral("morph"), QStringLiteral("nodeanim"), + }; + return kSubcommands.contains(arg); +} + +bool isGuiModeFlag(const QString& arg) +{ + return arg == QStringLiteral("--mcp") || arg == QStringLiteral("-mcp") + || arg == QStringLiteral("--with-mcp") || arg == QStringLiteral("--http-port"); +} + +} // namespace + +AppLaunchHandler::AppLaunchHandler(QObject* parent) + : QObject(parent) +{ + if (qobject_cast(QCoreApplication::instance())) + QCoreApplication::instance()->installEventFilter(this); +} + +AppLaunchHandler::~AppLaunchHandler() +{ + if (m_server) { + m_server->close(); + QLocalServer::removeServer(QLatin1String(kServerName)); + } +} + +bool AppLaunchHandler::isCliInvocation(int argc, char* argv[]) +{ + const QString execName = QFileInfo(QString::fromLocal8Bit(argv[0])).fileName().toLower(); + if (execName.startsWith(QStringLiteral("qtmesh")) && !execName.contains(QStringLiteral("editor"))) + return true; + + for (int i = 1; i < argc; ++i) { + const QString arg = QString::fromLocal8Bit(argv[i]); + if (arg == QStringLiteral("--cli") || arg == QStringLiteral("--help") + || arg == QStringLiteral("-h") || arg == QStringLiteral("--version") + || arg == QStringLiteral("-v")) { + return true; + } + } + + for (int i = 1; i < argc; ++i) { + const QString arg = QString::fromLocal8Bit(argv[i]); + if (arg.startsWith(QLatin1Char('-'))) + continue; + if (isCliSubcommand(arg)) + return true; + break; + } + return false; +} + +bool AppLaunchHandler::isImportableMeshPath(const QString& path) +{ + const QString lower = path.toLower(); + if (lower.endsWith(QStringLiteral(".scene.glb")) + || lower.endsWith(QStringLiteral(".scene.gltf"))) { + return true; + } + + const QStringList extensions = + Manager::defaultImportExtensions().split(QLatin1Char(' '), Qt::SkipEmptyParts); + for (const QString& ext : extensions) { + if (lower.endsWith(ext, Qt::CaseInsensitive)) + return true; + } + return false; +} + +QStringList AppLaunchHandler::collectGuiLaunchPaths(const QStringList& arguments) +{ + QStringList paths; + for (int i = 1; i < arguments.size(); ++i) { + const QString& arg = arguments.at(i); + if (arg.startsWith(QLatin1Char('-'))) + continue; + if (isGuiModeFlag(arg)) { + if (arg == QStringLiteral("--http-port") && i + 1 < arguments.size()) + ++i; + continue; + } + if (isCliSubcommand(arg)) + break; + + const QFileInfo info(arg); + if (!isImportableMeshPath(arg)) + continue; + if (!info.exists() || !info.isFile() || !info.isReadable()) + continue; + paths.append(info.absoluteFilePath()); + } + return paths; +} + +bool AppLaunchHandler::tryForwardToRunningInstance(const QStringList& paths) +{ + if (paths.isEmpty()) + return false; + + QLocalSocket socket; + socket.connectToServer(QLatin1String(kServerName)); + if (!socket.waitForConnected(750)) + return false; + + QByteArray payload; + { + QDataStream out(&payload, QIODevice::WriteOnly); + out.setVersion(QDataStream::Qt_6_0); + out << paths; + } + socket.write(payload); + socket.flush(); + socket.waitForBytesWritten(1500); + socket.disconnectFromServer(); + return true; +} + +bool AppLaunchHandler::startSingleInstanceServer() +{ + if (m_server) + return true; + + QLocalServer::removeServer(QLatin1String(kServerName)); + m_server = new QLocalServer(this); + if (!m_server->listen(QLatin1String(kServerName))) + return false; + + connect(m_server, &QLocalServer::newConnection, this, [this]() { + while (m_server->hasPendingConnections()) { + QLocalSocket* socket = m_server->nextPendingConnection(); + connect(socket, &QLocalSocket::readyRead, this, [this, socket]() { + const QByteArray payload = socket->readAll(); + if (payload.isEmpty()) + return; + QDataStream in(payload); + in.setVersion(QDataStream::Qt_6_0); + QStringList paths; + in >> paths; + handleIncomingPaths(paths); + socket->disconnectFromServer(); + socket->deleteLater(); + }); + } + }); + return true; +} + +void AppLaunchHandler::handleIncomingPaths(const QStringList& paths) +{ + QStringList accepted; + for (const QString& path : paths) { + if (!isImportableMeshPath(path)) + continue; + const QFileInfo info(path); + if (!info.exists() || !info.isFile()) + continue; + accepted.append(info.absoluteFilePath()); + } + if (!accepted.isEmpty()) { + SentryReporter::addBreadcrumb(QStringLiteral("app.launch.file_open"), + QStringLiteral("Received %1 file(s) via launch handler").arg(accepted.size())); + emit filesRequested(accepted); + } +} + +bool AppLaunchHandler::eventFilter(QObject* watched, QEvent* event) +{ + if (event->type() == QEvent::FileOpen) { + auto* openEvent = static_cast(event); + const QString path = openEvent->file(); + if (!path.isEmpty() && isImportableMeshPath(path)) { + SentryReporter::addBreadcrumb(QStringLiteral("app.launch.file_open"), + QStringLiteral("macOS FileOpen: %1").arg(QFileInfo(path).fileName())); + handleIncomingPaths({QFileInfo(path).absoluteFilePath()}); + return true; + } + } + return QObject::eventFilter(watched, event); +} diff --git a/src/AppLaunchHandler.h b/src/AppLaunchHandler.h new file mode 100644 index 000000000..885a8e5c9 --- /dev/null +++ b/src/AppLaunchHandler.h @@ -0,0 +1,49 @@ +#ifndef APPLAUNCHHANDLER_H +#define APPLAUNCHHANDLER_H + +#include +#include + +class QLocalServer; +class QEvent; + +/// Routes OS file-open requests (argv, Finder QFileOpenEvent, second-instance +/// socket) into the running GUI. CLI activation rules in main.cpp take precedence. +class AppLaunchHandler : public QObject +{ + Q_OBJECT + +public: + static constexpr const char* kServerName = "QtMeshEditorSingleInstance-v1"; + + explicit AppLaunchHandler(QObject* parent = nullptr); + ~AppLaunchHandler() override; + + /// Mirrors main.cpp CLI detection: true when CLIPipeline should run. + static bool isCliInvocation(int argc, char* argv[]); + + /// Positional mesh/scene paths from QApplication::arguments() (flags skipped). + static QStringList collectGuiLaunchPaths(const QStringList& arguments); + + /// Extension check against Manager::defaultImportExtensions() (+ scene.glb). + static bool isImportableMeshPath(const QString& path); + + /// If another GUI instance is running, forward paths and return true (caller exits). + bool tryForwardToRunningInstance(const QStringList& paths); + + /// Listen for subsequent launches. Emits filesRequested when paths arrive. + bool startSingleInstanceServer(); + +signals: + void filesRequested(const QStringList& paths); + +protected: + bool eventFilter(QObject* watched, QEvent* event) override; + +private: + void handleIncomingPaths(const QStringList& paths); + + QLocalServer* m_server = nullptr; +}; + +#endif // APPLAUNCHHANDLER_H diff --git a/src/AppLaunchHandler_test.cpp b/src/AppLaunchHandler_test.cpp new file mode 100644 index 000000000..06d59c71a --- /dev/null +++ b/src/AppLaunchHandler_test.cpp @@ -0,0 +1,80 @@ +#include "AppLaunchHandler.h" +#include "Manager.h" + +#include +#include +#include + +namespace { + +char arg0[] = "QtMeshEditor"; +char argInfo[] = "info"; +char argModel[] = "hero.fbx"; +char argHelp[] = "--help"; + +TEST(AppLaunchHandlerTest, IsCliInvocation_Subcommand) +{ + char* argv[] = {arg0, argInfo, argModel, nullptr}; + EXPECT_TRUE(AppLaunchHandler::isCliInvocation(3, argv)); +} + +TEST(AppLaunchHandlerTest, IsCliInvocation_HelpFlag) +{ + char* argv[] = {arg0, argHelp, nullptr}; + EXPECT_TRUE(AppLaunchHandler::isCliInvocation(2, argv)); +} + +TEST(AppLaunchHandlerTest, IsCliInvocation_GuiModelPath) +{ + char* argv[] = {arg0, argModel, nullptr}; + EXPECT_FALSE(AppLaunchHandler::isCliInvocation(2, argv)); +} + +TEST(AppLaunchHandlerTest, IsImportableMeshPath_KnownExtensions) +{ + EXPECT_TRUE(AppLaunchHandler::isImportableMeshPath(QStringLiteral("/tmp/hero.fbx"))); + EXPECT_TRUE(AppLaunchHandler::isImportableMeshPath(QStringLiteral("/tmp/level.mesh"))); + EXPECT_TRUE(AppLaunchHandler::isImportableMeshPath(QStringLiteral("/tmp/scene.scene.glb"))); + EXPECT_FALSE(AppLaunchHandler::isImportableMeshPath(QStringLiteral("/tmp/readme.pdf"))); +} + +TEST(AppLaunchHandlerTest, CollectGuiLaunchPaths_SkipsFlagsAndSubcommands) +{ + const QStringList args = { + QStringLiteral("QtMeshEditor"), + QStringLiteral("--with-mcp"), + QStringLiteral("scan"), + QStringLiteral("./assets"), + }; + EXPECT_TRUE(AppLaunchHandler::collectGuiLaunchPaths(args).isEmpty()); +} + +TEST(AppLaunchHandlerTest, CollectGuiLaunchPaths_ReadsExistingMeshFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString meshPath = dir.filePath(QStringLiteral("cube.obj")); + QFile obj(meshPath); + if (!obj.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + GTEST_SKIP() << "Could not create temp OBJ"; + } + obj.write("o cube\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"); + obj.close(); + + const QStringList args = { + QStringLiteral("QtMeshEditor"), + QStringLiteral("--verbose"), + meshPath, + }; + const QStringList paths = AppLaunchHandler::collectGuiLaunchPaths(args); + ASSERT_EQ(paths.size(), 1); + EXPECT_EQ(paths.front(), QFileInfo(meshPath).absoluteFilePath()); +} + +TEST(AppLaunchHandlerTest, DefaultImportExtensions_AlignsWithManager) +{ + EXPECT_FALSE(Manager::defaultImportExtensions().isEmpty()); + EXPECT_TRUE(AppLaunchHandler::isImportableMeshPath(QStringLiteral("x.glb"))); +} + +} // namespace diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f893c7904..168e415ee 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,6 +5,7 @@ set(SRC_FILES about.cpp AppConsoleLog.cpp +AppLaunchHandler.cpp AnimationBlender.cpp AnimationControlController.cpp CurveEditModel.cpp @@ -146,6 +147,7 @@ GlobalDefinitions.h Euler.h about.h AppConsoleLog.h +AppLaunchHandler.h AppSettingsKeys.h mainwindow.h Manager.h diff --git a/src/Info.plist.in b/src/Info.plist.in index 511c75948..6920a7943 100644 --- a/src/Info.plist.in +++ b/src/Info.plist.in @@ -42,5 +42,186 @@ NSSupportsAutomaticGraphicsSwitching + + CFBundleDocumentTypes + + + CFBundleTypeName + FBX 3D Model + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + fbx + + + CFBundleTypeName + glTF 2.0 Model + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + + gltf + glb + gltf2 + glb2 + + + + CFBundleTypeName + Wavefront OBJ Model + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + obj + + + CFBundleTypeName + Collada Model + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + dae + + + CFBundleTypeName + STL Model + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + stl + + + CFBundleTypeName + PLY Model + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + ply + + + CFBundleTypeName + VRM Avatar + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + vrm + + + CFBundleTypeName + 3D Studio Model + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + 3ds + + + CFBundleTypeName + QtMesh Scene + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + + scene.glb + scene.gltf + + + + CFBundleTypeName + Ogre Mesh + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + com.qtmesheditor.mesh + CFBundleTypeExtensions + mesh + + + CFBundleTypeName + PlayStation RSD Mesh + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + com.qtmesheditor.ps1-rsd + CFBundleTypeExtensions + rsd + + + CFBundleTypeName + PlayStation TMD Mesh + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + com.qtmesheditor.ps1-tmd + CFBundleTypeExtensions + tmd + + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.qtmesheditor.mesh + UTTypeDescription + Ogre Mesh + UTTypeConformsTo + public.3d-content + UTTypeTagSpecification + + public.filename-extension + mesh + + + + UTTypeIdentifier + com.qtmesheditor.ps1-rsd + UTTypeDescription + PlayStation RSD Mesh + UTTypeConformsTo + public.3d-content + UTTypeTagSpecification + + public.filename-extension + rsd + + + + UTTypeIdentifier + com.qtmesheditor.ps1-tmd + UTTypeDescription + PlayStation TMD Mesh + UTTypeConformsTo + public.3d-content + UTTypeTagSpecification + + public.filename-extension + tmd + + + \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 74e2482b2..fceedaa40 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -29,6 +29,7 @@ #include "SentryReporter.h" #include "CLIPipeline.h" #include "AppConsoleLog.h" +#include "AppLaunchHandler.h" #ifndef Q_OS_WIN #include @@ -66,39 +67,7 @@ int main(int argc, char *argv[]) #endif // CLI pipeline mode detection — check before creating QApplication - { - bool cliMode = false; - QString execName = QFileInfo(QString(argv[0])).fileName().toLower(); - if (execName.startsWith("qtmesh") && !execName.contains("editor")) { - cliMode = true; - } - for (int i = 1; i < argc; ++i) { - QString arg(argv[i]); - if (arg == "--cli" || arg == "--help" || arg == "-h" || - arg == "--version" || arg == "-v") { - cliMode = true; - break; - } - } - if (!cliMode) { - for (int i = 1; i < argc; ++i) { - QString arg(argv[i]); - if (arg.startsWith("-")) - continue; // skip flags like --verbose - if (arg == "info" || arg == "fix" || arg == "convert" || arg == "anim" - || arg == "validate" || arg == "lod" || arg == "pose" || arg == "turntable" - || arg == "scan" || arg == "material" || arg == "pack-textures" - || arg == "normal-from-height" || arg == "memory" - || arg == "analyze" || arg == "vertex-cache" - || arg == "decimate" || arg == "atlas" || arg == "atlas-apply" - || arg == "optimize" || arg == "bake-vertex-colors" - || arg == "vat" || arg == "uv" || arg == "retopo" - || arg == "skin" || arg == "morph" || arg == "nodeanim") - cliMode = true; - break; // first non-flag arg determines mode - } - } - if (cliMode) { + if (AppLaunchHandler::isCliInvocation(argc, argv)) { #ifdef Q_OS_WIN // QtMeshEditor.exe is a WIN32 GUI subsystem executable — it has no // console by default. Reattach to the parent console (PowerShell/cmd) @@ -111,8 +80,7 @@ int main(int argc, char *argv[]) freopen("CONOUT$", "w", stderr); } #endif - return CLIPipeline::run(argc, argv); - } + return CLIPipeline::run(argc, argv); } // Check for MCP server mode before creating QApplication @@ -259,10 +227,23 @@ int main(int argc, char *argv[]) return ThemeManager::qmlInstance(engine, scriptEngine); }); - // Show welcome dialog before creating MainWindow + const QStringList launchPaths = AppLaunchHandler::collectGuiLaunchPaths(a.arguments()); + AppLaunchHandler launchHandler; + if (!launchPaths.isEmpty() && launchHandler.tryForwardToRunningInstance(launchPaths)) + return 0; + launchHandler.startSingleInstanceServer(); + + // Buffer OS file requests until MainWindow exists (welcome dialog is modal). + QStringList queuedLaunchPaths = launchPaths; + QObject::connect(&launchHandler, &AppLaunchHandler::filesRequested, &a, + [&queuedLaunchPaths](const QStringList& paths) { + queuedLaunchPaths.append(paths); + }); + + // Show welcome dialog before creating MainWindow (skip when OS opened a file) QString welcomeOpenFile; bool welcomeNewScene = false; - if (WelcomeDialog::shouldShow()) { + if (launchPaths.isEmpty() && WelcomeDialog::shouldShow()) { WelcomeDialog welcome; welcome.exec(); if (welcome.userAction() == WelcomeDialog::OpenFile || @@ -280,8 +261,15 @@ int main(int argc, char *argv[]) MainWindow w; w.show(); - // Act on welcome dialog choice - if (!welcomeOpenFile.isEmpty()) { + QObject::disconnect(&launchHandler, &AppLaunchHandler::filesRequested, nullptr, nullptr); + QObject::connect(&launchHandler, &AppLaunchHandler::filesRequested, &w, + &MainWindow::openLaunchFiles); + + if (!queuedLaunchPaths.isEmpty()) { + QTimer::singleShot(0, &w, [&w, queuedLaunchPaths]() { + w.openLaunchFiles(queuedLaunchPaths); + }); + } else if (!welcomeOpenFile.isEmpty()) { QTimer::singleShot(0, &w, [&w, welcomeOpenFile]() { w.loadFile(welcomeOpenFile); }); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 1429fa420..ffd7f948b 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -3631,6 +3631,22 @@ void MainWindow::loadFile(const QString& filePath) mUriList.append(filePath); } +void MainWindow::openLaunchFiles(const QStringList& paths) +{ + if (paths.isEmpty()) + return; + + show(); + raise(); + activateWindow(); + + for (const QString& path : paths) { + SentryReporter::addBreadcrumb(QStringLiteral("app.launch.file_open"), + QFileInfo(path).fileName()); + loadFile(path); + } +} + void MainWindow::importMeshs(const QStringList &_uriList) { auto txn = SentryReporter::startTransaction("ui.import", "file.import"); diff --git a/src/mainwindow.h b/src/mainwindow.h index 634fc1b9a..44184a501 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -63,6 +63,8 @@ class MainWindow : public QMainWindow, public Ogre::FrameListener virtual ~MainWindow(); void importMeshs(const QStringList &_uriList); void loadFile(const QString& filePath); + /// Focus the window and queue one or more OS launch paths for import. + void openLaunchFiles(const QStringList& paths); void setMCPServer(MCPServer* server); /// Recreate Ogre render windows (e.g. after MSAA samples change in Preferences). diff --git a/website/src/DocsApp.jsx b/website/src/DocsApp.jsx index c6f65ea37..a8acee21c 100644 --- a/website/src/DocsApp.jsx +++ b/website/src/DocsApp.jsx @@ -5,6 +5,7 @@ import useQtmeshActionRef from './hooks/useQtmeshActionRef'; const NAV = [ { section: 'Getting Started', items: [ { id: 'installation', label: 'Installation' }, + { id: 'file-associations', label: 'Open 3D Files' }, { id: 'quick-start', label: 'Quick Start' }, { id: 'playstation-rsd-ply', label: 'PlayStation RSD / Psy-Q PLY' }, ]}, @@ -205,6 +206,42 @@ cmake --build build --target QtMeshEditor -j4 # The 'qtmesh' symlink is created automatically`} +
+

Open 3D Files from the OS

+

+ QtMeshEditor registers as a document handler on macOS, Windows, and Linux. After install, + double-click supported models or use your file manager's Open With menu. + Launching with a file path loads it in the GUI; if the app is already running, the existing + window is focused and the new file is imported (single-instance). +

+

App import support

+

+ The editor can import many formats via drag-and-drop or QtMeshEditor path/to/model.ext, + including .vrm, .3ds, and additional Assimp types beyond the OS registration + sets below. +

+

Registered Open With handlers

+
    +
  • macOS.fbx, .glb/.gltf, .obj, + .dae, .stl, .ply, .vrm, .3ds, + .scene.glb/.scene.gltf, .mesh, .rsd, .tmd + via CFBundleDocumentTypes; Homebrew cask runs lsregister after install.
  • +
  • Windows.fbx, .glb, .gltf, .obj, + .dae, .stl, .ply, .mesh, .rsd, .tmd + added to OpenWithProgids (not default-app takeover). Inno Setup installer on release; + portable ZIP includes bin/scripts/register-windows-file-associations.ps1.
  • +
  • Linux.fbx, .glb/.gltf, .obj, + .dae, .stl, .ply, .mesh, .rsd, .tmd, + .scene.glb/.scene.gltf via .desktop + XDG MIME in + .deb and Snap; postinstall runs update-mime-database and + update-desktop-database.
  • +
+

+ CLI mode is unchanged: qtmesh info model.fbx still runs headless. Only + QtMeshEditor model.fbx (or OS open-with) enters GUI-with-file mode. +

+
+

Quick Start

{`# Inspect a model