Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/Sandbox/GameScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)"
}
}
}
294 changes: 294 additions & 0 deletions Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/BootstrapCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
//
// 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 everything the exporter pipeline needs (e.g. astcenc, Pillow, lz4)",
discussion: """
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 already present")
var force = false

private var dependencies: [any BootstrapDependency] {
[
AstcencDependency(),
PipPackageDependency(name: "Pillow", importName: "PIL", pipSpec: "Pillow"),
PipPackageDependency(name: "lz4", importName: "lz4", pipSpec: "lz4"),
]
}

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) already installed (\(dependency.statusDetail))")
return
}

printInfo("Installing \(dependency.name)...")
try await dependency.install()

guard dependency.isInstalled() else {
throw BootstrapError.verificationFailed(dependency.name)
}
printSuccess("Installed \(dependency.name) (\(dependency.statusDetail))")
}
}

// MARK: - Dependency model

/// 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 }

/// Whether the dependency is already present and usable.
func isInstalled() -> Bool

/// 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
/// (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")
}

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 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
}
// Published in release-sha256.txt alongside the 5.3.0 GitHub release assets.
return (url, "ccccba91fe134cc8c4aa70f7d539acb3de9895b656c24e401e562cc1014e6afd")
#else
return nil
#endif
}

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)
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: - 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 {
case unsupportedPlatform(String)
case downloadFailed(String)
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 {
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."
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."
}
}
}
18 changes: 18 additions & 0 deletions Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/CLIHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading