Initial commit: QInventor CAD workbench for Windows

This commit is contained in:
Курнат Андрей
2026-10-18 22:05:57 +03:00
commit a71b6760f5
78 changed files with 41241 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
param([switch]$SkipDownload)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$version = '1.1.4'
$asset = "FreeCAD_${version}-Windows-x86_64-py311.7z"
$expected = '4828741fc91ee37fafcdb97a1abacb18b04ba451ac4372d9ff7a7349b36f4d6d'
$archive = Join-Path $root "downloads\$asset"
$runtime = Join-Path $root 'runtime'
New-Item -ItemType Directory -Path (Join-Path $root 'downloads') -Force | Out-Null
if (-not (Test-Path -LiteralPath $archive)) {
if ($SkipDownload) { throw "Не найден дистрибутив CAD: $archive" }
Invoke-WebRequest "https://github.com/FreeCAD/FreeCAD/releases/download/$version/$asset" -OutFile $archive
}
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected) {
throw 'Контрольная сумма архива FreeCAD не совпадает. Загруженный файл не использован.'
}
$sevenZip = 'C:\Program Files\7-Zip\7z.exe'
if (-not (Test-Path -LiteralPath $sevenZip)) { throw 'Для подготовки CAD-библиотек установите 7-Zip.' }
function Test-QIRuntimeFiles([string]$ArchivePath, [string]$RuntimePath) {
$listing = & $sevenZip l -slt -ba $ArchivePath
if ($LASTEXITCODE -ne 0) { throw 'Не удалось прочитать состав официального архива CAD.' }
$runtimeAbsolute = [IO.Path]::GetFullPath($RuntimePath).TrimEnd([IO.Path]::DirectorySeparatorChar)
$entryPath = ''; $entrySize = 0L; $isFolder = $false; $files = 0
foreach ($line in @($listing) + @('')) {
if ($line.StartsWith('Path = ')) { $entryPath = $line.Substring(7) }
elseif ($line.StartsWith('Size = ')) { $entrySize = [long]$line.Substring(7) }
elseif ($line.StartsWith('Attributes = ')) { $isFolder = $line.Substring(13).Contains('D') }
elseif ($line -eq '' -and $entryPath) {
if (-not $isFolder -and -not $entryPath.EndsWith('.pyc', [StringComparison]::OrdinalIgnoreCase)) {
$target = [IO.Path]::GetFullPath((Join-Path $runtimeAbsolute $entryPath))
if (-not $target.StartsWith($runtimeAbsolute + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Путь в архиве CAD выходит за пределы папки runtime.'
}
if (-not [IO.File]::Exists($target) -or ([IO.FileInfo]::new($target)).Length -ne $entrySize) {
Write-Host "Восстановление CAD-библиотек: $entryPath"
return $false
}
$files++
}
$entryPath = ''; $entrySize = 0L; $isFolder = $false
}
}
return $files -gt 0
}
# An interrupted extraction can leave the launcher EXE while omitting DLLs or
# workbenches. Compare every original non-bytecode file, not just the EXE.
if (-not (Test-QIRuntimeFiles $archive $runtime)) {
& $sevenZip x $archive "-o$runtime" -y -bsp0 -bso0
if ($LASTEXITCODE -ne 0) { throw 'Не удалось распаковать библиотеки CAD.' }
if (-not (Test-QIRuntimeFiles $archive $runtime)) { throw 'Не удалось восстановить полный комплект CAD-библиотек.' }
}
& (Join-Path $PSScriptRoot 'sync.ps1')
Write-Host "Библиотеки QInventor проверены и готовы к запуску: $root"
+14
View File
@@ -0,0 +1,14 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$publish = Join-Path $root 'artifacts\launcher'
dotnet publish (Join-Path $PSScriptRoot 'launcher\QInventor.Launcher.csproj') -c Release -r win-x64 -o $publish --nologo
if ($LASTEXITCODE -ne 0) { throw 'Не удалось собрать программу запуска QInventor.' }
$builtLauncher = Join-Path $publish 'QInventor.exe'
$installedLauncher = Join-Path $root 'QInventor.exe'
# A running CAD session locks the launcher on Windows. An identical rebuild
# needs no replacement and can still be packaged while that session is open.
if (-not (Test-Path -LiteralPath $installedLauncher) -or
(Get-FileHash -LiteralPath $builtLauncher).Hash -ne (Get-FileHash -LiteralPath $installedLauncher).Hash) {
Copy-Item -LiteralPath $builtLauncher -Destination $installedLauncher -Force
}
Write-Host 'QInventor.exe готов к запуску.'
+117
View File
@@ -0,0 +1,117 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
static class Program
{
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
static int Main(string[] args)
{
// Preserve a drive root (for example F:\\ on a portable USB drive).
var root = Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory);
var rootPrefix = Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar;
var log = Path.Combine(root, "logs", "launcher.log");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(log)!);
Directory.CreateDirectory(Path.Combine(root, "data"));
var marker = Path.Combine(root, "data", "runtime-path.txt");
var engine = File.Exists(marker)
? Path.GetFullPath(Path.Combine(root, File.ReadAllText(marker).Trim()))
: Directory.EnumerateFiles(Path.Combine(root, "runtime"), "FreeCAD.exe", SearchOption.AllDirectories)
.First(path => string.Equals(Path.GetFileName(Path.GetDirectoryName(path)), "bin", StringComparison.OrdinalIgnoreCase));
if (!engine.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) || !File.Exists(engine))
throw new IOException("Не найдены библиотеки CAD. Выполните scripts\\bootstrap.ps1.");
var nativeEngine = RuntimePath.ForEngine(root, engine);
var start = new ProcessStartInfo(nativeEngine) { UseShellExecute = false, WorkingDirectory = root };
start.Environment["QI_ROOT"] = root;
start.Environment.Remove("QI_TEST");
var selfTest = args.Contains("--self-test", StringComparer.Ordinal);
var profile = Path.Combine(root, "data");
if (selfTest)
{
start.Environment["QI_TEST"] = "1";
profile = Path.Combine(root, "artifacts", "test-profiles", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(profile);
var report = Path.Combine(root, "artifacts", "validation.json");
if (File.Exists(report))
File.Move(report, Path.Combine(root, "artifacts", $"validation-previous-{DateTime.UtcNow:yyyyMMdd-HHmmss-fffffff}.json"));
}
start.ArgumentList.Add("--user-cfg");
start.ArgumentList.Add(Path.Combine(profile, "user.cfg"));
start.ArgumentList.Add("--system-cfg");
start.ArgumentList.Add(Path.Combine(profile, "system.cfg"));
start.ArgumentList.Add("--write-log");
start.ArgumentList.Add("--log-file");
start.ArgumentList.Add(Path.Combine(root, "logs", "cad.log"));
// Apply first-run native view preferences before restored sketches
// exist. Setting them afterwards would mark clean projects dirty.
start.ArgumentList.Add(Path.Combine(root, "scripts", "startup.FCMacro"));
foreach (var arg in args)
{
if (arg != "--self-test") start.ArgumentList.Add(arg);
}
File.AppendAllText(log, $"{DateTimeOffset.Now:O} Starting QInventor {engine}\nNative runtime path: {nativeEngine}\n");
using var process = Process.Start(start) ?? throw new IOException("Не удалось запустить CAD-процесс.");
process.WaitForExit();
File.AppendAllText(log, $"{DateTimeOffset.Now:O} Exit {process.ExitCode}\n");
if (process.ExitCode != 0) throw new IOException($"CAD-процесс завершился с кодом {process.ExitCode}. Подробности: logs\\cad.log.");
if (selfTest)
{
using var report = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "artifacts", "validation.json")));
if (report.RootElement.GetProperty("status").GetString() != "passed")
{
File.AppendAllText(log, $"{DateTimeOffset.Now:O} Проверка приложения завершилась с ошибками; artifacts\\validation.json\n");
return 2;
}
}
return 0;
}
catch (Exception ex)
{
try { File.AppendAllText(log, $"{DateTimeOffset.Now:O} {ex}\n"); } catch { }
if (args.Contains("--self-test", StringComparer.Ordinal))
{
// Native failures can interrupt a suite before Python writes its
// final status. Preserve completed checks and make that failure
// explicit instead of leaving a misleading running report.
try
{
var reportPath = Path.Combine(root, "artifacts", "validation.json");
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
var report = new JsonObject();
if (File.Exists(reportPath))
{
try
{
report = JsonNode.Parse(File.ReadAllText(reportPath)) as JsonObject ?? new JsonObject();
}
catch (JsonException)
{
// A native crash can interrupt Python's live JSON
// write. Preserve its remaining bytes, then still
// produce a readable failure report.
var damaged = Path.Combine(root, "artifacts", $"validation-truncated-{DateTime.UtcNow:yyyyMMdd-HHmmss-fffffff}.json");
File.Copy(reportPath, damaged);
}
}
report["status"] = "failed";
var previousFailed = report["failed"] is JsonValue count && count.TryGetValue<int>(out var failures) ? failures : 0;
report["failed"] = Math.Max(1, previousFailed);
report["launcher_error"] = ex.Message;
report["finished"] = DateTime.UtcNow.ToString("o");
var staged = reportPath + ".launcher-tmp";
File.WriteAllText(staged, report.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
File.Move(staged, reportPath, overwrite: true);
}
catch { }
return 1;
}
MessageBoxW(IntPtr.Zero, "Не удалось запустить QInventor.\n\nПроверьте, что приложение перенесено вместе с папками runtime, src и scripts. Подробности ошибки сохранены в журнале.\n\nЖурнал: " + log, "QInventor", 0x10);
return 1;
}
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>QInventor</AssemblyName>
<RootNamespace>QInventor</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<Product>QInventor</Product>
<Description>QInventor — параметрическое проектирование</Description>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<SelfContained>true</SelfContained>
<PublishTrimmed>true</PublishTrimmed>
<InvariantGlobalization>true</InvariantGlobalization>
<ApplicationIcon>../../src/QInventor/resources/qinventor.ico</ApplicationIcon>
<DebugType>none</DebugType>
</PropertyGroup>
</Project>
+112
View File
@@ -0,0 +1,112 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Win32.SafeHandles;
// Some upstream DXF readers still use narrow std::ifstream for resource files.
// Give the native runtime an ASCII executable path while retaining the real
// portable folder for settings, projects and QI_ROOT. No CAD files are copied.
static class RuntimePath
{
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern uint GetShortPathNameW(string longPath, StringBuilder shortPath, uint capacity);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool DeviceIoControl(SafeFileHandle file, uint control, byte[] input, uint inputSize,
IntPtr output, uint outputSize, out uint returned, IntPtr overlapped);
static bool IsAscii(string value) => value.All(c => c < 128);
static string? ShortAsciiPath(string path)
{
if (IsAscii(path)) return path;
var buffer = new StringBuilder(32768);
var length = GetShortPathNameW(path, buffer, (uint)buffer.Capacity);
return length > 0 && length < buffer.Capacity && IsAscii(buffer.ToString())
? buffer.ToString() : null;
}
public static string ForEngine(string root, string engine, bool allowShortPath = true)
{
if (allowShortPath && ShortAsciiPath(engine) is { } shortEngine) return shortEngine;
var relativeEngine = Path.GetRelativePath(root, engine);
if (relativeEngine.StartsWith(".." + Path.DirectorySeparatorChar) || Path.IsPathRooted(relativeEngine))
throw new IOException("Путь CAD-движка находится за пределами переносимой папки.");
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(root.ToUpperInvariant())))[..32];
var candidates = new[]
{
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
Path.GetTempPath(),
Environment.GetFolderPath(Environment.SpecialFolder.CommonDocuments),
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
};
Exception? lastError = null;
foreach (var parent in candidates.Where(p => !string.IsNullOrWhiteSpace(p)).Distinct())
{
var asciiParent = ShortAsciiPath(Path.TrimEndingDirectorySeparator(parent));
if (asciiParent is null) continue;
var cache = Path.Combine(asciiParent, "QInventor", "RuntimeLinks");
var link = Path.Combine(cache, key);
try
{
Directory.CreateDirectory(cache);
var info = new DirectoryInfo(link);
if (info.Exists)
{
var target = info.ResolveLinkTarget(false)?.FullName;
if (target is null || !string.Equals(Path.TrimEndingDirectorySeparator(Path.GetFullPath(target)),
root, StringComparison.OrdinalIgnoreCase))
throw new IOException("Существующая ссылка CAD-движка указывает на другую папку.");
}
else
{
// Never remove an existing or dangling junction. Only create
// a fresh directory whose exact target is the validated root.
if (info.LinkTarget is not null)
throw new IOException("Служебная ссылка CAD-движка недоступна.");
Directory.CreateDirectory(link);
CreateJunction(link, root);
}
var aliasedEngine = Path.Combine(link, relativeEngine);
if (!File.Exists(aliasedEngine))
throw new IOException("Служебная ссылка CAD-движка не содержит исполняемый файл.");
return aliasedEngine;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Win32Exception)
{
lastError = ex;
}
}
throw new IOException("Не удалось подготовить служебный путь CAD-движка. Перенесите папку QInventor в путь из латинских символов.", lastError);
}
static void CreateJunction(string link, string target)
{
if (target.StartsWith(@"\\"))
throw new IOException("Сетевая папка без короткого пути не поддерживается CAD-движком.");
var substitute = Encoding.Unicode.GetBytes(@"\??\" + target);
var print = Encoding.Unicode.GetBytes(target);
var buffer = new byte[16 + substitute.Length + 2 + print.Length + 2];
void Write16(int offset, int value) => BitConverter.GetBytes(checked((ushort)value)).CopyTo(buffer, offset);
BitConverter.GetBytes(0xA0000003u).CopyTo(buffer, 0); // IO_REPARSE_TAG_MOUNT_POINT
Write16(4, buffer.Length - 8);
Write16(8, 0);
Write16(10, substitute.Length);
Write16(12, substitute.Length + 2);
Write16(14, print.Length);
substitute.CopyTo(buffer, 16);
print.CopyTo(buffer, 18 + substitute.Length);
using var handle = CreateFileW(link, 0x40000000, 0, IntPtr.Zero, 3,
0x00200000 | 0x02000000, IntPtr.Zero);
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
if (!DeviceIoControl(handle, 0x000900A4, buffer, (uint)buffer.Length,
IntPtr.Zero, 0, out _, IntPtr.Zero))
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
+90
View File
@@ -0,0 +1,90 @@
param([switch]$SkipTests)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
& (Join-Path $PSScriptRoot 'build-launcher.ps1')
& (Join-Path $PSScriptRoot 'sync.ps1')
if (-not $SkipTests) { & (Join-Path $PSScriptRoot 'test.ps1') }
$report = Join-Path $root 'artifacts\validation.json'
if (-not (Test-Path -LiteralPath $report)) { throw 'Нет отчёта проверки. Сначала выполните scripts/test.ps1.' }
$validated = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
if ($validated.status -ne 'passed') { throw 'Отчёт проверки CAD содержит ошибки.' }
if ($null -eq $validated.native_exit_code -or $validated.native_exit_code -ne 0) {
throw 'Не подтверждено корректное завершение CAD-процесса. Запустите scripts/test.ps1.'
}
if (-not $validated.source_sha256 -or $validated.source_sha256.Count -eq 0) {
throw 'В отчёте проверки нет снимка исходного кода. Выполните scripts/test.ps1.'
}
# Compare the complete current set too: checking only saved entries would let
# newly added, untested commands enter a -SkipTests package unnoticed.
$currentSources = foreach ($directory in @('src', 'tests', 'scripts')) {
Get-ChildItem -LiteralPath (Join-Path $root $directory) -File -Recurse |
Where-Object {
$_.Name -notmatch '\.(pyc|FCBak)$' -and
$_.FullName.Substring($root.Length + 1) -notmatch '(^|[\\/])(bin|obj|__pycache__)([\\/]|$)'
} | ForEach-Object { $_.FullName.Substring($root.Length + 1) }
}
$validatedPaths = @($validated.source_sha256 | ForEach-Object { $_.path } | Sort-Object)
$currentPaths = @($currentSources | Sort-Object)
if (($validatedPaths -join "`n") -cne ($currentPaths -join "`n")) {
throw 'Состав исходных файлов изменился после проверки. Выполните scripts/test.ps1.'
}
foreach ($file in $validated.source_sha256) {
$current = (Get-FileHash -LiteralPath (Join-Path $root $file.path) -Algorithm SHA256).Hash.ToLowerInvariant()
if ($current -ne $file.sha256) { throw "Исходный файл изменился после проверки: $($file.path). Выполните scripts/test.ps1." }
}
$enginePath = Join-Path $root ((Get-Content -LiteralPath (Join-Path $root 'data\runtime-path.txt') -Raw).Trim())
$engineRoot = Split-Path -Parent (Split-Path -Parent $enginePath)
$workbench = Join-Path $engineRoot 'Mod\QInventor'
if (-not (Test-Path -LiteralPath $workbench)) { $workbench = Join-Path (Split-Path -Parent $enginePath) 'Mod\QInventor' }
$sourceRoot = Join-Path $root 'src\QInventor'
$sourceFiles = @(Get-ChildItem -LiteralPath $sourceRoot -File -Recurse |
Where-Object { $_.Extension -ne '.pyc' -and $_.FullName -notmatch '[\\/]__pycache__[\\/]' })
$moduleFiles = @(Get-ChildItem -LiteralPath $workbench -File -Recurse |
Where-Object { $_.Extension -ne '.pyc' -and $_.FullName -notmatch '[\\/]__pycache__[\\/]' })
$sourceNames = @($sourceFiles | ForEach-Object { $_.FullName.Substring($sourceRoot.Length + 1) } | Sort-Object)
$moduleNames = @($moduleFiles | ForEach-Object { $_.FullName.Substring($workbench.Length + 1) } | Sort-Object)
if (($sourceNames -join "`n") -cne ($moduleNames -join "`n")) {
throw 'Рабочая среда CAD содержит файлы, которых нет в проверенном снимке исходного кода.'
}
foreach ($file in $sourceFiles) {
$nativeCopy = Join-Path $workbench $file.FullName.Substring($sourceRoot.Length + 1)
if ((Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash -ne (Get-FileHash -LiteralPath $nativeCopy -Algorithm SHA256).Hash) {
throw "Рабочая среда CAD отличается от проверенного исходного кода: $($file.Name)"
}
}
$release = Join-Path $root 'release'
New-Item -ItemType Directory -Path $release -Force | Out-Null
$sourceHashes = Get-ChildItem -LiteralPath (Join-Path $root 'src') -File -Recurse |
Where-Object { $_.Extension -ne '.pyc' } | ForEach-Object {
@{ path = $_.FullName.Substring($root.Length+1); sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() }
}
$manifest = @{
application = 'QInventor'; version = '1.0.0'; platform = 'Windows x64';
engine = 'FreeCAD 1.1.4 / Open CASCADE';
officialRuntimeArchiveSha256 = '4828741fc91ee37fafcdb97a1abacb18b04ba451ac4372d9ff7a7349b36f4d6d';
builtAtUtc = [DateTime]::UtcNow.ToString('o');
launcherSha256 = (Get-FileHash -LiteralPath (Join-Path $root 'QInventor.exe') -Algorithm SHA256).Hash.ToLowerInvariant();
validation = $validated; sources = @($sourceHashes)
}
$manifest | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath (Join-Path $root 'manifest.json') -Encoding utf8
Copy-Item -LiteralPath $report -Destination (Join-Path $release 'validation.json') -Force
$archive = Join-Path $release 'QInventor-1.0.0-win-x64-portable.zip'
if (Test-Path -LiteralPath $archive) {
Move-Item -LiteralPath $archive -Destination (Join-Path $release "QInventor-previous-$([DateTime]::Now.ToString('yyyyMMdd-HHmmss')).zip")
}
$files = @('QInventor.exe','QInventor.cmd','README.md','LICENSE','THIRD_PARTY_NOTICES.md','manifest.json',
'runtime','src','scripts','tests','docs','examples','data\runtime-path.txt')
Push-Location $root
try {
& 'C:\Program Files\7-Zip\7z.exe' a -tzip $archive @files '-xr!__pycache__' '-xr!*.pyc' '-xr!*.pdb' '-xr!*.FCStd1' '-xr!*.FCBak' '-xr!obj' '-xr!artifacts' '-x!scripts\launcher\bin\*' -mx1 -bsp0 -bso0
if ($LASTEXITCODE -ne 0) { throw 'Не удалось создать переносимый архив.' }
} finally { Pop-Location }
# Do not globally exclude bin: the runtime's bin directory contains the CAD
# executables. The launcher development bin is filtered by exact archive paths.
& 'C:\Program Files\7-Zip\7z.exe' t $archive -bsp0 -bso0
if ($LASTEXITCODE -ne 0) { throw 'Проверка целостности переносимого архива завершилась с ошибкой.' }
Get-FileHash -LiteralPath $archive -Algorithm SHA256 |
Select-Object @{Name='Алгоритм';Expression={$_.Algorithm}},
@{Name='Контрольная сумма';Expression={$_.Hash}},
@{Name='Файл';Expression={$_.Path}} | Format-List
Write-Host "Переносимый выпуск: $archive"
+14
View File
@@ -0,0 +1,14 @@
"""Rasterize the application's own vector icon with the bundled Qt runtime."""
import os
from pathlib import Path
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
from PySide6 import QtCore, QtGui, QtSvg
app = QtGui.QGuiApplication([])
resources = Path(__file__).resolve().parents[1] / 'src' / 'QInventor' / 'resources'
renderer = QtSvg.QSvgRenderer(str(resources / 'qinventor.svg'))
image = QtGui.QImage(256, 256, QtGui.QImage.Format_ARGB32)
image.fill(QtCore.Qt.transparent)
painter = QtGui.QPainter(image)
renderer.render(painter)
painter.end()
assert image.save(str(resources / 'qinventor.png'))
+93
View File
@@ -0,0 +1,93 @@
import os
import sys
import traceback
import FreeCAD as App
import FreeCADGui as Gui
from PySide import QtCore
ROOT = os.path.abspath(os.environ.get('QI_ROOT') or os.getcwd())
for path in (os.path.join(ROOT, 'src', 'QInventor'), os.path.join(ROOT, 'tests')):
if path not in sys.path:
sys.path.insert(0, path)
# These preferences reproduce the visible drafting workflow, not proprietary
# Autodesk solver internals. Apply defaults once so user changes persist.
settings = App.ParamGet('User parameter:BaseApp/Preferences/Mod/QInventor')
App.ParamGet('User parameter:BaseApp/Preferences/General').SetString('Language', 'Russian')
dimension_display = App.ParamGet('User parameter:BaseApp/Preferences/Mod/Sketcher')
if not any(item[1] == 'ShowDimensionalName' for item in (dimension_display.GetContents() or [])):
# Keep parameter aliases in the model and constraint list, but show a
# compact value on the canvas by default. Preserve explicit user choices.
dimension_display.SetBool('ShowDimensionalName', False)
App.saveParameter()
if not settings.GetBool('DefaultsV1', False):
App.ParamGet('User parameter:BaseApp/Preferences/General').SetString('AutoloadModule', 'QInventorWorkbench')
App.ParamGet('User parameter:BaseApp/Preferences/Units').SetInt('UserSchema', 0)
view = App.ParamGet('User parameter:BaseApp/Preferences/View')
view.SetString('NavigationStyle', 'Gui::RevitNavigationStyle')
view.SetUnsigned('BackgroundColor', 3908102143)
view.SetUnsigned('BackgroundColor2', 4177066239)
view.SetBool('Simple', False)
sketch = App.ParamGet('User parameter:BaseApp/Preferences/Mod/Sketcher')
for key, value in [('LeaveSketchWithEscape', False), ('ContinuousCreationMode', True),
('ContinuousConstraintMode', True), ('ShowDialogOnDistanceConstraint', True)]:
sketch.SetBool(key, value)
general = App.ParamGet('User parameter:BaseApp/Preferences/Mod/Sketcher/General')
general.SetBool('RestoreCamera', True)
general.SetBool('ForceOrtho', True)
App.ParamGet('User parameter:BaseApp/Preferences/Mod/Sketcher/Tools').SetInt('OnViewParameterVisibility', 2)
App.ParamGet('User parameter:BaseApp/Preferences/Mod/Sketcher/dimensioning').SetBool('SingleDimensioningTool', True)
settings.SetBool('DefaultsV1', True)
App.saveParameter()
# Early development profiles used FreeCAD's historical Open Inventor mouse
# mode. Migrate that one old default, preserving other user navigation choices.
if not settings.GetBool('NavigationV2', False):
view = App.ParamGet('User parameter:BaseApp/Preferences/View')
if view.GetString('NavigationStyle') == 'Gui::InventorNavigationStyle':
view.SetString('NavigationStyle', 'Gui::RevitNavigationStyle')
settings.SetBool('NavigationV2', True)
App.saveParameter()
def start():
try:
# The first launch has already created native menus before this macro.
# Changing the preference alone does not retranslate those QAction labels.
Gui.updateLocale()
import qi_localization
qi_localization.install()
# Native opening names a document after its filename. Keep the saved
# Russian title when a project was passed to the portable launcher.
import qi_io
for document in App.listDocuments().values():
if document.FileName:
qi_io.restore_document_label(document, document.FileName)
Gui.activateWorkbench('QInventorWorkbench')
if os.environ.get('QI_TEST'):
import gui_suite
gui_suite.run(ROOT)
elif not App.ActiveDocument:
import qi_ui
qi_ui.show_welcome()
except Exception:
text = traceback.format_exc()
App.Console.PrintError(text)
os.makedirs(os.path.join(ROOT, 'logs'), exist_ok=True)
with open(os.path.join(ROOT, 'logs', 'startup-error.txt'), 'w', encoding='utf-8') as f:
f.write(text)
if os.environ.get('QI_TEST'):
import json
result_path = os.path.join(ROOT, 'artifacts', 'validation.json')
result = {}
if os.path.exists(result_path):
with open(result_path, encoding='utf-8') as f:
result = json.load(f)
result.update(status='failed', fatal_error=text)
result['failed'] = result.get('failed', 0) + 1
with open(result_path, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2, ensure_ascii=False)
QtCore.QTimer.singleShot(100, Gui.getMainWindow().close)
QtCore.QTimer.singleShot(800, start)
+46
View File
@@ -0,0 +1,46 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$exe = Get-ChildItem -LiteralPath (Join-Path $root 'runtime') -Filter FreeCAD.exe -Recurse | Where-Object { $_.Directory.Name -eq 'bin' } | Select-Object -First 1
if (-not $exe) { throw 'Сначала выполните scripts/bootstrap.ps1.' }
$engineRoot = Split-Path -Parent $exe.DirectoryName
$modules = Join-Path $engineRoot 'Mod'
if (-not (Test-Path -LiteralPath $modules)) { $modules = Join-Path $exe.DirectoryName 'Mod' }
$destination = Join-Path $modules 'QInventor'
New-Item -ItemType Directory -Path $destination -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'data'),(Join-Path $root 'logs'),(Join-Path $root 'artifacts') -Force | Out-Null
$source = Join-Path $root 'src\QInventor'
$ownedManifest = Join-Path $root 'data\workbench-files.json'
$ownedFiles = @(Get-ChildItem -LiteralPath $source -File -Recurse |
Where-Object { $_.Extension -ne '.pyc' -and $_.FullName -notmatch '[\\/]__pycache__[\\/]' } |
ForEach-Object { $_.FullName.Substring($source.Length + 1) } | Sort-Object)
$destinationAbsolute = [IO.Path]::GetFullPath($destination)
$destinationPrefix = $destinationAbsolute + [IO.Path]::DirectorySeparatorChar
if (Test-Path -LiteralPath $ownedManifest) {
$previousOwnedFiles = @(Get-Content -LiteralPath $ownedManifest -Raw | ConvertFrom-Json)
foreach ($previous in $previousOwnedFiles) {
if ($previous -notin $ownedFiles) {
$obsolete = [IO.Path]::GetFullPath((Join-Path $destinationAbsolute $previous))
if (-not $obsolete.StartsWith($destinationPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Недопустимый путь в списке файлов рабочей среды QInventor.'
}
if ([IO.File]::Exists($obsolete)) { Remove-Item -LiteralPath $obsolete }
}
}
}
foreach ($file in $ownedFiles) {
$target = Join-Path $destinationAbsolute $file
New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $source $file) -Destination $target -Force
}
# This is our own module only. Avoid stale Python bytecode when two edits have
# equal sizes and fall in the same timestamp second; preserve upstream caches.
foreach ($cached in Get-ChildItem -LiteralPath $destinationAbsolute -File -Filter '*.pyc' -Recurse) {
if (-not $cached.FullName.StartsWith($destinationPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Путь кэша выходит за пределы рабочей среды QInventor.'
}
Remove-Item -LiteralPath $cached.FullName
}
ConvertTo-Json -InputObject $ownedFiles | Set-Content -LiteralPath $ownedManifest -Encoding utf8
$relative = $exe.FullName.Substring($root.Length + 1)
Set-Content -LiteralPath (Join-Path $root 'data\runtime-path.txt') -Value $relative -Encoding utf8
Write-Host "Рабочая среда QInventor обновлена: $destination"
+62
View File
@@ -0,0 +1,62 @@
param([int]$TimeoutSeconds = 3600)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
& (Join-Path $PSScriptRoot 'sync.ps1')
$report = Join-Path $root 'artifacts\validation.json'
if (Test-Path -LiteralPath $report) {
Move-Item -LiteralPath $report -Destination (Join-Path $root "artifacts\validation-previous-$([DateTime]::Now.ToString('yyyyMMdd-HHmmss')).json")
}
$engine = Join-Path $root ((Get-Content -LiteralPath (Join-Path $root 'data\runtime-path.txt') -Raw).Trim())
$env:QI_ROOT = $root
$previousTest = $env:QI_TEST
$env:QI_TEST = '1'
$profile = Join-Path $root ("artifacts\test-profiles\" + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $profile -Force | Out-Null
$params = @('--user-cfg', ('"' + (Join-Path $profile 'user.cfg') + '"'),
'--system-cfg', ('"' + (Join-Path $profile 'system.cfg') + '"'),
'--log-file', ('"' + (Join-Path $root 'logs\validation.log') + '"'),
('"' + (Join-Path $PSScriptRoot 'startup.FCMacro') + '"'))
$process = $null
try {
$process = Start-Process -FilePath $engine -ArgumentList $params -WorkingDirectory $root -WindowStyle Hidden -PassThru
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
while (-not $process.WaitForExit(1000)) {
if ([DateTime]::UtcNow -gt $deadline) {
Stop-Process -Id $process.Id -ErrorAction SilentlyContinue
throw "Истекло время проверки интерфейса. Подробности: logs\validation.log и artifacts\validation.json."
}
}
} finally {
if ($null -eq $previousTest) { Remove-Item Env:QI_TEST -ErrorAction SilentlyContinue }
else { $env:QI_TEST = $previousTest }
}
if (-not (Test-Path -LiteralPath $report)) {
Get-Content -LiteralPath (Join-Path $root 'logs\validation.log') -Tail 60
throw 'Проверка не создала отчёт.'
}
$result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
$result | Add-Member -NotePropertyName native_exit_code -NotePropertyValue $process.ExitCode -Force
if ($process.ExitCode -ne 0) {
# A native crash during shutdown can follow Python's final passing report.
# Persist the process outcome so -SkipTests cannot package that report.
$result.status = 'failed'
$result.failed = [Math]::Max(1, [int]$result.failed)
$result | Add-Member -NotePropertyName native_error -NotePropertyValue "CAD-процесс завершился с кодом $($process.ExitCode). Подробности: logs\validation.log." -Force
}
$stagedReport = "$report.parent-tmp"
$result | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $stagedReport -Encoding utf8
Move-Item -LiteralPath $stagedReport -Destination $report -Force
if ($process.ExitCode -ne 0 -or $result.status -ne 'passed') {
Write-Host "Проверка не пройдена: $($result.failed) ошибок. Отчёт: $report"
foreach ($check in $result.checks | Where-Object { -not $_.passed }) {
$detail = [string]$check.error
if ($detail.Length -gt 1200) { $detail = $detail.Substring(0, 1200) + '…' }
Write-Host "$($check.name): $detail"
}
throw 'Проверка QInventor завершилась с ошибками.'
}
$result | Select-Object @{Name='Состояние';Expression={'Пройдена'}},
@{Name='Успешных проверок';Expression={$_.passed}},
@{Name='Ошибок';Expression={$_.failed}},
@{Name='Версия';Expression={$_.version}} | Format-List
Write-Host "Отчёт проверки: $report"
+12
View File
@@ -0,0 +1,12 @@
from PySide6 import QtCore
app = QtCore.QCoreApplication([])
class Probe(QtCore.QTranslator):
def isEmpty(self): return False
def translate(self, context, source, disambiguation=None, n=-1):
if source == 'known': return 'известно'
return None
translator = Probe(app)
print('installed', app.installTranslator(translator))
print('known', app.translate('test', 'known'))
print('unknown', app.translate('test', 'unknown %1'))
print('base', QtCore.QTranslator().translate('test', 'unknown'))