From b643292548e5b2e55b50a67d15c4e30d6b8f0c39 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Sun, 5 Jul 2026 08:36:48 -0700 Subject: [PATCH 1/7] [Patch] Export script now accepts blend file --- .../UntoldEngineCLI/ExportCommand.swift | 5 ++- scripts/untoldexplorer.py | 41 +++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift index 873baaa6..d2d32a45 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift @@ -14,17 +14,18 @@ import Foundation struct ExportCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "export", - abstract: "Convert a USD/USDZ asset to UntoldEngine's .untold format", + abstract: "Convert a USD/USDZ asset or .blend file to UntoldEngine's .untold format", discussion: """ Runs the UntoldEngine exporter through Blender. Input and output paths may be absolute or relative to the current directory. Example: untoldengine export --input model.usdz --output model.untold --convert-orientation --compress-geometry + untoldengine export --input model.blend --output model.untold --convert-orientation --compress-geometry """ ) - @Option(name: .long, help: "Source .usd, .usda, .usdc, or .usdz asset") + @Option(name: .long, help: "Source .usd, .usda, .usdc, .usdz, or .blend asset") var input: String @Option(name: .long, help: "Destination .untold file") diff --git a/scripts/untoldexplorer.py b/scripts/untoldexplorer.py index 3bbedc2b..57533102 100644 --- a/scripts/untoldexplorer.py +++ b/scripts/untoldexplorer.py @@ -1363,6 +1363,30 @@ def import_usd_asset(asset_path: Path) -> list[object]: return imported +def load_blend_scene(asset_path: Path) -> list[object]: + """Open a .blend file in place of the factory-startup scene and return its objects. + + Unlike import_usd_asset, this replaces the whole scene (equivalent to + File > Open) rather than merging into it, so there is no existing-vs-new + object bookkeeping to do. + """ + blender_required() + result = bpy.ops.wm.open_mainfile(filepath=str(asset_path)) + if "FINISHED" not in result: + raise RuntimeError(f"Blender failed to open {asset_path}") + scene_objects = list(bpy.context.scene.objects) + if not scene_objects: + raise RuntimeError(f"No objects were found in {asset_path}") + return scene_objects + + +def load_source_objects(asset_path: Path) -> list[object]: + if asset_path.suffix.lower() == ".blend": + return load_blend_scene(asset_path) + clear_scene() + return import_usd_asset(asset_path) + + def make_export_orientation_matrix(source_orientation: str) -> object: blender_required() if axis_conversion is None or Matrix is None or Vector is None: @@ -3007,10 +3031,10 @@ def extract_nodes( progress_callback: Optional[ProgressCallback] = None, ) -> list[ExportedNode]: blender_required() + stage_label = "Open .blend" if asset_path.suffix.lower() == ".blend" else "Import USD" if progress_callback is not None: - progress_callback("Import USD", 0, 1, asset_path.name) - clear_scene() - imported_objects = import_usd_asset(asset_path) + progress_callback(stage_label, 0, 1, asset_path.name) + imported_objects = load_source_objects(asset_path) if progress_callback is not None: progress_callback("Select objects", 0, 1, f"{len(imported_objects)} imported object(s)") export_objects = prepare_export_objects_from_blender_objects(imported_objects, mesh_name) @@ -3629,8 +3653,7 @@ def add_material(material: ExportedMaterial) -> int: def extract_animation_clips(asset_path: Path, convert_orientation: bool = False, source_orientation: str = "blender-native") -> list[ExportedAnimationClip]: blender_required() - clear_scene() - imported_objects = import_usd_asset(asset_path) + imported_objects = load_source_objects(asset_path) conversion_matrix = make_export_orientation_matrix(source_orientation) if convert_orientation else None armatures = [obj for obj in imported_objects if getattr(obj, "type", None) == "ARMATURE"] if not armatures: @@ -3954,7 +3977,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: else: argv = argv[1:] parser = argparse.ArgumentParser(description="Cook USD scene or animation data into UntoldEngine's .untold format.") - parser.add_argument("--input", required=True, help="Path to a source USD/USDZ asset.") + parser.add_argument("--input", required=True, help="Path to a source USD/USDZ asset or a .blend file.") parser.add_argument("--output", required=True, help="Path to the output .untold file.") parser.add_argument("--file-type", default="tile", choices=sorted(FILE_TYPES.keys()), help="Untold file type to emit.") parser.add_argument("--mesh-name", default=None, help="Optional mesh object name when the USD asset imports multiple meshes.") @@ -3990,16 +4013,16 @@ def main(argv: list[str]) -> int: input_path = normalize_blender_path(args.input) output_path = normalize_blender_path(args.output) - if input_path.suffix.lower() not in {".usd", ".usda", ".usdc", ".usdz"}: + if input_path.suffix.lower() not in {".usd", ".usda", ".usdc", ".usdz", ".blend"}: raise RuntimeError(f"Unsupported source asset type: {input_path.suffix}") if not input_path.is_file(): raise RuntimeError(f"Input asset does not exist: {input_path}") - print(f"Importing {input_path.name} ...", flush=True) + print(f"{'Opening' if input_path.suffix.lower() == '.blend' else 'Importing'} {input_path.name} ...", flush=True) output_path.parent.mkdir(parents=True, exist_ok=True) if args.animation: progress = ProgressReporter("animation export", 4) - progress.stage("Import USD", input_path.name) + progress.stage("Open .blend" if input_path.suffix.lower() == ".blend" else "Import USD", input_path.name) exported_clips = extract_animation_clips( input_path, convert_orientation=args.convert_orientation, From 306669498d7fb5eefc19ee4f4df117e4a10d7949 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Sun, 5 Jul 2026 08:37:23 -0700 Subject: [PATCH 2/7] [Bugfix] Fix script crash when texture has zero dim --- scripts/untoldexplorer.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/untoldexplorer.py b/scripts/untoldexplorer.py index 57533102..7d46a349 100644 --- a/scripts/untoldexplorer.py +++ b/scripts/untoldexplorer.py @@ -755,9 +755,8 @@ class KeyframeQuaternion: value: tuple[float, float, float, float] -@dataclass class UnsupportedTextureFormatError(Exception): - """Raised when a texture format is not supported by the engine pipeline (e.g. EXR, HDR).""" + """Raised when a texture is not usable by the engine pipeline (e.g. EXR/HDR format, or no pixel data).""" class TextureStagingContext: @@ -2096,6 +2095,12 @@ def write_blender_image_to_path(image_name: str, destination_path: Path) -> None if image is None: raise RuntimeError(f"Blender image '{image_name}' is no longer available for export") + if not getattr(image, "has_data", True) or image.size[0] == 0 or image.size[1] == 0: + raise UnsupportedTextureFormatError( + f"'{image_name}' has no pixel data (missing source file or an unassigned " + f"image reference). Skipping texture." + ) + destination_path.parent.mkdir(parents=True, exist_ok=True) original_filepath_raw = getattr(image, "filepath_raw", "") From b8ee051df0b5552be541758cc53c180de01fc619 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Sun, 5 Jul 2026 23:31:32 -0700 Subject: [PATCH 3/7] [Patch] Added a optimize flag to export script --- .../UntoldEngineCLI/ExportCommand.swift | 54 +++++++++++++++- .../UntoldEngineCLI/TexbakeCommand.swift | 61 ++++++++++--------- 2 files changed, 82 insertions(+), 33 deletions(-) diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift index d2d32a45..33af6648 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/ExportCommand.swift @@ -19,9 +19,14 @@ struct ExportCommand: ParsableCommand { Runs the UntoldEngine exporter through Blender. Input and output paths may be absolute or relative to the current directory. + --optimize compresses geometry and, if the export produced a Textures + directory, bakes those textures to .utex and patches the .untold + references — equivalent to running --compress-geometry followed by + `untoldengine texbake --dir` and `untoldengine texbake --patch-refs`. + Example: - untoldengine export --input model.usdz --output model.untold --convert-orientation --compress-geometry - untoldengine export --input model.blend --output model.untold --convert-orientation --compress-geometry + untoldengine export --input model.usdz --output model.untold --convert-orientation --optimize + untoldengine export --input model.blend --output model.untold --convert-orientation --optimize """ ) @@ -55,6 +60,9 @@ struct ExportCommand: ParsableCommand { @Flag(name: .long, help: "Export animation clips without mesh geometry") var animation = false + @Flag(name: .long, help: "Compress geometry and bake/patch textures after export (implies --compress-geometry)") + var optimize = false + func run() throws { let inputURL = resolvePath(input).standardizedFileURL let outputURL = resolvePath(output).standardizedFileURL @@ -75,7 +83,7 @@ struct ExportCommand: ParsableCommand { if let meshName { exporterArguments += ["--mesh-name", meshName] } if convertOrientation { exporterArguments.append("--convert-orientation") } if validate { exporterArguments.append("--validate") } - if compressGeometry { exporterArguments.append("--compress-geometry") } + if compressGeometry || optimize { exporterArguments.append("--compress-geometry") } if animation { exporterArguments.append("--animation") } printInfo("Using Blender: \(blenderURL.path)") @@ -99,6 +107,43 @@ struct ExportCommand: ParsableCommand { throw ExportError.exportFailed(process.terminationStatus) } printSuccess("Exported: \(outputURL.path)") + + if optimize { + try optimizeTextures(outputURL: outputURL) + } + } + + private func optimizeTextures(outputURL: URL) throws { + let texturesDir = outputURL.deletingLastPathComponent().appendingPathComponent("Textures") + guard validateDirectory(texturesDir) else { + printInfo("No Textures directory found beside the output; skipping texture optimization.") + return + } + + let python3URL = try resolvePython3() + let texbakeScriptURL = try resolveTexbakeScript() + + printInfo("Baking textures: \(texturesDir.path)") + try runPython(python3URL, [texbakeScriptURL.path, "--dir", texturesDir.path]) + + printInfo("Patching texture references: \(outputURL.path)") + try runPython(python3URL, [texbakeScriptURL.path, "--patch-refs", outputURL.path]) + + printSuccess("Optimized textures: \(texturesDir.path)") + } + + private func runPython(_ python3URL: URL, _ arguments: [String]) throws { + let process = Process() + process.executableURL = python3URL + process.arguments = arguments + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw ExportError.optimizeFailed(process.terminationStatus) + } } private func resolveBlender() throws -> URL { @@ -167,6 +212,7 @@ enum ExportError: LocalizedError { case blenderNotExecutable(String) case exporterNotInstalled(String) case exportFailed(Int32) + case optimizeFailed(Int32) var errorDescription: String? { switch self { @@ -180,6 +226,8 @@ enum ExportError: LocalizedError { return "Exporter support files were not found. Reinstall the CLI. Expected: \(path)" case let .exportFailed(status): return "Blender exporter failed with exit status \(status)" + case let .optimizeFailed(status): + return "Texture optimization (texbake) failed with exit status \(status)" } } } diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/TexbakeCommand.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/TexbakeCommand.swift index c7fbb5f7..08ff2874 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/TexbakeCommand.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/TexbakeCommand.swift @@ -77,44 +77,45 @@ struct TexbakeCommand: ParsableCommand { throw TexbakeError.failed(process.terminationStatus) } } +} - private func resolvePython3() throws -> URL { - if let override = ProcessInfo.processInfo.environment["PYTHON3_BIN"], !override.isEmpty { - let path = resolvePath(override).path - guard FileManager.default.isExecutableFile(atPath: path) else { - throw TexbakeError.pythonNotExecutable(path) - } - return URL(fileURLWithPath: path) +/// Shared with ExportCommand's --optimize, which also needs to run texbake.py. +func resolvePython3() throws -> URL { + if let override = ProcessInfo.processInfo.environment["PYTHON3_BIN"], !override.isEmpty { + let path = resolvePath(override).path + guard FileManager.default.isExecutableFile(atPath: path) else { + throw TexbakeError.pythonNotExecutable(path) } + return URL(fileURLWithPath: path) + } - for directory in ProcessInfo.processInfo.environment["PATH", default: ""].split(separator: ":") { - let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent("python3") - if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate } - } - throw TexbakeError.pythonNotFound + for directory in ProcessInfo.processInfo.environment["PATH", default: ""].split(separator: ":") { + let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent("python3") + if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate } } + throw TexbakeError.pythonNotFound +} - private func resolveTexbakeScript() throws -> URL { - if let supportDirectory = ProcessInfo.processInfo.environment["UNTOLDENGINE_EXPORTER_DIR"] { - let candidate = URL(fileURLWithPath: supportDirectory).appendingPathComponent("texbake.py") - if FileManager.default.fileExists(atPath: candidate.path) { return candidate } - } +func resolveTexbakeScript() throws -> URL { + if let supportDirectory = ProcessInfo.processInfo.environment["UNTOLDENGINE_EXPORTER_DIR"] { + let candidate = URL(fileURLWithPath: supportDirectory).appendingPathComponent("texbake.py") + if FileManager.default.fileExists(atPath: candidate.path) { return candidate } + } - let executableURL = Bundle.main.executableURL - ?? URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL - let installedScript = executableURL - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("libexec/untoldengine/texbake.py") - if FileManager.default.fileExists(atPath: installedScript.path) { return installedScript } + let executableURL = Bundle.main.executableURL + ?? URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL + let installedScript = executableURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("libexec/untoldengine/texbake.py") + if FileManager.default.fileExists(atPath: installedScript.path) { return installedScript } - let developmentScript = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - .appendingPathComponent("../../scripts/texbake.py") - .standardizedFileURL - if FileManager.default.fileExists(atPath: developmentScript.path) { return developmentScript } + let developmentScript = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("../../scripts/texbake.py") + .standardizedFileURL + if FileManager.default.fileExists(atPath: developmentScript.path) { return developmentScript } - throw TexbakeError.notInstalled(installedScript.path) - } + throw TexbakeError.notInstalled(installedScript.path) } enum TexbakeError: LocalizedError { From 5d631ff03e2f8dce95ee2984df881ac035d77cf0 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 6 Jul 2026 00:04:33 -0700 Subject: [PATCH 4/7] [Patch] added bootstrap command to script --- .../UntoldEngineCLI/BootstrapCommand.swift | 209 ++++++++++++++++++ .../UntoldEngineCLI/UntoldEngineCLI.swift | 2 +- docs/API/Optimizations.md | 21 +- docs/API/UsingUntoldEngineCLI.md | 27 ++- scripts/texbake.py | 18 +- 5 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BootstrapCommand.swift diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BootstrapCommand.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BootstrapCommand.swift new file mode 100644 index 00000000..441c4972 --- /dev/null +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BootstrapCommand.swift @@ -0,0 +1,209 @@ +// +// BootstrapCommand.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import ArgumentParser +import CryptoKit +import Foundation + +struct BootstrapCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "bootstrap", + abstract: "Install native tools the exporter pipeline needs (e.g. astcenc)", + discussion: """ + Downloads, verifies, and installs the external command-line tools that + `untoldengine texbake` relies on into ~/.untoldengine/tools, so you + don't have to hunt down releases or set environment variables like + ASTCENC_BIN by hand. + + Example: + untoldengine bootstrap + """ + ) + + @Flag(name: .long, help: "Reinstall even if the pinned version is already present") + var force = false + + private var dependencies: [any BootstrapDependency] { + [AstcencDependency()] + } + + func run() async throws { + for dependency in dependencies { + try await install(dependency) + } + } + + private func install(_ dependency: any BootstrapDependency) async throws { + if !force, dependency.isInstalled() { + printSuccess("\(dependency.name) \(dependency.version) already installed: \(dependency.installedBinaryURL.path)") + return + } + + guard let asset = dependency.assetForCurrentPlatform() else { + throw BootstrapError.unsupportedPlatform(dependency.name) + } + + printInfo("Downloading \(dependency.name) \(dependency.version)...") + let zipURL = try await download(from: asset.url) + defer { try? FileManager.default.removeItem(at: zipURL) } + + try verifyChecksum(of: zipURL, expected: asset.sha256, name: dependency.name) + printInfo("Checksum verified") + + printInfo("Installing \(dependency.name) to \(dependency.installedBinaryURL.path)") + try dependency.install(from: zipURL) + + printSuccess("Installed \(dependency.name) \(dependency.version): \(dependency.installedBinaryURL.path)") + } + + private func download(from url: URL) async throws -> URL { + let (tempURL, response) = try await URLSession.shared.download(from: url) + + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw BootstrapError.downloadFailed("Server returned a non-200 response for \(url.absoluteString)") + } + + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".zip") + try FileManager.default.moveItem(at: tempURL, to: destination) + return destination + } + + private func verifyChecksum(of fileURL: URL, expected: String, name: String) throws { + let data = try Data(contentsOf: fileURL) + let digest = SHA256.hash(data: data) + let actual = digest.map { String(format: "%02x", $0) }.joined() + guard actual.caseInsensitiveCompare(expected) == .orderedSame else { + throw BootstrapError.checksumMismatch(name: name, expected: expected, actual: actual) + } + } +} + +// MARK: - Dependency model + +/// A single external tool that `bootstrap` knows how to fetch and install. +/// Add a new type conforming to this protocol and list it in +/// `BootstrapCommand.dependencies` to bootstrap another dependency. +protocol BootstrapDependency { + var name: String { get } + var version: String { get } + var installedBinaryURL: URL { get } + + func isInstalled() -> Bool + func assetForCurrentPlatform() -> (url: URL, sha256: String)? + func install(from zipURL: URL) throws +} + +/// Directory bootstrap-managed tools are installed into: ~/.untoldengine/tools +/// (override with UNTOLDENGINE_HOME). Shared with texbake.py's astcenc lookup. +func untoldEngineToolsDirectory() -> URL { + if let override = ProcessInfo.processInfo.environment["UNTOLDENGINE_HOME"], !override.isEmpty { + return resolvePath(override).appendingPathComponent("tools", isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".untoldengine", isDirectory: true) + .appendingPathComponent("tools", isDirectory: true) +} + +// MARK: - astcenc + +struct AstcencDependency: BootstrapDependency { + let name = "astcenc" + let version = "5.3.0" + + private var installDirectory: URL { + untoldEngineToolsDirectory().appendingPathComponent(name, isDirectory: true) + } + + var installedBinaryURL: URL { + installDirectory.appendingPathComponent(name) + } + + private var versionMarkerURL: URL { + installDirectory.appendingPathComponent(".version") + } + + func isInstalled() -> Bool { + guard FileManager.default.isExecutableFile(atPath: installedBinaryURL.path) else { return false } + let installedVersion = try? String(contentsOf: versionMarkerURL, encoding: .utf8) + return installedVersion?.trimmingCharacters(in: .whitespacesAndNewlines) == version + } + + func assetForCurrentPlatform() -> (url: URL, sha256: String)? { + #if os(macOS) + guard let url = URL(string: "https://github.com/ARM-software/astc-encoder/releases/download/\(version)/astcenc-\(version)-macos-universal.zip") else { + return nil + } + // Published in release-sha256.txt alongside the 5.3.0 GitHub release assets. + return (url, "ccccba91fe134cc8c4aa70f7d539acb3de9895b656c24e401e562cc1014e6afd") + #else + return nil + #endif + } + + func install(from zipURL: URL) throws { + let fm = FileManager.default + let extractDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: extractDir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: extractDir) } + + let errorPipe = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-q", zipURL.path, "-d", extractDir.path] + process.standardError = errorPipe + + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let msg = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "Unknown error" + throw BootstrapError.extractionFailed(name: name, message: msg.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + let extractedBinary = extractDir.appendingPathComponent("bin/astcenc") + guard fm.fileExists(atPath: extractedBinary.path) else { + throw BootstrapError.unexpectedArchiveLayout(name) + } + + try fm.createDirectory(at: installDirectory, withIntermediateDirectories: true) + if fm.fileExists(atPath: installedBinaryURL.path) { + try fm.removeItem(at: installedBinaryURL) + } + try fm.copyItem(at: extractedBinary, to: installedBinaryURL) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: installedBinaryURL.path) + + try version.write(to: versionMarkerURL, atomically: true, encoding: .utf8) + } +} + +// MARK: - Errors + +enum BootstrapError: LocalizedError { + case unsupportedPlatform(String) + case downloadFailed(String) + case checksumMismatch(name: String, expected: String, actual: String) + case extractionFailed(name: String, message: String) + case unexpectedArchiveLayout(String) + + var errorDescription: String? { + switch self { + case let .unsupportedPlatform(name): + return "\(name) has no bootstrap download for this platform. See docs/API/Optimizations.md for manual install instructions." + case let .downloadFailed(message): + return "Download failed: \(message)" + case let .checksumMismatch(name, expected, actual): + return "Checksum mismatch for \(name): expected \(expected), got \(actual). The download may be corrupted; try again." + case let .extractionFailed(name, message): + return "Failed to extract \(name): \(message)" + case let .unexpectedArchiveLayout(name): + return "\(name) archive did not contain the expected binary layout." + } + } +} diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift index bfe36020..a6202c15 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift @@ -16,6 +16,6 @@ struct UntoldEngineCLI: AsyncParsableCommand { commandName: "untoldengine", abstract: "UntoldEngine command-line tools", version: "0.1.0", - subcommands: [CreateCommand.self, UpdateCommand.self, AssetsCommand.self, ExportCommand.self, TexbakeCommand.self] + subcommands: [CreateCommand.self, UpdateCommand.self, AssetsCommand.self, ExportCommand.self, TexbakeCommand.self, BootstrapCommand.self] ) } diff --git a/docs/API/Optimizations.md b/docs/API/Optimizations.md index 8632e390..7f36d107 100644 --- a/docs/API/Optimizations.md +++ b/docs/API/Optimizations.md @@ -15,25 +15,32 @@ ASTC compression is a post-export step run with `untoldengine texbake`, separate ### Prerequisites -Download the appropriate macOS build from the -[`astcenc` releases page](https://github.com/ARM-software/astc-encoder/releases), -extract it, and ensure the encoder binary is executable: +Install `astcenc` with: ```bash -chmod +x /full/path/to/astcenc +untoldengine bootstrap ``` -Set `ASTCENC_BIN` to its absolute path before running `untoldengine texbake`: +This downloads the pinned, checksum-verified `astcenc` release into +`~/.untoldengine/tools/astcenc/astcenc`. `untoldengine texbake` finds it there +automatically — no environment variables to set. Re-running `bootstrap` is a +no-op once the pinned version is installed; pass `--force` to reinstall. + +If you'd rather manage the binary yourself, download the appropriate build from the +[`astcenc` releases page](https://github.com/ARM-software/astc-encoder/releases), +make it executable, and set `ASTCENC_BIN` to its absolute path: ```bash +chmod +x /full/path/to/astcenc export ASTCENC_BIN="/full/path/to/astcenc" ``` The tool is resolved in this order: 1. `ASTCENC_BIN=/path/to/astcenc` -2. `Tools/astcenc/astcenc` beside the repo root -3. `astcenc` on `PATH` +2. `~/.untoldengine/tools/astcenc/astcenc` (where `bootstrap` installs it; override the home directory with `UNTOLDENGINE_HOME`) +3. `Tools/astcenc/astcenc` beside the repo root +4. `astcenc` on `PATH` ### Single asset workflow diff --git a/docs/API/UsingUntoldEngineCLI.md b/docs/API/UsingUntoldEngineCLI.md index 53c3b983..baedaf6a 100644 --- a/docs/API/UsingUntoldEngineCLI.md +++ b/docs/API/UsingUntoldEngineCLI.md @@ -111,18 +111,39 @@ untoldengine update ~/Projects/MyGame --asset-path ~/GameAssets --- -## Exporting USD/USDZ Assets +## Bootstrapping Dependencies -Run the exporter from the game project or any other directory: +Some optimizations (ASTC texture compression) rely on external native tools. +Install them once with: + +```bash +untoldengine bootstrap +``` + +This downloads and verifies pinned tool versions into `~/.untoldengine/tools` +so `untoldengine export --optimize` and `untoldengine texbake` find them +automatically. See [Optimizations](Optimizations.md) for details. + +--- + +## Exporting Assets + +Run the exporter from the game project or any other directory. Input can be a +USD/USDZ asset or a `.blend` file: ```bash untoldengine export \ --input /path/to/model.usdz \ --output /path/to/model.untold \ --convert-orientation \ - --compress-geometry + --optimize ``` +`--optimize` compresses geometry and, if the asset has textures, bakes and +patches them to `.utex` — equivalent to running `--compress-geometry` +followed by `untoldengine texbake --dir` and `--patch-refs`. See +[Optimizations](Optimizations.md) for what each flag does. + Use `--blender /path/to/Blender` when Blender is not installed in its standard macOS location and is not available on `PATH`. diff --git a/scripts/texbake.py b/scripts/texbake.py index 661a72a5..a2bb14e5 100644 --- a/scripts/texbake.py +++ b/scripts/texbake.py @@ -223,8 +223,9 @@ def generate_mip_chain(img: "Image.Image") -> list["Image.Image"]: def find_astcenc() -> str: """ - Locate the astcenc binary. Tries an explicit env var, a shared Tools folder, - then common names on PATH. + Locate the astcenc binary. Tries an explicit env var, the location + `untoldengine bootstrap` installs to, a shared Tools folder, then common + names on PATH. Returns the binary name/path if found, raises RuntimeError otherwise. """ env_override = os.environ.get("ASTCENC_BIN") @@ -233,6 +234,12 @@ def find_astcenc() -> str: if astcenc_path.is_file() and os.access(astcenc_path, os.X_OK): return str(astcenc_path) + untoldengine_home = os.environ.get("UNTOLDENGINE_HOME") + home_root = Path(untoldengine_home).expanduser() if untoldengine_home else Path.home() / ".untoldengine" + bootstrap_candidate = home_root / "tools" / "astcenc" / "astcenc" + if bootstrap_candidate.is_file() and os.access(bootstrap_candidate, os.X_OK): + return str(bootstrap_candidate) + repo_root = Path(__file__).resolve().parents[1] shared_tools_candidate = repo_root.parent / "Tools" / "astcenc" / "astcenc" if shared_tools_candidate.is_file() and os.access(shared_tools_candidate, os.X_OK): @@ -243,9 +250,10 @@ def find_astcenc() -> str: if shutil.which(name): return name raise RuntimeError( - "astcenc not found on PATH.\n" - "Set ASTCENC_BIN=/full/path/to/astcenc\n" - f"Or place it at: {shared_tools_candidate}\n" + "astcenc not found.\n" + "Run: untoldengine bootstrap\n" + "Or set ASTCENC_BIN=/full/path/to/astcenc\n" + f"Or place it at: {bootstrap_candidate}\n" "Download it from: https://github.com/ARM-software/astc-encoder/releases" ) From adadf7dade011ef5b13c6088b7ddc75b9db68467 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 6 Jul 2026 05:32:14 -0700 Subject: [PATCH 5/7] [Patch] Added bootstrap script --- .../UntoldEngineCLI/BootstrapCommand.swift | 185 +++++++++++++----- .../Sources/UntoldEngineCLI/CLIHelpers.swift | 18 ++ docs/API/Optimizations.md | 12 +- docs/API/UsingUntoldEngineCLI.md | 11 +- scripts/texbake.py | 8 +- 5 files changed, 171 insertions(+), 63 deletions(-) diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BootstrapCommand.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BootstrapCommand.swift index 441c4972..9d182454 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BootstrapCommand.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BootstrapCommand.swift @@ -15,23 +15,27 @@ import Foundation struct BootstrapCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "bootstrap", - abstract: "Install native tools the exporter pipeline needs (e.g. astcenc)", + abstract: "Install everything the exporter pipeline needs (e.g. astcenc, Pillow, lz4)", discussion: """ - Downloads, verifies, and installs the external command-line tools that - `untoldengine texbake` relies on into ~/.untoldengine/tools, so you - don't have to hunt down releases or set environment variables like - ASTCENC_BIN by hand. + Downloads, verifies, and installs the external tools and Python + packages that `untoldengine export --optimize` and `untoldengine + texbake` rely on, so you don't have to hunt down releases or run pip + install by hand. Example: untoldengine bootstrap """ ) - @Flag(name: .long, help: "Reinstall even if the pinned version is already present") + @Flag(name: .long, help: "Reinstall even if already present") var force = false private var dependencies: [any BootstrapDependency] { - [AstcencDependency()] + [ + AstcencDependency(), + PipPackageDependency(name: "Pillow", importName: "PIL", pipSpec: "Pillow"), + PipPackageDependency(name: "lz4", importName: "lz4", pipSpec: "lz4"), + ] } func run() async throws { @@ -42,63 +46,39 @@ struct BootstrapCommand: AsyncParsableCommand { private func install(_ dependency: any BootstrapDependency) async throws { if !force, dependency.isInstalled() { - printSuccess("\(dependency.name) \(dependency.version) already installed: \(dependency.installedBinaryURL.path)") + printSuccess("\(dependency.name) already installed (\(dependency.statusDetail))") return } - guard let asset = dependency.assetForCurrentPlatform() else { - throw BootstrapError.unsupportedPlatform(dependency.name) - } - - printInfo("Downloading \(dependency.name) \(dependency.version)...") - let zipURL = try await download(from: asset.url) - defer { try? FileManager.default.removeItem(at: zipURL) } - - try verifyChecksum(of: zipURL, expected: asset.sha256, name: dependency.name) - printInfo("Checksum verified") - - printInfo("Installing \(dependency.name) to \(dependency.installedBinaryURL.path)") - try dependency.install(from: zipURL) - - printSuccess("Installed \(dependency.name) \(dependency.version): \(dependency.installedBinaryURL.path)") - } - - private func download(from url: URL) async throws -> URL { - let (tempURL, response) = try await URLSession.shared.download(from: url) - - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - throw BootstrapError.downloadFailed("Server returned a non-200 response for \(url.absoluteString)") - } - - let destination = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString + ".zip") - try FileManager.default.moveItem(at: tempURL, to: destination) - return destination - } + printInfo("Installing \(dependency.name)...") + try await dependency.install() - private func verifyChecksum(of fileURL: URL, expected: String, name: String) throws { - let data = try Data(contentsOf: fileURL) - let digest = SHA256.hash(data: data) - let actual = digest.map { String(format: "%02x", $0) }.joined() - guard actual.caseInsensitiveCompare(expected) == .orderedSame else { - throw BootstrapError.checksumMismatch(name: name, expected: expected, actual: actual) + guard dependency.isInstalled() else { + throw BootstrapError.verificationFailed(dependency.name) } + printSuccess("Installed \(dependency.name) (\(dependency.statusDetail))") } } // MARK: - Dependency model -/// A single external tool that `bootstrap` knows how to fetch and install. +/// A single external tool or package that `bootstrap` knows how to install. /// Add a new type conforming to this protocol and list it in /// `BootstrapCommand.dependencies` to bootstrap another dependency. protocol BootstrapDependency { var name: String { get } - var version: String { get } - var installedBinaryURL: URL { get } + /// Whether the dependency is already present and usable. func isInstalled() -> Bool - func assetForCurrentPlatform() -> (url: URL, sha256: String)? - func install(from zipURL: URL) throws + + /// Installs the dependency. Only called when `isInstalled()` returned + /// false (or `--force` was passed) — implementations don't need to + /// re-check. + func install() async throws + + /// Human-readable detail (path, version, etc.) shown after `isInstalled()` + /// succeeds, either before or after installation. + var statusDetail: String { get } } /// Directory bootstrap-managed tools are installed into: ~/.untoldengine/tools @@ -130,13 +110,29 @@ struct AstcencDependency: BootstrapDependency { installDirectory.appendingPathComponent(".version") } + var statusDetail: String { + "\(version): \(installedBinaryURL.path)" + } + func isInstalled() -> Bool { guard FileManager.default.isExecutableFile(atPath: installedBinaryURL.path) else { return false } let installedVersion = try? String(contentsOf: versionMarkerURL, encoding: .utf8) return installedVersion?.trimmingCharacters(in: .whitespacesAndNewlines) == version } - func assetForCurrentPlatform() -> (url: URL, sha256: String)? { + func install() async throws { + guard let asset = assetForCurrentPlatform() else { + throw BootstrapError.unsupportedPlatform(name) + } + + let zipURL = try await download(from: asset.url) + defer { try? FileManager.default.removeItem(at: zipURL) } + + try verifyChecksum(of: zipURL, expected: asset.sha256) + try extractAndInstall(from: zipURL) + } + + private func assetForCurrentPlatform() -> (url: URL, sha256: String)? { #if os(macOS) guard let url = URL(string: "https://github.com/ARM-software/astc-encoder/releases/download/\(version)/astcenc-\(version)-macos-universal.zip") else { return nil @@ -148,7 +144,29 @@ struct AstcencDependency: BootstrapDependency { #endif } - func install(from zipURL: URL) throws { + private func download(from url: URL) async throws -> URL { + let (tempURL, response) = try await URLSession.shared.download(from: url) + + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw BootstrapError.downloadFailed("Server returned a non-200 response for \(url.absoluteString)") + } + + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".zip") + try FileManager.default.moveItem(at: tempURL, to: destination) + return destination + } + + private func verifyChecksum(of fileURL: URL, expected: String) throws { + let data = try Data(contentsOf: fileURL) + let digest = SHA256.hash(data: data) + let actual = digest.map { String(format: "%02x", $0) }.joined() + guard actual.caseInsensitiveCompare(expected) == .orderedSame else { + throw BootstrapError.checksumMismatch(name: name, expected: expected, actual: actual) + } + } + + private func extractAndInstall(from zipURL: URL) throws { let fm = FileManager.default let extractDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) try fm.createDirectory(at: extractDir, withIntermediateDirectories: true) @@ -183,6 +201,67 @@ struct AstcencDependency: BootstrapDependency { } } +// MARK: - Python packages (Pillow, lz4, ...) + +/// Installs a single Python package via `pip install --user` and verifies it +/// importable by whatever python3 `untoldengine texbake` will itself resolve +/// (env override, then PATH — see resolvePython3() in TexbakeCommand.swift). +struct PipPackageDependency: BootstrapDependency { + let name: String + let importName: String + let pipSpec: String + + var statusDetail: String { + "importable by python3 as '\(importName)'" + } + + func isInstalled() -> Bool { + canImport(importName) + } + + func install() async throws { + let python3URL = try resolvePython3() + // `pip install --user` is rejected inside a venv/virtualenv (site-packages + // are already isolated there, and --user has no meaning). Only add it + // when installing against a system/base Python interpreter. + var arguments = ["-m", "pip", "install"] + if !isVirtualEnvironment(python3URL) { + arguments.append("--user") + } + arguments.append(pipSpec) + + let status = try runInheritedProcess(python3URL, arguments) + guard status == 0 else { + throw BootstrapError.pipInstallFailed(name: name, status: status) + } + } + + private func canImport(_ module: String) -> Bool { + guard let python3URL = try? resolvePython3() else { return false } + let process = Process() + process.executableURL = python3URL + process.arguments = ["-c", "import \(module)"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return false } + process.waitUntilExit() + return process.terminationStatus == 0 + } + + private func isVirtualEnvironment(_ python3URL: URL) -> Bool { + let process = Process() + process.executableURL = python3URL + process.arguments = ["-c", "import sys; print(1 if (hasattr(sys, 'real_prefix') or sys.base_prefix != sys.prefix) else 0)"] + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return false } + process.waitUntilExit() + let output = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) + return output?.trimmingCharacters(in: .whitespacesAndNewlines) == "1" + } +} + // MARK: - Errors enum BootstrapError: LocalizedError { @@ -191,6 +270,8 @@ enum BootstrapError: LocalizedError { case checksumMismatch(name: String, expected: String, actual: String) case extractionFailed(name: String, message: String) case unexpectedArchiveLayout(String) + case pipInstallFailed(name: String, status: Int32) + case verificationFailed(String) var errorDescription: String? { switch self { @@ -204,6 +285,10 @@ enum BootstrapError: LocalizedError { return "Failed to extract \(name): \(message)" case let .unexpectedArchiveLayout(name): return "\(name) archive did not contain the expected binary layout." + case let .pipInstallFailed(name, status): + return "pip install failed for \(name) with exit status \(status)" + case let .verificationFailed(name): + return "\(name) still isn't detected after installing. Check the output above for errors." } } } diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/CLIHelpers.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/CLIHelpers.swift index 893a9b72..224139d7 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/CLIHelpers.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/CLIHelpers.swift @@ -76,6 +76,24 @@ func validateDirectory(_ url: URL) -> Bool { return exists && isDirectory.boolValue } +// MARK: - Process Execution + +/// Runs a process with stdout/stderr connected to this process's own, so the +/// child's output streams live (e.g. pip's install progress). Returns the +/// exit status; callers decide what error to throw on non-zero. +@discardableResult +func runInheritedProcess(_ executableURL: URL, _ arguments: [String]) throws -> Int32 { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + return process.terminationStatus +} + // MARK: - Asset Folder Structure /// Creates the standard UntoldEngine asset folder structure diff --git a/docs/API/Optimizations.md b/docs/API/Optimizations.md index 7f36d107..43b2e919 100644 --- a/docs/API/Optimizations.md +++ b/docs/API/Optimizations.md @@ -15,18 +15,20 @@ ASTC compression is a post-export step run with `untoldengine texbake`, separate ### Prerequisites -Install `astcenc` with: +Install everything `texbake` needs — `astcenc`, and the `Pillow`/`lz4` Python +packages — with: ```bash untoldengine bootstrap ``` This downloads the pinned, checksum-verified `astcenc` release into -`~/.untoldengine/tools/astcenc/astcenc`. `untoldengine texbake` finds it there -automatically — no environment variables to set. Re-running `bootstrap` is a -no-op once the pinned version is installed; pass `--force` to reinstall. +`~/.untoldengine/tools/astcenc/astcenc` and `pip install`s `Pillow`/`lz4` for +whichever `python3` `untoldengine texbake` will itself resolve. Nothing to set +by hand. Re-running `bootstrap` is a no-op once everything is present; pass +`--force` to reinstall. -If you'd rather manage the binary yourself, download the appropriate build from the +If you'd rather manage `astcenc` yourself, download the appropriate build from the [`astcenc` releases page](https://github.com/ARM-software/astc-encoder/releases), make it executable, and set `ASTCENC_BIN` to its absolute path: diff --git a/docs/API/UsingUntoldEngineCLI.md b/docs/API/UsingUntoldEngineCLI.md index baedaf6a..3c820cd2 100644 --- a/docs/API/UsingUntoldEngineCLI.md +++ b/docs/API/UsingUntoldEngineCLI.md @@ -113,16 +113,17 @@ untoldengine update ~/Projects/MyGame --asset-path ~/GameAssets ## Bootstrapping Dependencies -Some optimizations (ASTC texture compression) rely on external native tools. -Install them once with: +Some optimizations (ASTC texture compression) rely on external tools and +Python packages. Install them once with: ```bash untoldengine bootstrap ``` -This downloads and verifies pinned tool versions into `~/.untoldengine/tools` -so `untoldengine export --optimize` and `untoldengine texbake` find them -automatically. See [Optimizations](Optimizations.md) for details. +This downloads and verifies a pinned `astcenc` into `~/.untoldengine/tools` +and installs the `Pillow`/`lz4` Python packages, so `untoldengine export +--optimize` and `untoldengine texbake` find everything automatically. See +[Optimizations](Optimizations.md) for details. --- diff --git a/scripts/texbake.py b/scripts/texbake.py index a2bb14e5..c88a2a24 100644 --- a/scripts/texbake.py +++ b/scripts/texbake.py @@ -14,9 +14,11 @@ export-untold-tiles has produced a textures/ directory. Requirements: - pip install Pillow - Download astcenc from https://github.com/ARM-software/astc-encoder/releases - and set ASTCENC_BIN=/full/path/to/astcenc + Run `untoldengine bootstrap` to install Pillow, lz4, and astcenc automatically. + Or install manually: + pip install Pillow lz4 + Download astcenc from https://github.com/ARM-software/astc-encoder/releases + and set ASTCENC_BIN=/full/path/to/astcenc Usage (single file): python scripts/texbake.py --input textures/wall_basecolor.png --slot base_color From 7cee8cc8c4dc0d431d7eecf14c8a3191ceb3fb4d Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 6 Jul 2026 06:07:14 -0700 Subject: [PATCH 6/7] [Docs] Added documentation for the script --- docs/API/GettingStarted.md | 54 ++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index 034473ca..89e12619 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -88,11 +88,28 @@ To see all available asset packs: untoldengine assets list ``` +## Bootstrap Exporter Dependencies + +Before exporting or optimizing assets, install the external tools the CLI +relies on — the `astcenc` texture compressor and the `Pillow`/`lz4` Python +packages — in one step: + +```bash +untoldengine bootstrap +``` + +This downloads a pinned, checksum-verified `astcenc` release into +`~/.untoldengine/tools` and `pip install`s the Python packages. Nothing to +download by hand or wire up with environment variables. Re-running `bootstrap` +is a no-op once everything is installed; pass `--force` to reinstall. See +[Optimizations](Optimizations.md) for details. + ## Native Asset Format: `.untold` -Untold Engine uses `.untold` as its native runtime asset format. USDZ/USD remains -the authoring format — you model assets in your DCC tool, export to USDZ, then -convert to `.untold` before loading them in the engine. +Untold Engine uses `.untold` as its native runtime asset format. You author +assets in Blender (or any DCC tool that exports USD/USDZ), then convert them +to `.untold` before loading them in the engine. The exporter accepts either a +`.blend` file directly or a USD/USDZ asset. The `.untold` format is a binary container optimised for fast runtime parsing with no ModelIO dependency. It supports runtime mesh data, PBR materials, texture references, @@ -108,24 +125,45 @@ After the model has been converted to `.untold` format, copy it into your Xcode ### Option 2: CLI -Use the `export-untold` script to convert a single USDZ asset: +Use `untoldengine export` to convert a single asset — a `.blend` file or a USD/USDZ asset — into `.untold`: + +```bash +untoldengine export \ + --input /path/to/your/model/robot/robot.blend \ + --output /path/to/your/project/GameData/Models/robot/robot.untold \ + --convert-orientation +``` + +USD/USDZ input works the same way: ```bash -./scripts/export-untold \ +untoldengine export \ --input /path/to/your/model/robot/robot.usdz \ --output /path/to/your/project/GameData/Models/robot/robot.untold \ + --convert-orientation +``` + +Add `--optimize` to also LZ4-compress geometry and, if the asset has textures, +compress them with `astcenc` into `.utex` — equivalent to running +`--compress-geometry` followed by `untoldengine texbake --dir` and +`--patch-refs`. Run `untoldengine bootstrap` once beforehand so the tools it +needs are available: + +```bash +untoldengine export \ + --input /path/to/your/model/robot/robot.blend \ + --output /path/to/your/project/GameData/Models/robot/robot.untold \ --convert-orientation \ - --source-orientation blender-native + --optimize ``` For animation assets, use the `--animation` flag: ```bash -./scripts/export-untold \ +untoldengine export \ --input /path/to/your/animation/robot/robot.usdz \ --output /path/to/your/project/GameData/Animations/robot/robot.untold \ --convert-orientation \ - --source-orientation blender-native \ --animation ``` From f45bcdaf20250c9dd4a2685a0211f4ef46163b5f Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 6 Jul 2026 06:36:02 -0700 Subject: [PATCH 7/7] [Patch]Added script to update blender addon --- Sources/Sandbox/GameScene.swift | 5 ++ .../UntoldEngineCLI/BlenderAddonCommand.swift | 64 +++++++++++++++++++ .../UntoldEngineCLI/UntoldEngineCLI.swift | 2 +- scripts/untold-blender-addon/README.md | 6 +- 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BlenderAddonCommand.swift diff --git a/Sources/Sandbox/GameScene.swift b/Sources/Sandbox/GameScene.swift index 8f54c4fa..1abc1992 100644 --- a/Sources/Sandbox/GameScene.swift +++ b/Sources/Sandbox/GameScene.swift @@ -55,6 +55,11 @@ setSceneReady(success) } */ + + let splat = createEntity() + setEntityGaussian(entityId: splat, filename: "/Users/haroldserrano/Downloads/truck/point_cloud/iteration_7000/point_cloud", withExtension: "ply") + rotateBy(entityId: splat, angle: 180.0, axis: simd_float3(0.0, 1.0, 0.0)) + setSceneReady(true) } private func configureEngineSystems() { diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BlenderAddonCommand.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BlenderAddonCommand.swift new file mode 100644 index 00000000..4edfc6f3 --- /dev/null +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BlenderAddonCommand.swift @@ -0,0 +1,64 @@ +// +// BlenderAddonCommand.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import ArgumentParser +import Foundation + +struct BlenderAddonCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "blender-addon", + abstract: "Package the Blender add-on into an installable zip", + discussion: """ + Runs scripts/untold-blender-addon/package.sh, which bundles the + add-on source with fresh vendored copies of the exporter scripts + (untoldexplorer.py, texbake.py, tilestreamingpartition.py) into + scripts/untold-blender-addon/build/untold_exporter.zip. + + This is an engine-repo tool: run it from the UntoldEngine repository + root, the same way you'd run `swift build`. + + Example: + untoldengine blender-addon + """ + ) + + func run() throws { + let packageScript = resolvePath("scripts/untold-blender-addon/package.sh") + guard FileManager.default.fileExists(atPath: packageScript.path) else { + throw BlenderAddonError.scriptNotFound(packageScript.path) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [packageScript.path] + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw BlenderAddonError.packagingFailed(process.terminationStatus) + } + } +} + +enum BlenderAddonError: LocalizedError { + case scriptNotFound(String) + case packagingFailed(Int32) + + var errorDescription: String? { + switch self { + case let .scriptNotFound(path): + return "Could not find \(path). Run this command from the UntoldEngine repository root." + case let .packagingFailed(status): + return "Packaging the Blender add-on failed with exit status \(status)" + } + } +} diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift index a6202c15..d8d779a9 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift @@ -16,6 +16,6 @@ struct UntoldEngineCLI: AsyncParsableCommand { commandName: "untoldengine", abstract: "UntoldEngine command-line tools", version: "0.1.0", - subcommands: [CreateCommand.self, UpdateCommand.self, AssetsCommand.self, ExportCommand.self, TexbakeCommand.self, BootstrapCommand.self] + subcommands: [CreateCommand.self, UpdateCommand.self, AssetsCommand.self, ExportCommand.self, TexbakeCommand.self, BootstrapCommand.self, BlenderAddonCommand.self] ) } diff --git a/scripts/untold-blender-addon/README.md b/scripts/untold-blender-addon/README.md index 3df71837..738a30d1 100644 --- a/scripts/untold-blender-addon/README.md +++ b/scripts/untold-blender-addon/README.md @@ -101,12 +101,14 @@ and tiled streaming scenes from Blender scene objects. ## Packaging -Run: +From the repository root, run: ```sh -scripts/untold-blender-addon/package.sh +untoldengine blender-addon ``` +(equivalent to running `scripts/untold-blender-addon/package.sh` directly.) + The package script creates an installable zip with bundled copies of `scripts/untoldexplorer.py`, `scripts/texbake.py`, and `scripts/tilestreamingpartition.py` under `untold_exporter/vendor/`.