This commit is contained in:
Robin Appelman 2023-12-26 18:35:24 +01:00
commit ecb7d5720b
9 changed files with 1343 additions and 49 deletions

4
.gitignore vendored
View file

@ -5,4 +5,6 @@ result
.direnv
*.snap.new
*.bsp
*.glb
*.glb
cache
config.toml

986
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,19 @@ version = "0.1.0"
edition = "2021"
rust-version = "1.70.0"
[lib]
name = "vbsp_to_gltf"
path = "src/lib.rs"
[[bin]]
name = "vbsp-to-gltf"
path = "src/cli.rs"
[[bin]]
name = "vbsp-server"
path = "src/server/server.rs"
required-features = ["server"]
[dependencies]
miette = { version = "5.5.0", features = ["fancy"] }
vbsp = "0.4.1"
@ -14,7 +27,7 @@ tracing-tree = "0.3.0"
vtf = "0.1.6"
vmt-parser = "0.1.1"
image = "0.23.14"
tf-asset-loader = { version = "0.1", features = ["zip"] }
tf-asset-loader = { version = "0.1.4", features = ["zip"] }
vmdl = "0.1"
clap = { version = "4.0.29", features = ["derive"] }
gltf-json = { version = "1.4.0", features = ["KHR_texture_transform"] }
@ -22,5 +35,18 @@ gltf = "1.4.0"
cgmath = "0.18.0"
bytemuck = { version = "1.14.0", features = ["derive"] }
url = { version = "2.5.0", optional = true, features = ["serde"] }
serde = { version = "1.0.193", optional = true }
toml = { version = "0.8.8", optional = true }
axum = { version = "0.7.2", optional = true, features = ["macros"] }
tokio = { version = "1.35.1", features = ["full"], optional = true }
reqwest = { version = "0.11.23", optional = true, default-features = false, features = ["rustls-tls-webpki-roots"] }
async-tempfile = { version = "0.5.0", optional = true }
tower-http = { version = "0.5.0", optional = true, features = ["cors"] }
http = { version = "1.0.0", optional = true }
[features]
server = ["url", "serde", "toml", "axum", "tokio", "reqwest", "async-tempfile", "tower-http", "http"]
[profile.dev.package."*"]
opt-level = 2

View file

