mirror of
https://codeberg.org/icewind/vbsp-to-gltf.git
synced 2026-06-03 10:14:08 +02:00
server
This commit is contained in:
parent
23f9215481
commit
ecb7d5720b
9 changed files with 1343 additions and 49 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -5,4 +5,6 @@ result
|
|||
.direnv
|
||||
*.snap.new
|
||||
*.bsp
|
||||
*.glb
|
||||
*.glb
|
||||
cache
|
||||
config.toml
|
||||
986
Cargo.lock
generated
986
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
28
Cargo.toml
28
Cargo.toml
|
|
@ -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
|
||||
26
flake.nix
26
flake.nix
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
9
src/lib.rs
Normal 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
24
src/server/index.html
Normal 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/<map name>.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
31
src/server/pack.rs
Normal 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
256
src/server/server.rs
Normal 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 => {},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue