mirror of
https://codeberg.org/icewind/vbsp-to-gltf.git
synced 2026-06-03 18:24:07 +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
|
.direnv
|
||||||
*.snap.new
|
*.snap.new
|
||||||
*.bsp
|
*.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"
|
edition = "2021"
|
||||||
rust-version = "1.70.0"
|
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]
|
[dependencies]
|
||||||
miette = { version = "5.5.0", features = ["fancy"] }
|
miette = { version = "5.5.0", features = ["fancy"] }
|
||||||
vbsp = "0.4.1"
|
vbsp = "0.4.1"
|
||||||
|
|
@ -14,7 +27,7 @@ tracing-tree = "0.3.0"
|
||||||
vtf = "0.1.6"
|
vtf = "0.1.6"
|
||||||
vmt-parser = "0.1.1"
|
vmt-parser = "0.1.1"
|
||||||
image = "0.23.14"
|
image = "0.23.14"
|
||||||
tf-asset-loader = { version = "0.1", features = ["zip"] }
|
tf-asset-loader = { version = "0.1.4", features = ["zip"] }
|
||||||
vmdl = "0.1"
|
vmdl = "0.1"
|
||||||
clap = { version = "4.0.29", features = ["derive"] }
|
clap = { version = "4.0.29", features = ["derive"] }
|
||||||
gltf-json = { version = "1.4.0", features = ["KHR_texture_transform"] }
|
gltf-json = { version = "1.4.0", features = ["KHR_texture_transform"] }
|
||||||
|
|
@ -22,5 +35,18 @@ gltf = "1.4.0"
|
||||||
cgmath = "0.18.0"
|
cgmath = "0.18.0"
|
||||||
bytemuck = { version = "1.14.0", features = ["derive"] }
|
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."*"]
|
[profile.dev.package."*"]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
26
flake.nix
26
flake.nix
|
|
@ -88,6 +88,14 @@
|
||||||
// {
|
// {
|
||||||
mode = "check";
|
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;
|
default = vbsp-to-gltf;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -101,25 +109,7 @@
|
||||||
cargo-outdated
|
cargo-outdated
|
||||||
cargo-audit
|
cargo-audit
|
||||||
cargo-msrv
|
cargo-msrv
|
||||||
cargo-semver-checks
|
|
||||||
cargo-insta
|
|
||||||
meshoptimizer
|
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 {
|
in {
|
||||||
default = mkShell {
|
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;
|
use clap::Parser;
|
||||||
pub use error::Error;
|
|
||||||
use miette::Context;
|
use miette::Context;
|
||||||
use std::fs::{read, File};
|
use std::fs::{read, File};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -17,6 +8,7 @@ use tracing_subscriber::util::SubscriberInitExt;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
use tracing_tree::HierarchicalLayer;
|
use tracing_tree::HierarchicalLayer;
|
||||||
use vbsp::Bsp;
|
use vbsp::Bsp;
|
||||||
|
use vbsp_to_gltf::{export, Error};
|
||||||
|
|
||||||
fn setup() {
|
fn setup() {
|
||||||
miette::set_panic_hook();
|
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