From e51bbb79c44d5721fa98c27c123f588bbbb77e75 Mon Sep 17 00:00:00 2001 From: George Roberts Date: Wed, 14 Oct 2026 11:58:58 +0100 Subject: [PATCH] Machine Selector: support Fusion .f3d/.f3z files Extract the embedded .mch (Zstandard/deflate ZIP entries, nested .f3d in .f3z) to a temp file and pass it to the post engine. Archives without a machine are hidden from the machine list. Co-authored-by: Cursor --- vs-code-extension/CHANGELOG.md | 4 + vs-code-extension/out/extension.js | 15 +- vs-code-extension/out/fusionArchive.js | 264 ++++++++++++++++++ vs-code-extension/out/postRunner.js | 36 ++- .../out/providers/fileTreeProvider.js | 7 +- 5 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 vs-code-extension/out/fusionArchive.js diff --git a/vs-code-extension/CHANGELOG.md b/vs-code-extension/CHANGELOG.md index c7bb2ac..f2a0037 100644 --- a/vs-code-extension/CHANGELOG.md +++ b/vs-code-extension/CHANGELOG.md @@ -1,4 +1,8 @@ +## Unreleased +Added: + - **Machine Selector**: Fusion `.f3d` / `.f3z` files can be selected and imported as machines. The embedded `.mch` is extracted and passed to the post processor; archives without a machine are hidden from the list. +--- ## 5.0.2 Fixed: - **Click to jump**: clicking in the `.cps` file no longer makes the cursor jump around. diff --git a/vs-code-extension/out/extension.js b/vs-code-extension/out/extension.js index 15dfec0..92b622f 100644 --- a/vs-code-extension/out/extension.js +++ b/vs-code-extension/out/extension.js @@ -45,6 +45,7 @@ const config = __importStar(require("./config")); const utils_1 = require("./utils"); const postRunner_1 = require("./postRunner"); const onlineLibrary_1 = require("./onlineLibrary"); +const fusionArchive_1 = require("./fusionArchive"); const lineSelection_1 = require("./lineSelection"); const fileTreeProvider_1 = require("./providers/fileTreeProvider"); const propertyProvider_1 = require("./providers/propertyProvider"); @@ -167,7 +168,7 @@ function activate(context) { }); const machineTree = new fileTreeProvider_1.FileTreeProvider(context, { rootDirName: 'Machines', - fileExtensions: ['.machine', '.mch'], + fileExtensions: ['.machine', '.mch', ...fusionArchive_1.ARCHIVE_EXTENSIONS], settingsKey: 'customMachineLocations', selectCommand: 'autodesk.post.setMachine', includeAllFilesNode: false, @@ -1095,7 +1096,7 @@ async function importCustomFile(type, engine, tree, targetPath) { const isMachine = type === 'machineFile'; const fileType = isMachine ? 'Machines' : 'CNC files'; const filters = isMachine - ? { 'Machine Configuration file': ['machine', 'mch'] } + ? { 'Machine Configuration file': ['machine', 'mch', 'f3d', 'f3z'] } : { 'Autodesk CAM intermediate file': ['cnc'] }; // Use the right-clicked folder if it's a directory, otherwise fall back to Custom let destDir; @@ -1292,6 +1293,16 @@ function promptOnlineLibraryFilter(tree, machineListView) { function editMachineFile(filePath) { if (!(0, utils_1.fileExists)(filePath)) return; + if ((0, fusionArchive_1.isFusionArchive)(filePath)) { + try { + filePath = (0, fusionArchive_1.extractMchFromArchive)(filePath); + } + catch (e) { + vscode.window.showErrorMessage(e.message); + return; + } + vscode.window.showInformationMessage('Showing the machine extracted from the Fusion archive. Edits are not saved back into the archive.'); + } vscode.workspace.openTextDocument(filePath).then(doc => { vscode.window.showTextDocument(doc, vscode.ViewColumn.One); }); diff --git a/vs-code-extension/out/fusionArchive.js b/vs-code-extension/out/fusionArchive.js new file mode 100644 index 0000000..68c754b --- /dev/null +++ b/vs-code-extension/out/fusionArchive.js @@ -0,0 +1,264 @@ +"use strict"; +// Copyright (c) 2020-2026 by Autodesk, Inc. +// Licensed under MIT. See LICENSE file in the project root. +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ARCHIVE_EXTENSIONS = void 0; +exports.isFusionArchive = isFusionArchive; +exports.archiveContainsMch = archiveContainsMch; +exports.extractMchFromArchive = extractMchFromArchive; +exports.resolveMachineFile = resolveMachineFile; +exports.tryResolveMachineFile = tryResolveMachineFile; +const fs = __importStar(require("fs")); +const os = __importStar(require("os")); +const path = __importStar(require("path")); +const zlib = __importStar(require("zlib")); +const utils_1 = require("./utils"); +/** Fusion design archives: .f3d is a ZIP (entries usually Zstandard, method 93); .f3z is a ZIP of .f3d files. */ +exports.ARCHIVE_EXTENSIONS = ['.f3d', '.f3z']; +const METHOD_STORED = 0; +const METHOD_DEFLATE = 8; +const METHOD_ZSTD = 93; +const SIG_EOCD = 0x06054b50; +const SIG_ZIP64_LOCATOR = 0x07064b50; +const SIG_ZIP64_EOCD = 0x06064b50; +const SIG_CENTRAL = 0x02014b50; +const SIG_LOCAL = 0x04034b50; +/** Archive contents are untrusted: cap decompressed sizes to avoid zip bombs. */ +const MAX_MCH_BYTES = 64 * 1024 * 1024; +const MAX_NESTED_ARCHIVE_BYTES = 1024 * 1024 * 1024; +const MAX_NESTING_DEPTH = 2; +const EXTRACT_DIR = path.join(os.tmpdir(), 'AutodeskPostUtility', 'ArchiveMachines'); +function isFusionArchive(filePath) { + const lower = (filePath || '').toLowerCase(); + return exports.ARCHIVE_EXTENSIONS.some(ext => lower.endsWith(ext)); +} +function fileSource(fd, size) { + return { + size, + read(offset, length) { + const buf = Buffer.alloc(length); + let done = 0; + while (done < length) { + const n = fs.readSync(fd, buf, done, length - done, offset + done); + if (n === 0) + throw new Error('Unexpected end of archive'); + done += n; + } + return buf; + }, + }; +} +function bufferSource(buf) { + return { + size: buf.length, + read(offset, length) { + if (offset < 0 || offset + length > buf.length) + throw new Error('Unexpected end of archive'); + return buf.subarray(offset, offset + length); + }, + }; +} +/** Reads the ZIP central directory and returns { name, method, compressedSize, size, localOffset } per file entry. */ +function listEntries(src) { + const tailLen = Math.min(src.size, 22 + 0xffff); + const tailStart = src.size - tailLen; + const tail = src.read(tailStart, tailLen); + let eocd = -1; + for (let i = tail.length - 22; i >= 0; i--) { + if (tail.readUInt32LE(i) === SIG_EOCD) { + eocd = i; + break; + } + } + if (eocd < 0) + throw new Error('Not a ZIP archive'); + let count = tail.readUInt16LE(eocd + 10); + let cdSize = tail.readUInt32LE(eocd + 12); + let cdOffset = tail.readUInt32LE(eocd + 16); + if (cdOffset === 0xffffffff || count === 0xffff) { + const loc = eocd - 20; + if (loc >= 0 && tail.readUInt32LE(loc) === SIG_ZIP64_LOCATOR) { + const z64Offset = Number(tail.readBigUInt64LE(loc + 8)); + const z64 = src.read(z64Offset, 56); + if (z64.readUInt32LE(0) !== SIG_ZIP64_EOCD) + throw new Error('Corrupt ZIP64 directory'); + count = Number(z64.readBigUInt64LE(32)); + cdSize = Number(z64.readBigUInt64LE(40)); + cdOffset = Number(z64.readBigUInt64LE(48)); + } + } + const cd = src.read(cdOffset, cdSize); + const entries = []; + let p = 0; + for (let i = 0; i < count && p + 46 <= cd.length; i++) { + if (cd.readUInt32LE(p) !== SIG_CENTRAL) + throw new Error('Corrupt ZIP central directory'); + const method = cd.readUInt16LE(p + 10); + let compressedSize = cd.readUInt32LE(p + 20); + let size = cd.readUInt32LE(p + 24); + const nameLen = cd.readUInt16LE(p + 28); + const extraLen = cd.readUInt16LE(p + 30); + const commentLen = cd.readUInt16LE(p + 32); + let localOffset = cd.readUInt32LE(p + 42); + const name = cd.subarray(p + 46, p + 46 + nameLen).toString('utf-8'); + let e = p + 46 + nameLen; + const extraEnd = e + extraLen; + while (e + 4 <= extraEnd) { + const id = cd.readUInt16LE(e); + const len = cd.readUInt16LE(e + 2); + if (id === 0x0001) { + let q = e + 4; + if (size === 0xffffffff) { size = Number(cd.readBigUInt64LE(q)); q += 8; } + if (compressedSize === 0xffffffff) { compressedSize = Number(cd.readBigUInt64LE(q)); q += 8; } + if (localOffset === 0xffffffff) { localOffset = Number(cd.readBigUInt64LE(q)); } + } + e += 4 + len; + } + if (!name.endsWith('/')) + entries.push({ name, method, compressedSize, size, localOffset }); + p = extraEnd + commentLen; + } + return entries; +} +function readEntry(src, entry, maxBytes) { + if (entry.size > maxBytes) + throw new Error(`Archive entry too large: ${entry.name}`); + const local = src.read(entry.localOffset, 30); + if (local.readUInt32LE(0) !== SIG_LOCAL) + throw new Error(`Corrupt ZIP entry: ${entry.name}`); + const dataStart = entry.localOffset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28); + const raw = src.read(dataStart, entry.compressedSize); + switch (entry.method) { + case METHOD_STORED: + return raw; + case METHOD_DEFLATE: + return zlib.inflateRawSync(raw, { maxOutputLength: maxBytes }); + case METHOD_ZSTD: + if (typeof zlib.zstdDecompressSync !== 'function') + throw new Error('This editor\'s Node.js runtime has no Zstandard support, which Fusion archives require. Update VS Code/Cursor.'); + return zlib.zstdDecompressSync(raw, { maxOutputLength: maxBytes }); + default: + throw new Error(`Unsupported compression method ${entry.method} for ${entry.name}`); + } +} +function isMchEntry(name) { + return name.toLowerCase().endsWith('.mch'); +} +/** Prefers the active machine asset when a design carries several. */ +function pickMchEntry(entries) { + const mchs = entries.filter(e => isMchEntry(e.name)); + return mchs.find(e => e.name.includes('[Active]')) ?? mchs[0]; +} +/** Depth-first search for a .mch inside src and nested .f3d/.f3z entries. Returns the .mch bytes or undefined. */ +function findMch(src, depth) { + const entries = listEntries(src); + const mch = pickMchEntry(entries); + if (mch) + return readEntry(src, mch, MAX_MCH_BYTES); + if (depth >= MAX_NESTING_DEPTH) + return undefined; + for (const entry of entries) { + if (!isFusionArchive(entry.name)) + continue; + const nested = readEntry(src, entry, MAX_NESTED_ARCHIVE_BYTES); + const found = findMch(bufferSource(nested), depth + 1); + if (found) + return found; + } + return undefined; +} +function withArchive(archivePath, fn) { + const fd = fs.openSync(archivePath, 'r'); + try { + return fn(fileSource(fd, fs.fstatSync(fd).size)); + } + finally { + fs.closeSync(fd); + } +} +function statKey(archivePath) { + const st = fs.statSync(archivePath); + return `${st.mtimeMs}:${st.size}`; +} +const _containsCache = new Map(); +/** Cheap check (central directory only, unless the .mch is inside a nested design) used to hide archives with no machine. */ +function archiveContainsMch(archivePath) { + try { + const key = statKey(archivePath); + const cached = _containsCache.get(archivePath); + if (cached && cached.key === key) + return cached.value; + const value = withArchive(archivePath, src => { + const entries = listEntries(src); + if (entries.some(e => isMchEntry(e.name))) + return true; + return entries.some(e => isFusionArchive(e.name)) && findMch(src, 0) !== undefined; + }); + _containsCache.set(archivePath, { key, value }); + return value; + } + catch { + return false; + } +} +/** Extracts the machine definition from a .f3d/.f3z into a temp .mch and returns its path. Throws with a user-facing message on failure. */ +function extractMchFromArchive(archivePath) { + const key = statKey(archivePath); + // The output name is derived from a hash, never from archive entry names. + const outPath = path.join(EXTRACT_DIR, `${(0, utils_1.getHash)(`${path.resolve(archivePath)}|${key}`)}.mch`); + if ((0, utils_1.fileExists)(outPath)) + return outPath; + const data = withArchive(archivePath, src => findMch(src, 0)); + if (!data) + throw new Error(`No machine definition (.mch) found in ${path.basename(archivePath)}`); + (0, utils_1.ensureDir)(EXTRACT_DIR); + const tmpPath = `${outPath}.${process.pid}.tmp`; + fs.writeFileSync(tmpPath, data); + fs.renameSync(tmpPath, outPath); + return outPath; +} +/** Returns a path to a readable .mch/.machine file: the input itself, or the .mch extracted from a Fusion archive. */ +function resolveMachineFile(filePath) { + return isFusionArchive(filePath) ? extractMchFromArchive(filePath) : filePath; +} +function tryResolveMachineFile(filePath) { + try { + return resolveMachineFile(filePath); + } + catch { + return undefined; + } +} diff --git a/vs-code-extension/out/postRunner.js b/vs-code-extension/out/postRunner.js index 609b7b4..e4052f1 100644 --- a/vs-code-extension/out/postRunner.js +++ b/vs-code-extension/out/postRunner.js @@ -49,11 +49,12 @@ const process = __importStar(require("process")); const cp = __importStar(require("child_process")); const config = __importStar(require("./config")); const utils_1 = require("./utils"); -const MACHINE_EXTENSIONS = ['.machine', '.mch']; +const fusionArchive_1 = require("./fusionArchive"); +const MACHINE_EXTENSIONS = ['.machine', '.mch', ...fusionArchive_1.ARCHIVE_EXTENSIONS]; /** Return "vendor model" from .mch/.machine JSON when present, else undefined (caller uses filename). Matches tree label. */ function getMachineDisplayLabelFromPath(filePath) { try { - const raw = fs.readFileSync(filePath, 'utf-8'); + const raw = fs.readFileSync((0, fusionArchive_1.resolveMachineFile)(filePath), 'utf-8'); const mch = JSON.parse(raw); const vendor = (mch.general?.vendor && String(mch.general.vendor).trim()) || ''; const model = (mch.general?.model && String(mch.general.model).trim()) || ''; @@ -366,6 +367,15 @@ class PostEngine { setMachineFile(selectedFile) { if (!MACHINE_EXTENSIONS.some(ext => selectedFile.toLowerCase().endsWith(ext))) return; + if ((0, fusionArchive_1.isFusionArchive)(selectedFile)) { + try { + (0, fusionArchive_1.extractMchFromArchive)(selectedFile); + } + catch (e) { + vscode.window.showErrorMessage(`Cannot use ${path.basename(selectedFile)} as a machine: ${e.message}`); + return; + } + } this.machineFile = selectedFile; vscode.window.setStatusBarMessage('Machine file updated', 2000); if (!this.machineStatusBar) { @@ -464,10 +474,23 @@ class PostEngine { } } // ── Parameter building ────────────────────────────────────────── + /** Path handed to the post engine: archives (.f3d/.f3z) are swapped for their extracted .mch. */ + getMachineFileForPost() { + if (!this.machineFile) + return ''; + try { + return (0, fusionArchive_1.resolveMachineFile)(this.machineFile); + } + catch (e) { + vscode.window.showErrorMessage(`Machine ${path.basename(this.machineFile)} ignored: ${e.message}`); + return ''; + } + } createParameters(postLocation, isCompare, isSecondary = false) { const params = ['--noeditor']; - if (this.machineFile) { - params.unshift(this.machineFile); + const machineFile = this.getMachineFileForPost(); + if (machineFile) { + params.unshift(machineFile); params.unshift('--machine'); } params.push('--debugall'); // always run with debug so line jumping works; strip for display when mode is 'off' @@ -506,8 +529,9 @@ class PostEngine { } createParametersForFile(postLocation, cncPath, outputPath, forRegressionTest = false, noDebugAll = false) { const params = ['--noeditor']; - if (this.machineFile) { - params.unshift(this.machineFile); + const machineFile = this.getMachineFileForPost(); + if (machineFile) { + params.unshift(machineFile); params.unshift('--machine'); } if (!forRegressionTest && !noDebugAll) { diff --git a/vs-code-extension/out/providers/fileTreeProvider.js b/vs-code-extension/out/providers/fileTreeProvider.js index 1381155..9e8b61f 100644 --- a/vs-code-extension/out/providers/fileTreeProvider.js +++ b/vs-code-extension/out/providers/fileTreeProvider.js @@ -43,6 +43,7 @@ const process = __importStar(require("process")); const config = __importStar(require("../config")); const utils_1 = require("../utils"); const onlineLibrary_1 = require("../onlineLibrary"); +const fusionArchive_1 = require("../fusionArchive"); class FileTreeItem extends vscode.TreeItem { constructor(label, collapsibleState, filePath = '', /** When set (checkbox mode), tree parent path for unique id and getParent. */ @@ -68,7 +69,7 @@ function getCachedMch(filePath) { const mtime = fs.statSync(filePath).mtime.getTime(); if (cached && cached.mtime === mtime) return cached.data; - const raw = fs.readFileSync(filePath, 'utf-8'); + const raw = fs.readFileSync((0, fusionArchive_1.resolveMachineFile)(filePath), 'utf-8'); const data = JSON.parse(raw); _mchCache.set(filePath, { mtime, data }); return data; @@ -209,8 +210,10 @@ function getEmbeddedImageFromMch(filePath) { return undefined; return `data:image/png;base64,${cleaned}`; } -/** Exclude .machine files that use default Haas vendors from Online Library. */ +/** Exclude .machine files that use default Haas vendors from Online Library, and Fusion archives that carry no machine. */ function isExcludedMachineFile(fullPath) { + if ((0, fusionArchive_1.isFusionArchive)(fullPath)) + return !(0, fusionArchive_1.archiveContainsMch)(fullPath); if (!fullPath || !fullPath.toLowerCase().endsWith('.machine')) return false; try {