@ -88,6 +88,14 @@
// {
mode = "check";
});
server = naersk'.buildPackage (nearskOpt
// {
pname = "vbsp-server";
preConfigure = ''
cargo_build_options="--features server $cargo_build_options"
'';
buildInputs = with pkgs; [meshoptimizer];
});
default = vbsp-to-gltf;
};
@ -101,25 +109,7 @@
cargo-outdated
cargo-audit
cargo-msrv
cargo-semver-checks
cargo-insta
meshoptimizer
(writeShellApplication {
name = "cargo-fuzz";
runtimeInputs = [cargo-fuzz toolchain];
text = ''
# shellcheck disable=SC2068
RUSTC_BOOTSTRAP=1 cargo-fuzz $@
'';
})
(writeShellApplication {
name = "cargo-expand";
runtimeInputs = [cargo-expand toolchain];
text = ''
# shellcheck disable=SC2068
RUSTC_BOOTSTRAP=1 cargo-expand $@
'';
})
];
in {
default = mkShell {

View file

@ -1,13 +1,4 @@
mod bsp;
pub mod convert;
mod error;
pub mod gltf_builder;
mod materials;
mod prop;
use crate::convert::export;
use clap::Parser;
pub use error::Error;
use miette::Context;
use std::fs::{read, File};
use std::path::PathBuf;
@ -17,6 +8,7 @@ use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
use tracing_tree::HierarchicalLayer;
use vbsp::Bsp;
use vbsp_to_gltf::{export, Error};
fn setup() {
miette::set_panic_hook();

9
src/lib.rs Normal file
View file

@ -0,0 +1,9 @@
mod bsp;
pub mod convert;
mod error;
pub mod gltf_builder;
mod materials;
mod prop;
pub use convert::export;
pub use error::Error;

24
src/server/index.html Normal file
View file

@ -0,0 +1,24 @@
<html>
<head>
<title>TF2 map converter</title>
</head>
<body>
<h1>TF2 map converter</h1>
<p>
Download <span title="that is available on serveme.tf">any<sup>*</sup></span> tf2 map as gltf/glb
</p>
<h2>Usage</h2>
<p>
Simply point your browser (or any other http client) at <code>/gltf/&lt;map name&gt;.glb</code> to download the map
in glb format.
</p>
<p>
For example: <a href="/gltf/cp_steel_f12.glb">/gltf/cp_steel_f12.glb</a>
</p>
<aside>
Note that map conversion can take 10 to 30 seconds, once a map has been converted once it is cached for subsequent requests.
</aside>
</body>
</html>

31
src/server/pack.rs Normal file
View file

@ -0,0 +1,31 @@
use crate::{Result, ServerError};
use async_tempfile::TempFile;
use tokio::fs::read;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
pub async fn pack(map: &str, data: &[u8]) -> Result<Vec<u8>> {
let mut input = TempFile::new_with_name(map.to_string()).await?;
let output = TempFile::new_with_name(format!("out_{map}")).await?;
input.write_all(data).await?;
let out = Command::new("gltfpack")
.arg("-kn")
.arg("-mm")
.arg("-tc")
.arg("-i")
.arg(input.file_path())
.arg("-o")
.arg(output.file_path())
.output()
.await?;
if !out.status.success() {
return Err(ServerError::GltfPack(
String::from_utf8_lossy(&out.stderr).into(),
));
}
Ok(read(output.file_path()).await?)
}

256
src/server/server.rs Normal file
View file

@ -0,0 +1,256 @@
mod pack;
use crate::pack::pack;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use clap::Parser;
use http::Method;
use reqwest::Client;
use serde::Deserialize;
use std::fs::{read, read_to_string, write};
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::sync::Arc;
use tf_asset_loader::{Loader, LoaderError};
use thiserror::Error;
use tokio::net::TcpListener;
use tokio::signal;
use toml::from_str;
use tower_http::cors::{Any, CorsLayer};
use tracing::{error, info};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
use tracing_tree::HierarchicalLayer;
use url::Url;
use vbsp::{Bsp, BspError};
use vbsp_to_gltf::{export, Error};
type Result<T, E = ServerError> = std::result::Result<T, E>;
#[derive(Debug, Deserialize)]
struct Config {
cache_dir: PathBuf,
map_server: Url,
#[serde(default = "default_port")]
port: u16,
}
fn default_port() -> u16 {
3030
}
#[derive(Debug, Error)]
enum ServerError {
#[error(transparent)]
Convert(#[from] vbsp_to_gltf::Error),
#[error(transparent)]
Toml(#[from] toml::de::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Loader(#[from] LoaderError),
#[error(transparent)]
Req(#[from] reqwest::Error),
#[error("invalid map name {0}")]
InvalidMapName(String),
#[error("failed to optimize output: {0}")]
GltfPack(String),
#[error(transparent)]
TmpFile(#[from] async_tempfile::Error),
}
impl ServerError {
fn status_code(&self) -> StatusCode {
match self {
ServerError::InvalidMapName(_) => StatusCode::UNPROCESSABLE_ENTITY,
// unexpected header means the dl wasn't a map
ServerError::Convert(Error::Bsp(BspError::UnexpectedHeader(_))) => {
StatusCode::NOT_FOUND
}
ServerError::Req(e)
if e.status()
.map(|status| status.is_client_error())
.unwrap_or_default() =>
{
StatusCode::NOT_FOUND
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for ServerError {
fn into_response(self) -> Response {
error!(error = ?self, "error during request");
(self.status_code(), self.to_string()).into_response()
}
}
/// View a demo file
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
struct Args {
/// Config url
config: PathBuf,
}
fn setup() -> Result<Config> {
miette::set_panic_hook();
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(
HierarchicalLayer::new(2)
.with_targets(true)
.with_bracketed_fields(true),
)
.init();
let args = Args::parse();
let toml = read_to_string(&args.config)?;
Ok(from_str(&toml)?)
}
#[tokio::main]
async fn main() -> Result<()> {
let config = setup()?;
let app = App {
cache_dir: config.cache_dir,
map_server: config.map_server,
client: Client::default(),
loader: Loader::new()?,
};
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST])
.allow_origin(Any);
let app = Router::new()
.route("/gltf/:map", get(convert))
.route("/", get(index))
.layer(cors)
.with_state(Arc::new(app));
// Run our app with hyper
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, config.port))
.await
.unwrap();
tracing::info!("listening on {}", listener.local_addr()?);
let serve = async {
if let Err(e) = axum::serve(listener, app).await {
eprintln!("{e:#?}");
}
};
let shutdown = shutdown_signal();
tokio::select! {
_ = serve => {},
_ = shutdown => {},
}
Ok(())
}
struct App {
cache_dir: PathBuf,
map_server: Url,
client: Client,
loader: Loader,
}
impl App {
fn cached(&self, map: &str) -> Result<Option<Vec<u8>>> {
let path = self.cache_dir.join(map);
if path.exists() {
Ok(Some(read(path)?))
} else {
Ok(None)
}
}
fn cache(&self, map: &str, data: &[u8]) -> Result<()> {
let path = self.cache_dir.join(map);
Ok(write(path, data)?)
}
async fn download(&self, map: &str) -> Result<Vec<u8>> {
info!(map = map, "downloading map");
Ok(self
.client
.get(
self.map_server
.join(map)
.map_err(|_| ServerError::InvalidMapName(map.into()))?,
)
.send()
.await?
.bytes()
.await?
.to_vec())
}
}
async fn index() -> impl IntoResponse {
Html(include_str!("./index.html"))
}
async fn convert(State(app): State<Arc<App>>, Path(map): Path<String>) -> impl IntoResponse {
if !map.is_ascii() || map.contains('/') || !map.ends_with(".glb") {
return Err(ServerError::InvalidMapName(map));
}
if let Some(cached) = app.cached(&map)? {
info!(map = map, "serving cached model");
return Ok(cached);
}
let bsp_name = format!("{}.bsp", map.strip_suffix(".glb").unwrap());
let bsp_data = app.download(&bsp_name).await?;
let mut loader = app.loader.clone();
let bsp = Bsp::read(&bsp_data).map_err(Error::from)?;
loader.add_source(bsp.pack.clone().into_zip());
let glb = export(bsp, &loader)?;
let glb = glb.to_vec().map_err(Error::from)?;
let packed = pack(&map, &glb).await?;
info!(
unoptimized = glb.len(),
optimized = packed.len(),
map = map,
"optimized model"
);
app.cache(&map, &packed)?;
Ok(packed)
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}