diff --git a/Cargo.lock b/Cargo.lock index da8fbe5..6fb7df5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -66,6 +72,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -84,6 +96,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.0" @@ -164,12 +188,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "countme" version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.8.2" @@ -296,12 +335,31 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -393,6 +451,12 @@ dependencies = [ "slab", ] +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + [[package]] name = "half" version = "2.7.1" @@ -525,6 +589,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -654,6 +731,16 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -788,6 +875,19 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1010,6 +1110,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1551,6 +1657,7 @@ name = "zerosyntax-server" version = "1.2.3" dependencies = [ "anyhow", + "base64", "clap", "dashmap 6.2.1", "percent-encoding", @@ -1565,6 +1672,7 @@ dependencies = [ "zerosyntax-analysis", "zerosyntax-schema", "zerosyntax-syntax", + "zerosyntax-w3d", ] [[package]] @@ -1576,6 +1684,14 @@ dependencies = [ "rowan", ] +[[package]] +name = "zerosyntax-w3d" +version = "1.2.3" +dependencies = [ + "glam", + "image", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 6f87bf5..d63e514 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/schema", "crates/syntax", "crates/analysis", "crates/server"] +members = ["crates/schema", "crates/syntax", "crates/analysis", "crates/w3d", "crates/server"] [workspace.package] version = "1.2.3" @@ -13,6 +13,7 @@ rust-version = "1.75" zerosyntax-schema = { path = "crates/schema" } zerosyntax-syntax = { path = "crates/syntax" } zerosyntax-analysis = { path = "crates/analysis" } +zerosyntax-w3d = { path = "crates/w3d" } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -32,6 +33,9 @@ tokio = { version = "1", features = ["full"] } ropey = "1" dashmap = "6" percent-encoding = "2" +base64 = "0.22" +glam = "0.27" +image = { version = "0.24.9", default-features = false, features = ["dds", "png", "tga"] } # server (workspace indexing) walkdir = "2" diff --git a/README.md b/README.md index d41bfee..28dd129 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ VS Code extension with the server bundled. - Optional indentation formatting, disabled by default so existing files are never reformatted without your consent. - Base-game and mod indexing for `map.ini` and `solo.ini`, including W3D model - and bone checks. + and bone checks. Selecting a `Model =` completion shows a textured W3D + thumbnail when the indexed asset is available, with configurable size and + zoom. ## Install the VS Code extension diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index fcb9136..ccdb8c5 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -26,6 +26,7 @@ pub enum CompletionKind { EnumMember, Value, Reference, + W3dModel, } /// A single completion candidate. @@ -418,7 +419,7 @@ fn model_asset_completions( .model_names() .map(|name| Completion { label: name.to_string(), - kind: CompletionKind::Reference, + kind: CompletionKind::W3dModel, detail: Some("W3D model".into()), insert: None, }) diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index 03178bc..b6e3a70 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -3006,6 +3006,7 @@ End vec![crate::index::FileAsset { kind: AssetKind::Audio, name: "Known.wav".into(), + uri: "file:///Known.wav".into(), }], ); let audio_only = codes(&index); @@ -3016,6 +3017,7 @@ End vec![crate::index::FileAsset { kind: AssetKind::Texture, name: "Known.dds".into(), + uri: "file:///Known.dds".into(), }], ); assert!(codes(&index).contains(&"unknown-texture")); diff --git a/crates/analysis/src/index.rs b/crates/analysis/src/index.rs index c97c5f8..580330d 100644 --- a/crates/analysis/src/index.rs +++ b/crates/analysis/src/index.rs @@ -25,6 +25,7 @@ pub enum AssetKind { pub struct FileAsset { pub kind: AssetKind, pub name: String, + pub uri: String, } /// Model data discovered from a W3D asset. @@ -124,7 +125,8 @@ pub struct WorkspaceIndex { model_assets: HashMap>, /// Reverse map for removing/replacing models contributed by one asset file. file_models: HashMap>, - asset_names: HashMap>>, + asset_names: HashMap>>, + texture_assets: HashMap>, file_assets: HashMap>, object_models: HashMap)>>, file_object_models: HashMap)>>, @@ -321,7 +323,13 @@ impl WorkspaceIndex { .or_default() .entry(asset.name.to_ascii_lowercase()) .or_default() - .push((file.to_string(), asset.name.clone())); + .push((file.to_string(), asset.clone())); + if asset.kind == AssetKind::Texture { + self.texture_assets + .entry(asset_stem(&asset.name)) + .or_default() + .push((file.to_string(), asset.clone())); + } } if assets.is_empty() { self.file_assets.remove(file); @@ -353,6 +361,15 @@ impl WorkspaceIndex { self.asset_names.remove(&asset.kind); } } + if asset.kind == AssetKind::Texture { + let stem = asset_stem(&asset.name); + if let Some(contribs) = self.texture_assets.get_mut(&stem) { + contribs.retain(|(source, _)| source != file); + if contribs.is_empty() { + self.texture_assets.remove(&stem); + } + } + } } } @@ -373,7 +390,7 @@ impl WorkspaceIndex { .get(&kind) .into_iter() .flat_map(|names| names.values().filter_map(|sources| sources.first())) - .map(|(_, display)| display.as_str()) + .map(|(_, asset)| asset.name.as_str()) } pub fn set_file_object_models(&mut self, file: &str, objects: Vec<(String, Vec)>) { @@ -672,10 +689,26 @@ impl WorkspaceIndex { pub fn model_names(&self) -> impl Iterator { self.model_assets .values() - .filter_map(|contribs| contribs.first()) + .filter_map(|contribs| contribs.last()) .map(|(_, m)| m.name.as_str()) } + /// Last indexed contributor wins: base roots are indexed first and + /// workspace roots last. + pub fn effective_model_source(&self, model: &str) -> Option<&str> { + self.model_assets + .get(&model.to_ascii_lowercase()) + .and_then(|contribs| contribs.last()) + .map(|(source, _)| source.as_str()) + } + + pub fn effective_texture_source(&self, texture: &str) -> Option<&FileAsset> { + self.texture_assets + .get(&asset_stem(texture)) + .and_then(|contribs| contribs.last()) + .map(|(_, asset)| asset) + } + /// User-addressable members (pivots/subobjects/meshes) for `model`, /// across every file that contributes the model. May repeat a member /// when several files define the same model; callers dedup or use `any`. @@ -773,6 +806,14 @@ fn normalized_model_assets(models: &[ModelAsset]) -> Vec<(String, Vec)> out } +fn asset_stem(name: &str) -> String { + let file = name.rsplit(['/', '\\']).next().unwrap_or(name); + file.rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(file) + .to_ascii_lowercase() +} + fn normalize_object_models(objects: &[(String, Vec)]) -> Vec<(String, Vec)> { let mut out = objects .iter() @@ -1200,6 +1241,7 @@ mod tests { let audio = |name: &str| FileAsset { kind: AssetKind::Audio, name: name.into(), + uri: format!("file:///{name}"), }; let mut idx = WorkspaceIndex::new(); idx.set_file_assets("base", vec![audio("Click.WAV")]); @@ -1224,6 +1266,44 @@ mod tests { assert!(idx.is_asset(AssetKind::Audio, "OTHER.WAV")); } + #[test] + fn effective_model_and_texture_use_last_contributor() { + let texture = |name: &str, uri: &str| FileAsset { + kind: AssetKind::Texture, + name: name.into(), + uri: uri.into(), + }; + let mut idx = WorkspaceIndex::new(); + idx.set_file_models( + "base.w3d", + vec![ModelAsset { + name: "Tank".into(), + members: Vec::new(), + }], + ); + idx.set_file_models( + "mod.w3d", + vec![ModelAsset { + name: "TANK".into(), + members: Vec::new(), + }], + ); + idx.set_file_assets( + "base.big", + vec![texture("Tank.dds", "big:///base.big!/Tank.dds")], + ); + idx.set_file_assets( + "workspace", + vec![texture("tank.tga", "file:///workspace/tank.tga")], + ); + assert_eq!(idx.effective_model_source("tank"), Some("mod.w3d")); + assert_eq!( + idx.effective_texture_source("TANK.DDS") + .map(|asset| asset.uri.as_str()), + Some("file:///workspace/tank.tga") + ); + } + #[test] fn split_prefixed_reference_site_span_excludes_prefix() { let a = Analyzer::embedded(); diff --git a/crates/analysis/tests/spec.rs b/crates/analysis/tests/spec.rs index 8714851..385e9af 100644 --- a/crates/analysis/tests/spec.rs +++ b/crates/analysis/tests/spec.rs @@ -407,10 +407,12 @@ fn specs_hold() { .map(|name| FileAsset { kind: AssetKind::Audio, name: name.clone(), + uri: format!("file:///{name}"), }) .chain(spec.texture_assets.iter().map(|name| FileAsset { kind: AssetKind::Texture, name: name.clone(), + uri: format!("file:///{name}"), })) .collect(), ); diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 24d9195..3c738af 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -12,11 +12,13 @@ path = "src/main.rs" zerosyntax-schema.workspace = true zerosyntax-syntax.workspace = true zerosyntax-analysis.workspace = true +zerosyntax-w3d.workspace = true tower-lsp.workspace = true tokio.workspace = true ropey.workspace = true dashmap.workspace = true percent-encoding.workspace = true +base64.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index 0cd7d15..44dacef 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -6,11 +6,13 @@ //! `didChange` deltas are applied to the rope and the document is re-parsed //! once per change batch; read-only requests reuse the cached parse. +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::time::Duration; +use base64::Engine; use dashmap::DashMap; use percent_encoding::percent_decode_str; use ropey::Rope; @@ -31,13 +33,59 @@ use zerosyntax_syntax::{Edit, Parse}; use crate::convert::{self, PositionEnc}; use crate::scan::{ - clear_index_cache, index_cache_path, load_sibling_str_keys, read_lossy, scan_with_cache, + clear_index_cache, index_cache_path, load_sibling_str_keys, read_asset_uri, read_lossy, + scan_with_cache, }; #[cfg(test)] use crate::scan::{parse_w3d_models, scan_big, scan_roots}; const CLEAR_INDEX_CACHE_COMMAND: &str = "zerosyntax.clearIndexCache"; const REBUILD_INDEX_CACHE_COMMAND: &str = "zerosyntax.rebuildIndexCache"; +const PREVIEW_CACHE_SIZE: usize = 64; + +#[derive(Default)] +struct PreviewCache { + items: HashMap>, + order: VecDeque, + generation: u64, +} + +impl PreviewCache { + fn get(&self, key: &str) -> Option> { + self.items.get(key).cloned() + } + + fn insert(&mut self, generation: u64, key: String, markdown: Arc) { + if self.generation != generation { + return; + } + if let Some(existing) = self.items.get_mut(&key) { + *existing = markdown; + return; + } + // ponytail: FIFO is enough for 64 completion thumbnails; use an LRU + // only if measured model-switching churn makes eviction visible. + if self.items.len() == PREVIEW_CACHE_SIZE { + if let Some(oldest) = self.order.pop_front() { + self.items.remove(&oldest); + } + } + self.order.push_back(key.clone()); + self.items.insert(key, markdown); + } + + fn clear(&mut self) { + self.items.clear(); + self.order.clear(); + self.generation = self.generation.wrapping_add(1); + } +} + +#[derive(Deserialize)] +struct CompletionResolveData { + zerosyntax: String, + model: String, +} /// An open document: its text (as both a rope for position math and a string /// for the parser) and the parse of that exact text. `did_open`/`did_change` @@ -75,6 +123,12 @@ impl SymbolAt { const DEFAULT_ANALYSIS_DEBOUNCE_MS: u64 = 250; const MAX_ANALYSIS_DEBOUNCE_MS: u64 = 5_000; +const DEFAULT_PREVIEW_IMAGE_WIDTH: u32 = 160; +const MIN_PREVIEW_IMAGE_WIDTH: u32 = 80; +const MAX_PREVIEW_IMAGE_WIDTH: u32 = 640; +const DEFAULT_PREVIEW_ZOOM_PERCENT: u32 = 100; +const MIN_PREVIEW_ZOOM_PERCENT: u32 = 25; +const MAX_PREVIEW_ZOOM_PERCENT: u32 = 400; const FORMATTING_REGISTRATION_ID: &str = "zerosyntax-formatting"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -86,6 +140,8 @@ struct RuntimeSettings { allow_bare_percentages: bool, map_ordering_diagnostics: bool, debounce_ms: u64, + preview_image_width: u32, + preview_zoom_percent: u32, } impl Default for RuntimeSettings { @@ -98,6 +154,8 @@ impl Default for RuntimeSettings { allow_bare_percentages: false, map_ordering_diagnostics: true, debounce_ms: DEFAULT_ANALYSIS_DEBOUNCE_MS, + preview_image_width: DEFAULT_PREVIEW_IMAGE_WIDTH, + preview_zoom_percent: DEFAULT_PREVIEW_ZOOM_PERCENT, } } } @@ -109,6 +167,7 @@ impl RuntimeSettings { }; let value = value.get("zerosyntax").unwrap_or(value); let analysis = value.get("analysis"); + let preview = value.get("preview"); let debounce_ms = normalized_debounce_ms(analysis.and_then(|analysis| analysis.get("debounceMs"))); Self { @@ -154,6 +213,18 @@ impl RuntimeSettings { .and_then(|value| value.as_bool()) .unwrap_or(true), debounce_ms, + preview_image_width: normalized_u32( + preview.and_then(|preview| preview.get("imageWidth")), + DEFAULT_PREVIEW_IMAGE_WIDTH, + MIN_PREVIEW_IMAGE_WIDTH, + MAX_PREVIEW_IMAGE_WIDTH, + ), + preview_zoom_percent: normalized_u32( + preview.and_then(|preview| preview.get("zoomPercent")), + DEFAULT_PREVIEW_ZOOM_PERCENT, + MIN_PREVIEW_ZOOM_PERCENT, + MAX_PREVIEW_ZOOM_PERCENT, + ), } } } @@ -168,6 +239,29 @@ fn normalized_debounce_ms(value: Option<&serde_json::Value>) -> u64 { .unwrap_or(DEFAULT_ANALYSIS_DEBOUNCE_MS) } +fn normalized_u32(value: Option<&serde_json::Value>, default: u32, min: u32, max: u32) -> u32 { + value + .and_then(|value| { + value + .as_i64() + .map(|value| value.clamp(i64::from(min), i64::from(max)) as u32) + .or_else(|| { + value + .as_u64() + .map(|value| value.clamp(min.into(), max.into()) as u32) + }) + }) + .unwrap_or(default) +} + +fn markdown_text(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") + .replace('`', "\\`") +} + pub struct Backend { client: Client, analyzer: Arc>>, @@ -210,6 +304,7 @@ pub struct Backend { analysis_debounce_ms: AtomicU64, /// Monotonic id source for semantic-token results (delta bookkeeping). semantic_result_id: AtomicU64, + preview_cache: Mutex, } fn load_schema(path: &str) -> std::result::Result { @@ -404,6 +499,7 @@ impl Backend { progress_support: OnceLock::new(), analysis_debounce_ms: AtomicU64::new(DEFAULT_ANALYSIS_DEBOUNCE_MS), semantic_result_id: AtomicU64::new(1), + preview_cache: Mutex::new(PreviewCache::default()), } } @@ -563,6 +659,14 @@ impl Backend { previous.model_member_strictness != settings.model_member_strictness; let map_ordering_changed = previous.map_ordering_diagnostics != settings.map_ordering_diagnostics; + let preview_changed = previous.preview_image_width != settings.preview_image_width + || previous.preview_zoom_percent != settings.preview_zoom_percent; + + if preview_changed { + if let Ok(mut cache) = self.preview_cache.lock() { + cache.clear(); + } + } if schema_changed || roots_changed { let (mut analyzer, warning) = if schema_changed { @@ -769,6 +873,9 @@ impl Backend { if let Ok(mut index) = self.index.write() { *index = replacement; } + if let Ok(mut cache) = self.preview_cache.lock() { + cache.clear(); + } self.clear_diagnostic_caches(); self.end_scan_progress( progress_token, @@ -974,6 +1081,7 @@ impl LanguageServer for Backend { // Editor-facing settings arrive as `initializationOptions`. Shape: // `{ "format": {"enable": bool}, "schemaPath": "schema.json", + // "preview": {"imageWidth": 160, "zoomPercent": 100}, // "analysis": {"modelMemberStrictness": "compatible", // "allowPercentagesWithoutSign": false, // "mapOrderingDiagnostics": true, "debounceMs": 250}, @@ -1056,6 +1164,7 @@ impl LanguageServer for Backend { )), completion_provider: Some(CompletionOptions { trigger_characters: Some(vec!["=".into(), " ".into()]), + resolve_provider: Some(true), ..Default::default() }), semantic_tokens_provider: Some( @@ -1331,6 +1440,115 @@ impl LanguageServer for Backend { Ok(Some(CompletionResponse::Array(items))) } + async fn completion_resolve(&self, mut item: CompletionItem) -> Result { + let Some(data) = item + .data + .clone() + .and_then(|value| serde_json::from_value::(value).ok()) + .filter(|data| { + data.zerosyntax == "w3d-model-preview" + && !data.model.is_empty() + && data.model.len() <= 256 + }) + else { + return Ok(item); + }; + let source = self + .index + .read() + .ok() + .and_then(|index| index.effective_model_source(&data.model).map(str::to_owned)); + let Some(source) = source else { + return Ok(item); + }; + let (image_width, zoom_percent) = self + .settings + .lock() + .map(|settings| (settings.preview_image_width, settings.preview_zoom_percent)) + .unwrap_or((DEFAULT_PREVIEW_IMAGE_WIDTH, DEFAULT_PREVIEW_ZOOM_PERCENT)); + let cache_key = format!( + "{}\0{}\0{image_width}\0{zoom_percent}", + data.model.to_ascii_lowercase(), + source + ); + let (cached, cache_generation) = self + .preview_cache + .lock() + .map(|cache| (cache.get(&cache_key), cache.generation)) + .unwrap_or((None, 0)); + if let Some(markdown) = cached { + item.documentation = Some(Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: markdown.to_string(), + })); + return Ok(item); + } + + let index = self.index.clone(); + let model = data.model.clone(); + let zoom = zoom_percent as f32 / 100.0; + let rendered = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + let bytes = read_asset_uri(&source)?; + let file = zerosyntax_w3d::W3dFile::parse(&bytes)?; + file.render_thumbnail(&model, zoom, |texture| { + let uri = index.read().ok().and_then(|index| { + index + .effective_texture_source(texture) + .map(|asset| asset.uri.clone()) + })?; + read_asset_uri(&uri).ok() + }) + .map_err(anyhow::Error::from) + }) + .await; + + let markdown = match rendered { + Ok(Ok(rendered)) => { + let encoded = + base64::engine::general_purpose::STANDARD.encode(rendered.png.as_slice()); + let label = markdown_text(&data.model); + let mut markdown = format!( + "![{label} model preview](data:image/png;base64,{encoded}|width={image_width})\n\n`{label}`" + ); + if !rendered.missing_textures.is_empty() { + let shown = rendered + .missing_textures + .iter() + .take(3) + .map(|name| format!("`{}`", markdown_text(name))) + .collect::>() + .join(", "); + let remaining = rendered.missing_textures.len().saturating_sub(3); + markdown.push_str("\n\nMissing texture"); + if rendered.missing_textures.len() != 1 { + markdown.push('s'); + } + markdown.push_str(&format!(": {shown}")); + if remaining > 0 { + markdown.push_str(&format!(" and {remaining} more")); + } + } + Arc::::from(markdown) + } + Ok(Err(error)) => { + tracing::warn!(%error, model = %data.model, "could not render W3D completion preview"); + Arc::::from("_Preview unavailable: unsupported or malformed W3D data._") + } + Err(error) => { + tracing::warn!(%error, model = %data.model, "W3D preview task failed"); + Arc::::from("_Preview unavailable: unsupported or malformed W3D data._") + } + }; + if let Ok(mut cache) = self.preview_cache.lock() { + cache.insert(cache_generation, cache_key, markdown.clone()); + } + item.documentation = Some(Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: markdown.to_string(), + })); + Ok(item) + } + async fn semantic_tokens_full( &self, params: SemanticTokensParams, @@ -1994,12 +2212,26 @@ mod tests { assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(12.5))), 250); } + #[test] + fn preview_settings_default_and_clamp() { + let defaults = RuntimeSettings::default(); + assert_eq!(defaults.preview_image_width, 160); + assert_eq!(defaults.preview_zoom_percent, 100); + + let settings = RuntimeSettings::from_value(Some(&serde_json::json!({ + "preview": {"imageWidth": 10_000, "zoomPercent": 0} + }))); + assert_eq!(settings.preview_image_width, 640); + assert_eq!(settings.preview_zoom_percent, 25); + } + #[test] fn runtime_settings_accept_startup_and_vscode_shapes() { let startup = RuntimeSettings::from_value(Some(&serde_json::json!({ "format": {"enable": true}, "schemaPath": "schema.json", "baseIniRoots": ["base"], + "preview": {"imageWidth": 320, "zoomPercent": 150}, "analysis": {"modelMemberStrictness": "strict", "debounceMs": 9000} }))); let notification = RuntimeSettings::from_value(Some(&serde_json::json!({ @@ -2007,11 +2239,14 @@ mod tests { "format": {"enable": true}, "schema": {"path": "schema.json"}, "baseIniRoots": ["base"], + "preview": {"imageWidth": 320, "zoomPercent": 150}, "analysis": {"modelMemberStrictness": "strict", "debounceMs": 9000} } }))); assert_eq!(startup, notification); assert_eq!(startup.debounce_ms, 5000); + assert_eq!(startup.preview_image_width, 320); + assert_eq!(startup.preview_zoom_percent, 150); } #[test] @@ -2130,7 +2365,6 @@ mod tests { let analyzer = Analyzer::embedded(); let scanned = scan_big(&analyzer, &path).unwrap(); - let _ = std::fs::remove_file(&path); assert_eq!(scanned.len(), 3, "INI, W3D, and one aggregated asset entry"); let ini = scanned.iter().find(|entry| !entry.1.is_empty()).unwrap(); @@ -2144,6 +2378,12 @@ mod tests { assert_eq!(assets.len(), 2); assert!(assets.iter().any(|asset| asset.name == "Click.WAV")); assert!(assets.iter().any(|asset| asset.name == "Particle.DDS")); + let texture = assets + .iter() + .find(|asset| asset.name == "Particle.DDS") + .unwrap(); + assert_eq!(read_asset_uri(&texture.uri).unwrap(), b"not read"); + let _ = std::fs::remove_file(&path); } #[test] @@ -2161,6 +2401,7 @@ mod tests { assert_eq!(assets.len(), 2); assert!(assets.iter().any(|asset| asset.name == "Click.wav")); assert!(assets.iter().any(|asset| asset.name == "Particle.tga")); + assert!(assets.iter().all(|asset| asset.uri.starts_with("file:"))); } #[test] @@ -2267,6 +2508,27 @@ End assert!(good.members.iter().any(|m| m == "Cargo01"), "{good:?}"); } + #[test] + fn malformed_w3d_keeps_filename_fallback_model() { + let mut truncated = 0u32.to_le_bytes().to_vec(); + truncated.extend_from_slice(&16u32.to_le_bytes()); + + let models = parse_w3d_models(&truncated, "Fallback"); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "Fallback"); + assert!(models[0].members.is_empty()); + } + + #[test] + fn invalidated_preview_is_not_cached_again() { + let mut cache = PreviewCache::default(); + let generation = cache.generation; + cache.clear(); + cache.insert(generation, "model".into(), Arc::from("stale")); + + assert!(cache.get("model").is_none()); + } + #[test] fn parses_w3d_aggregate_and_emitter_names() { fn fixed(name: &str) -> [u8; N] { diff --git a/crates/server/src/convert.rs b/crates/server/src/convert.rs index 338dd41..b3cd75d 100644 --- a/crates/server/src/convert.rs +++ b/crates/server/src/convert.rs @@ -171,6 +171,12 @@ pub fn to_lsp_completion(c: Completion, snippets_supported: bool) -> CompletionI (None, None, None) }; + let data = (c.kind == CompletionKind::W3dModel).then(|| { + serde_json::json!({ + "zerosyntax": "w3d-model-preview", + "model": c.label, + }) + }); CompletionItem { label: c.label, kind: Some(match c.kind { @@ -180,8 +186,10 @@ pub fn to_lsp_completion(c: Completion, snippets_supported: bool) -> CompletionI CompletionKind::EnumMember => CompletionItemKind::ENUM_MEMBER, CompletionKind::Value => CompletionItemKind::VALUE, CompletionKind::Reference => CompletionItemKind::REFERENCE, + CompletionKind::W3dModel => CompletionItemKind::REFERENCE, }), detail: c.detail, + data, insert_text, insert_text_format, command, @@ -324,6 +332,7 @@ pub fn semantic_tokens_splice( #[cfg(test)] mod tests { use super::*; + use zerosyntax_analysis::completion::{Completion, CompletionKind}; use zerosyntax_analysis::semantic::{SemKind, SemToken}; /// Apply a splice the way a client would, for the equivalence test below. @@ -338,6 +347,24 @@ mod tests { out } + #[test] + fn model_completion_is_tagged_but_not_eagerly_rendered() { + let item = to_lsp_completion( + Completion { + label: "AVTank".into(), + kind: CompletionKind::W3dModel, + detail: Some("W3D model".into()), + insert: None, + }, + false, + ); + assert_eq!( + item.data.as_ref().and_then(|data| data.get("model")), + Some(&serde_json::json!("AVTank")) + ); + assert!(item.documentation.is_none()); + } + #[test] fn splice_reproduces_next_from_prev() { let tok = |dl, ds, len, ty| SemanticToken { diff --git a/crates/server/src/scan.rs b/crates/server/src/scan.rs index 3429ece..5d8cee9 100644 --- a/crates/server/src/scan.rs +++ b/crates/server/src/scan.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::UNIX_EPOCH; use anyhow::{Context, Result}; +use percent_encoding::percent_decode_str; use serde::{Deserialize, Serialize}; use tower_lsp::lsp_types::Url; use zerosyntax_analysis::index::{ @@ -15,6 +16,7 @@ use zerosyntax_analysis::index::{ Definition, FileAsset, ModelAsset, ModuleTagDefinition, ReferenceSite, }; use zerosyntax_analysis::Analyzer; +use zerosyntax_w3d::W3dFile; pub(crate) type ScanEntry = ( String, @@ -28,7 +30,8 @@ pub(crate) type ScanEntry = ( Option>, ); -const INDEX_CACHE_VERSION: u32 = 4; +const INDEX_CACHE_VERSION: u32 = 5; +const MAX_PREVIEW_ASSET_BYTES: u64 = 128 * 1024 * 1024; #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] struct Fingerprint { @@ -274,7 +277,7 @@ fn file_stem_str(path: &str) -> String { .to_string() } -fn raw_asset(path: &str) -> Option { +fn raw_asset(path: &str, uri: &str) -> Option { let name = path.rsplit(['/', '\\']).next()?; let (_, extension) = name.rsplit_once('.')?; let kind = if extension.eq_ignore_ascii_case("wav") || extension.eq_ignore_ascii_case("mp3") { @@ -287,123 +290,28 @@ fn raw_asset(path: &str) -> Option { Some(FileAsset { kind, name: name.to_string(), + uri: uri.to_string(), }) } pub(crate) fn parse_w3d_models(bytes: &[u8], fallback_name: &str) -> Vec { - let mut names = Vec::new(); - let mut members = Vec::new(); - if !fallback_name.is_empty() { - names.push(fallback_name.to_string()); - } - walk_w3d_chunks(bytes, 0, bytes.len(), 0, &mut |kind, payload| match kind { - 0x0000_001F if payload.len() >= 40 => { - push_name(&mut members, read_fixed_name(&payload[8..24])); - push_name(&mut names, read_fixed_name(&payload[24..40])); - } - 0x0000_0101 | 0x0000_0501 | 0x0000_0601 if payload.len() >= 20 => { - push_name(&mut names, read_fixed_name(&payload[4..20])); - } - 0x0000_0102 => { - for pivot in payload.chunks_exact(60) { - push_name(&mut members, read_fixed_name(&pivot[..16])); - } - } - 0x0000_0701 if payload.len() >= 40 => { - push_name(&mut names, read_fixed_name(&payload[8..24])); - push_name(&mut names, read_fixed_name(&payload[24..40])); - } - 0x0000_0704 if payload.len() >= 36 => { - push_name(&mut members, read_fixed_name(&payload[4..36])); - } - 0x0000_0740 if payload.len() >= 40 => { - push_name(&mut members, read_fixed_name(&payload[8..40])); - } - 0x0000_0750 if payload.len() >= 48 => { - push_name(&mut members, read_fixed_name(&payload[16..48])); - } - _ => {} - }); - dedup_case_insensitive(&mut names); - dedup_case_insensitive(&mut members); - names - .into_iter() - .filter(|name| !name.is_empty()) - .map(|name| ModelAsset { - name, - members: members.clone(), - }) - .collect() -} - -const MAX_W3D_CHUNK_DEPTH: usize = 16; - -fn walk_w3d_chunks( - bytes: &[u8], - mut pos: usize, - end: usize, - depth: usize, - f: &mut impl FnMut(u32, &[u8]), -) { - if depth > MAX_W3D_CHUNK_DEPTH { - return; - } - while pos + 8 <= end && pos + 8 <= bytes.len() { - let kind = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap()); - let size_raw = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()); - let has_children = (size_raw & 0x8000_0000) != 0 || is_w3d_container(kind); - let size = (size_raw & 0x7fff_ffff) as usize; - let payload_start = pos + 8; - let Some(payload_end) = payload_start.checked_add(size) else { - break; - }; - if payload_end > end || payload_end > bytes.len() { - break; - } - let payload = &bytes[payload_start..payload_end]; - f(kind, payload); - if has_children { - walk_w3d_chunks(bytes, payload_start, payload_end, depth + 1, f); - } - pos = payload_end; - } -} - -fn is_w3d_container(kind: u32) -> bool { - matches!( - kind, - 0x0000_0000 - | 0x0000_0100 - | 0x0000_0500 - | 0x0000_0600 - | 0x0000_0700 - | 0x0000_0702 - | 0x0000_0705 - ) -} - -fn read_fixed_name(bytes: &[u8]) -> &str { - let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len()); - std::str::from_utf8(&bytes[..end]).unwrap_or("").trim() -} - -fn push_name(out: &mut Vec, name: &str) { - if name.is_empty() { - return; - } - out.push(name.to_string()); - if let Some((_, short)) = name.rsplit_once('.') { - if !short.is_empty() { - out.push(short.to_string()); - } + match W3dFile::parse(bytes) { + Ok(file) => file + .catalog(fallback_name) + .into_iter() + .map(|model| ModelAsset { + name: model.name, + members: model.members, + }) + .collect(), + Err(_) if !fallback_name.trim().is_empty() => vec![ModelAsset { + name: fallback_name.trim().to_string(), + members: Vec::new(), + }], + Err(_) => Vec::new(), } } -fn dedup_case_insensitive(values: &mut Vec) { - let mut seen = std::collections::HashSet::new(); - values.retain(|value| seen.insert(value.to_ascii_lowercase())); -} - pub(crate) fn scan_big(analyzer: &Analyzer, path: &Path) -> Result> { let mut out = Vec::new(); let mut assets = Vec::new(); @@ -448,10 +356,28 @@ pub(crate) fn scan_big(analyzer: &Analyzer, path: &Path) -> Result Result Result> { let mut out = Vec::new(); for root in roots { + let root_start = out.len(); if root.is_file() && root .extension() @@ -512,6 +439,30 @@ fn collect_paths(roots: &[PathBuf], checked: bool) -> Result> { out.push(path.to_path_buf()); } } + out[root_start..].sort_by(|left, right| { + let left_stem = left + .with_extension("") + .to_string_lossy() + .to_ascii_lowercase(); + let right_stem = right + .with_extension("") + .to_string_lossy() + .to_ascii_lowercase(); + left_stem.cmp(&right_stem).then_with(|| { + let rank = |path: &Path| { + path.extension() + .and_then(|value| value.to_str()) + .map_or(0, |extension| { + if extension.eq_ignore_ascii_case("dds") { + 1 + } else { + 0 + } + }) + }; + rank(left).cmp(&rank(right)) + }) + }); } Ok(out) } @@ -658,7 +609,7 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { )) .into_iter() .collect()) - } else if let Some(asset) = raw_asset(&path.to_string_lossy()) { + } else if let Some(asset) = raw_asset(&path.to_string_lossy(), uri.as_str()) { Ok(vec![( uri.to_string(), Vec::new(), @@ -675,6 +626,43 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { } } +pub(crate) fn read_asset_uri(uri: &str) -> Result> { + let url = Url::parse(uri).with_context(|| format!("invalid asset URI `{uri}`"))?; + if url.scheme() == "file" { + let path = url + .to_file_path() + .map_err(|_| anyhow::anyhow!("invalid file asset URI `{uri}`"))?; + let len = std::fs::metadata(&path)?.len(); + if len > MAX_PREVIEW_ASSET_BYTES { + anyhow::bail!("asset exceeds 128 MiB"); + } + return std::fs::read(&path) + .with_context(|| format!("failed to read asset {}", path.display())); + } + if url.scheme() != "big" { + anyhow::bail!("unsupported asset URI scheme `{}`", url.scheme()); + } + let decoded = percent_decode_str(url.path()).decode_utf8_lossy(); + let (archive, entry_name) = decoded + .split_once("!/") + .ok_or_else(|| anyhow::anyhow!("invalid BIG asset URI `{uri}`"))?; + let archive = + if cfg!(windows) && archive.as_bytes().get(2) == Some(&b':') && archive.starts_with('/') { + &archive[1..] + } else { + archive + }; + let path = Path::new(archive); + let entry = big_entries(path)? + .into_iter() + .find(|entry| entry.name.eq_ignore_ascii_case(entry_name)) + .ok_or_else(|| anyhow::anyhow!("asset `{entry_name}` not found in {}", path.display()))?; + if entry.size as u64 > MAX_PREVIEW_ASSET_BYTES { + anyhow::bail!("asset exceeds 128 MiB"); + } + read_big_entry_bytes(path, &entry) +} + #[cfg(test)] pub(crate) fn scan_roots(analyzer: &Analyzer, roots: &[PathBuf]) -> Vec { scan_files(analyzer, &collect_scan_paths(roots), &mut |_, _| {}) diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index 5ab4e9f..7f926c4 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -9,6 +9,7 @@ failure. """ import json +import base64 import subprocess import sys import threading @@ -60,6 +61,29 @@ def main() -> int: workspace = pathlib.Path(tempfile.mkdtemp(prefix="zerosyntax-e2e-")) (workspace / "Images.INI").write_text("MappedImage TestScanImage\nEnd\n") + + def w3d_chunk(kind, payload): + return struct.pack(", +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct W3dError(String); + +impl W3dError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for W3dError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for W3dError {} + +#[derive(Debug, Default)] +pub struct W3dFile { + pub(crate) meshes: Vec, + pub(crate) hierarchies: Vec, + pub(crate) hlods: Vec, + extra_names: Vec, + extra_members: Vec, +} + +impl W3dFile { + pub fn parse(bytes: &[u8]) -> Result { + parse::parse(bytes) + } + + pub fn catalog(&self, fallback_name: &str) -> Vec { + let mut names = Vec::new(); + let mut members = self.extra_members.clone(); + push_name(&mut names, fallback_name); + names.extend(self.extra_names.iter().cloned()); + for mesh in &self.meshes { + push_name(&mut names, &mesh.name); + push_name(&mut members, &mesh.name); + } + for hierarchy in &self.hierarchies { + push_name(&mut names, &hierarchy.name); + for pivot in &hierarchy.pivots { + push_name(&mut members, &pivot.name); + } + } + for hlod in &self.hlods { + push_name(&mut names, &hlod.name); + for subobject in hlod + .lods + .iter() + .flat_map(|lod| &lod.subobjects) + .chain(&hlod.aggregates) + { + push_name(&mut members, &subobject.name); + } + } + dedup(&mut names); + dedup(&mut members); + names + .into_iter() + .filter(|name| !name.is_empty()) + .map(|name| ModelCatalogEntry { + name, + members: members.clone(), + }) + .collect() + } + + pub fn render_thumbnail( + &self, + model: &str, + zoom: f32, + load_texture: impl FnMut(&str) -> Option>, + ) -> Result { + render::thumbnail(self, model, zoom, load_texture) + } +} + +fn push_name(out: &mut Vec, name: &str) { + let name = name.trim(); + if name.is_empty() { + return; + } + out.push(name.to_string()); + if let Some((_, short)) = name.rsplit_once('.') { + if !short.is_empty() { + out.push(short.to_string()); + } + } +} + +fn dedup(values: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + values.retain(|value| seen.insert(value.to_ascii_lowercase())); +} + +#[cfg(test)] +mod tests { + use image::GenericImageView; + + use super::*; + + fn chunk(kind: u32, payload: Vec) -> Vec { + let mut out = kind.to_le_bytes().to_vec(); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend(payload); + out + } + + fn floats(values: &[f32]) -> Vec { + values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() + } + + #[test] + fn parses_and_renders_a_textured_w3d() { + let mut header = vec![0; 116]; + header[8..16].copy_from_slice(b"Triangle"); + header[24..31].copy_from_slice(b"Preview"); + let mut triangle = Vec::new(); + triangle.extend([0u32, 1, 2].into_iter().flat_map(u32::to_le_bytes)); + triangle.extend_from_slice(&0u32.to_le_bytes()); + triangle.extend(floats(&[0.0, -1.0, 0.0, 0.0])); + let texture = chunk(0x30, chunk(0x31, chunk(0x32, b"Preview.tga\0".to_vec()))); + let mut material_info = vec![0; 32]; + material_info[8..12].copy_from_slice(&[255, 255, 255, 0]); + material_info[24..28].copy_from_slice(&1.0f32.to_le_bytes()); + let vertex_material = chunk(0x2a, chunk(0x2b, chunk(0x2d, material_info))); + let material = chunk(0x38, chunk(0x48, chunk(0x49, 0u32.to_le_bytes().to_vec()))); + let mesh = chunk( + 0, + [ + chunk(0x1f, header), + chunk( + 0x02, + floats(&[-1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]), + ), + chunk( + 0x03, + floats(&[0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0]), + ), + chunk(0x0d, floats(&[0.0, 0.0, 1.0, 0.0, 0.5, 1.0])), + chunk(0x20, triangle), + vertex_material, + texture, + material, + ] + .concat(), + ); + let mut tga = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 32, 0x20]; + tga.extend_from_slice(&[ + 0, 0, 255, 255, 0, 255, 0, 255, 255, 0, 0, 255, 255, 255, 255, 255, + ]); + + let file = W3dFile::parse(&mesh).unwrap(); + assert_eq!(file.meshes[0].material_diffuse, [255, 255, 255, 255]); + let rendered = file + .render_thumbnail("Preview", 1.0, |name| { + name.eq_ignore_ascii_case("Preview.tga") + .then(|| tga.clone()) + }) + .unwrap(); + assert!(rendered.missing_textures.is_empty()); + let image = image::load_from_memory(&rendered.png).unwrap(); + assert_eq!(image.dimensions(), (render::WIDTH, render::HEIGHT)); + assert!( + image + .to_rgb8() + .pixels() + .any(|pixel| pixel[0] != pixel[1] || pixel[1] != pixel[2]), + "expected textured geometry over the grayscale checkerboard" + ); + } +} diff --git a/crates/w3d/src/parse.rs b/crates/w3d/src/parse.rs new file mode 100644 index 0000000..9675b24 --- /dev/null +++ b/crates/w3d/src/parse.rs @@ -0,0 +1,514 @@ +use glam::{Quat, Vec2, Vec3}; + +use crate::{ + push_name, W3dError, W3dFile, MAX_CHUNKS_PER_CONTAINER, MAX_CHUNK_DEPTH, MAX_FILE_BYTES, + MAX_TRIANGLES, MAX_VERTICES, +}; + +const MESH: u32 = 0x0000; +const VERTICES: u32 = 0x0002; +const NORMALS: u32 = 0x0003; +const TEXCOORDS: u32 = 0x000d; +const VERTEX_INFLUENCES: u32 = 0x000e; +const MESH_HEADER3: u32 = 0x001f; +const TRIANGLES: u32 = 0x0020; +const MATERIAL_INFO: u32 = 0x0028; +const VERTEX_MATERIALS: u32 = 0x002a; +const VERTEX_MATERIAL: u32 = 0x002b; +const VERTEX_MATERIAL_INFO: u32 = 0x002d; +const TEXTURES: u32 = 0x0030; +const TEXTURE: u32 = 0x0031; +const TEXTURE_NAME: u32 = 0x0032; +const MATERIAL_PASS: u32 = 0x0038; +const DCG: u32 = 0x003b; +const TEXTURE_STAGE: u32 = 0x0048; +const TEXTURE_IDS: u32 = 0x0049; +const STAGE_TEXCOORDS: u32 = 0x004a; +const PER_FACE_TEXCOORD_IDS: u32 = 0x004b; +const HIERARCHY: u32 = 0x0100; +const HIERARCHY_HEADER: u32 = 0x0101; +const PIVOTS: u32 = 0x0102; +const VERTEX_COLORS: u32 = 0x0115; +const HLOD: u32 = 0x0700; +const HLOD_HEADER: u32 = 0x0701; +const HLOD_LOD_ARRAY: u32 = 0x0702; +const HLOD_ARRAY_HEADER: u32 = 0x0703; +const HLOD_SUB_OBJECT: u32 = 0x0704; +const HLOD_AGGREGATES: u32 = 0x0705; + +#[derive(Debug, Default)] +pub(crate) struct Mesh { + pub name: String, + pub container: String, + pub attributes: u32, + pub vertices: Vec, + pub normals: Vec, + pub uvs: Vec, + pub triangles: Vec, + pub influences: Vec, + pub colors: Vec<[u8; 4]>, + pub material_diffuse: [u8; 4], + pub textures: Vec, + pub pass: MaterialPass, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Triangle { + pub indices: [u32; 3], +} + +#[derive(Debug, Default)] +pub(crate) struct MaterialPass { + pub colors: Vec<[u8; 4]>, + pub texture_ids: Vec, + pub uvs: Vec, + pub per_face_uv_ids: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct Hierarchy { + pub name: String, + pub pivots: Vec, +} + +#[derive(Debug)] +pub(crate) struct Pivot { + pub name: String, + pub parent: Option, + pub translation: Vec3, + pub rotation: Quat, +} + +#[derive(Debug, Default)] +pub(crate) struct Hlod { + pub name: String, + pub hierarchy: String, + pub lods: Vec, + pub aggregates: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct Lod { + pub max_screen_size: f32, + pub subobjects: Vec, +} + +#[derive(Debug)] +pub(crate) struct SubObject { + pub bone: usize, + pub name: String, +} + +struct Chunk<'a> { + kind: u32, + data: &'a [u8], +} + +pub(crate) fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_FILE_BYTES { + return Err(W3dError::new("W3D file exceeds 128 MiB")); + } + let mut file = W3dFile::default(); + for chunk in chunks(bytes, 0)? { + match chunk.kind { + MESH => file.meshes.push(parse_mesh(chunk.data)?), + HIERARCHY => file.hierarchies.push(parse_hierarchy(chunk.data)?), + HLOD => file.hlods.push(parse_hlod(chunk.data)?), + _ => {} + } + collect_catalog_names( + chunk.kind, + chunk.data, + &mut file.extra_names, + &mut file.extra_members, + 0, + )?; + } + let vertices: usize = file.meshes.iter().map(|mesh| mesh.vertices.len()).sum(); + let triangles: usize = file.meshes.iter().map(|mesh| mesh.triangles.len()).sum(); + if vertices > MAX_VERTICES || triangles > MAX_TRIANGLES { + return Err(W3dError::new("W3D geometry budget exceeded")); + } + Ok(file) +} + +fn parse_mesh(bytes: &[u8]) -> Result { + let mut mesh = Mesh { + material_diffuse: [204, 204, 204, 255], + ..Mesh::default() + }; + for chunk in chunks(bytes, 1)? { + match chunk.kind { + MESH_HEADER3 if chunk.data.len() >= 40 => { + mesh.attributes = u32_at(chunk.data, 4)?; + mesh.name = fixed_name(&chunk.data[8..24]); + mesh.container = fixed_name(&chunk.data[24..40]); + } + VERTICES => mesh.vertices = vec3s(chunk.data)?, + NORMALS => mesh.normals = vec3s(chunk.data)?, + TEXCOORDS => mesh.uvs = vec2s(chunk.data, true)?, + TRIANGLES => { + if chunk.data.len() % 32 != 0 { + return Err(W3dError::new("invalid triangle chunk size")); + } + mesh.triangles = chunk + .data + .chunks_exact(32) + .map(|bytes| { + Ok(Triangle { + indices: [u32_at(bytes, 0)?, u32_at(bytes, 4)?, u32_at(bytes, 8)?], + }) + }) + .collect::>()?; + } + VERTEX_INFLUENCES => { + if chunk.data.len() % 8 != 0 { + return Err(W3dError::new("invalid vertex influence chunk size")); + } + mesh.influences = chunk + .data + .chunks_exact(8) + .map(|value| u16::from_le_bytes([value[0], value[1]])) + .collect(); + } + VERTEX_COLORS => mesh.colors = rgba(chunk.data)?, + VERTEX_MATERIALS => parse_vertex_materials(chunk.data, &mut mesh)?, + TEXTURES => mesh.textures = parse_textures(chunk.data)?, + MATERIAL_PASS if mesh.pass.texture_ids.is_empty() => { + mesh.pass = parse_material_pass(chunk.data)?; + } + MATERIAL_INFO => {} + _ => {} + } + } + Ok(mesh) +} + +fn parse_vertex_materials(bytes: &[u8], mesh: &mut Mesh) -> Result<(), W3dError> { + for material in chunks(bytes, 2)? { + if material.kind != VERTEX_MATERIAL { + continue; + } + for chunk in chunks(material.data, 3)? { + if chunk.kind == VERTEX_MATERIAL_INFO && chunk.data.len() >= 11 { + mesh.material_diffuse[..3].copy_from_slice(&chunk.data[8..11]); + if chunk.data.len() >= 28 { + let opacity = f32_at(chunk.data, 24)?.clamp(0.0, 1.0); + mesh.material_diffuse[3] = (opacity * 255.0).round() as u8; + } + return Ok(()); + } + } + } + Ok(()) +} + +fn parse_textures(bytes: &[u8]) -> Result, W3dError> { + let mut textures = Vec::new(); + for texture in chunks(bytes, 2)? { + if texture.kind != TEXTURE { + continue; + } + let mut name = String::new(); + for chunk in chunks(texture.data, 3)? { + if chunk.kind == TEXTURE_NAME { + name = fixed_name(chunk.data); + } + } + textures.push(name); + } + Ok(textures) +} + +fn parse_material_pass(bytes: &[u8]) -> Result { + let mut pass = MaterialPass::default(); + for chunk in chunks(bytes, 2)? { + match chunk.kind { + DCG => pass.colors = rgba(chunk.data)?, + TEXTURE_STAGE if pass.texture_ids.is_empty() => { + for stage in chunks(chunk.data, 3)? { + match stage.kind { + TEXTURE_IDS => pass.texture_ids = u32s(stage.data)?, + STAGE_TEXCOORDS => pass.uvs = vec2s(stage.data, true)?, + PER_FACE_TEXCOORD_IDS => pass.per_face_uv_ids = u32s(stage.data)?, + _ => {} + } + } + } + _ => {} + } + } + Ok(pass) +} + +fn parse_hierarchy(bytes: &[u8]) -> Result { + let mut hierarchy = Hierarchy::default(); + for chunk in chunks(bytes, 1)? { + match chunk.kind { + HIERARCHY_HEADER if chunk.data.len() >= 20 => { + hierarchy.name = fixed_name(&chunk.data[4..20]); + } + PIVOTS => { + if chunk.data.len() % 60 != 0 { + return Err(W3dError::new("invalid pivot chunk size")); + } + for pivot in chunk.data.chunks_exact(60) { + let parent = u32_at(pivot, 16)?; + let rotation = Quat::from_xyzw( + f32_at(pivot, 44)?, + f32_at(pivot, 48)?, + f32_at(pivot, 52)?, + f32_at(pivot, 56)?, + ); + hierarchy.pivots.push(Pivot { + name: fixed_name(&pivot[..16]), + parent: (parent != u32::MAX).then_some(parent as usize), + translation: vec3_at(pivot, 20)?, + rotation: if rotation.is_finite() && rotation.length_squared() > 0.0 { + rotation.normalize() + } else { + Quat::IDENTITY + }, + }); + } + } + _ => {} + } + } + Ok(hierarchy) +} + +fn parse_hlod(bytes: &[u8]) -> Result { + let mut hlod = Hlod::default(); + for chunk in chunks(bytes, 1)? { + match chunk.kind { + HLOD_HEADER if chunk.data.len() >= 40 => { + hlod.name = fixed_name(&chunk.data[8..24]); + hlod.hierarchy = fixed_name(&chunk.data[24..40]); + } + HLOD_LOD_ARRAY => hlod.lods.push(parse_lod(chunk.data)?), + HLOD_AGGREGATES => hlod.aggregates = parse_subobjects(chunk.data, 2)?, + _ => {} + } + } + Ok(hlod) +} + +fn parse_lod(bytes: &[u8]) -> Result { + let mut lod = Lod::default(); + for chunk in chunks(bytes, 2)? { + match chunk.kind { + HLOD_ARRAY_HEADER if chunk.data.len() >= 8 => { + lod.max_screen_size = f32_at(chunk.data, 4)?; + } + HLOD_SUB_OBJECT => lod.subobjects.push(parse_subobject(chunk.data)?), + _ => {} + } + } + Ok(lod) +} + +fn parse_subobjects(bytes: &[u8], depth: usize) -> Result, W3dError> { + chunks(bytes, depth)? + .into_iter() + .filter(|chunk| chunk.kind == HLOD_SUB_OBJECT) + .map(|chunk| parse_subobject(chunk.data)) + .collect() +} + +fn parse_subobject(bytes: &[u8]) -> Result { + if bytes.len() < 36 { + return Err(W3dError::new("invalid HLOD subobject")); + } + Ok(SubObject { + bone: u32_at(bytes, 0)? as usize, + name: fixed_name(&bytes[4..36]), + }) +} + +fn collect_catalog_names( + kind: u32, + bytes: &[u8], + names: &mut Vec, + members: &mut Vec, + depth: usize, +) -> Result<(), W3dError> { + match kind { + MESH_HEADER3 if bytes.len() >= 40 => { + push_name(members, &fixed_name(&bytes[8..24])); + push_name(names, &fixed_name(&bytes[24..40])); + } + HIERARCHY_HEADER | 0x0501 | 0x0601 if bytes.len() >= 20 => { + push_name(names, &fixed_name(&bytes[4..20])); + } + PIVOTS => { + for pivot in bytes.chunks_exact(60) { + push_name(members, &fixed_name(&pivot[..16])); + } + } + HLOD_HEADER if bytes.len() >= 40 => { + push_name(names, &fixed_name(&bytes[8..24])); + push_name(names, &fixed_name(&bytes[24..40])); + } + HLOD_SUB_OBJECT if bytes.len() >= 36 => { + push_name(members, &fixed_name(&bytes[4..36])); + } + 0x0740 if bytes.len() >= 40 => push_name(members, &fixed_name(&bytes[8..40])), + 0x0750 if bytes.len() >= 48 => push_name(members, &fixed_name(&bytes[16..48])), + _ => {} + } + if depth >= MAX_CHUNK_DEPTH || !is_container(kind) { + return Ok(()); + } + for child in chunks(bytes, depth + 1)? { + collect_catalog_names(child.kind, child.data, names, members, depth + 1)?; + } + Ok(()) +} + +fn chunks(bytes: &[u8], depth: usize) -> Result>, W3dError> { + if depth > MAX_CHUNK_DEPTH { + return Err(W3dError::new("W3D chunk nesting exceeds 16")); + } + let mut out = Vec::new(); + let mut position = 0usize; + while position + 8 <= bytes.len() { + if out.len() == MAX_CHUNKS_PER_CONTAINER { + return Err(W3dError::new("W3D chunk count budget exceeded")); + } + let kind = u32_at(bytes, position)?; + let size = (u32_at(bytes, position + 4)? & 0x7fff_ffff) as usize; + let start = position + 8; + let end = start + .checked_add(size) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| W3dError::new("truncated W3D chunk"))?; + out.push(Chunk { + kind, + data: &bytes[start..end], + }); + position = end; + } + if bytes[position..].iter().any(|byte| *byte != 0) { + return Err(W3dError::new("trailing bytes after W3D chunk")); + } + Ok(out) +} + +fn is_container(kind: u32) -> bool { + matches!( + kind, + MESH | VERTEX_MATERIALS + | VERTEX_MATERIAL + | TEXTURES + | TEXTURE + | MATERIAL_PASS + | TEXTURE_STAGE + | HIERARCHY + | HLOD + | HLOD_LOD_ARRAY + | HLOD_AGGREGATES + | 0x0500 + | 0x0600 + ) +} + +fn fixed_name(bytes: &[u8]) -> String { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).trim().to_string() +} + +fn u32_at(bytes: &[u8], offset: usize) -> Result { + let value = bytes + .get(offset..offset + 4) + .ok_or_else(|| W3dError::new("truncated u32"))?; + Ok(u32::from_le_bytes(value.try_into().unwrap())) +} + +fn f32_at(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(u32_at(bytes, offset)?)) +} + +fn vec3_at(bytes: &[u8], offset: usize) -> Result { + Ok(Vec3::new( + f32_at(bytes, offset)?, + f32_at(bytes, offset + 4)?, + f32_at(bytes, offset + 8)?, + )) +} + +fn vec3s(bytes: &[u8]) -> Result, W3dError> { + if bytes.len() % 12 != 0 { + return Err(W3dError::new("invalid vector3 chunk size")); + } + (0..bytes.len()) + .step_by(12) + .map(|offset| vec3_at(bytes, offset)) + .collect() +} + +fn vec2s(bytes: &[u8], flip_v: bool) -> Result, W3dError> { + if bytes.len() % 8 != 0 { + return Err(W3dError::new("invalid vector2 chunk size")); + } + (0..bytes.len()) + .step_by(8) + .map(|offset| { + let u = f32_at(bytes, offset)?; + let v = f32_at(bytes, offset + 4)?; + Ok(Vec2::new(u, if flip_v { 1.0 - v } else { v })) + }) + .collect() +} + +fn u32s(bytes: &[u8]) -> Result, W3dError> { + if bytes.len() % 4 != 0 { + return Err(W3dError::new("invalid u32 array size")); + } + (0..bytes.len()) + .step_by(4) + .map(|offset| u32_at(bytes, offset)) + .collect() +} + +fn rgba(bytes: &[u8]) -> Result, W3dError> { + if bytes.len() % 4 != 0 { + return Err(W3dError::new("invalid RGBA array size")); + } + Ok(bytes + .chunks_exact(4) + .map(|color| [color[0], color[1], color[2], color[3]]) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(kind: u32, payload: &[u8]) -> Vec { + let mut out = kind.to_le_bytes().to_vec(); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(payload); + out + } + + #[test] + fn parses_catalog_and_rejects_truncation() { + let mut header = vec![0; 116]; + header[8..12].copy_from_slice(b"Body"); + header[24..28].copy_from_slice(b"Tank"); + let mesh = chunk(MESH, &chunk(MESH_HEADER3, &header)); + let file = parse(&mesh).unwrap(); + assert!(file + .catalog("Fallback") + .iter() + .any(|model| model.name == "Tank")); + + let mut truncated = mesh; + truncated.pop(); + assert!(parse(&truncated).is_err()); + } +} diff --git a/crates/w3d/src/render.rs b/crates/w3d/src/render.rs new file mode 100644 index 0000000..ac38215 --- /dev/null +++ b/crates/w3d/src/render.rs @@ -0,0 +1,715 @@ +use std::collections::{HashMap, HashSet}; +use std::io::Cursor; + +use glam::{Mat4, Vec2, Vec3, Vec4}; +use image::{DynamicImage, ImageEncoder, ImageFormat, Rgba, RgbaImage}; + +use crate::parse::{Hierarchy, Mesh, SubObject}; +use crate::{W3dError, W3dFile}; + +pub const WIDTH: u32 = 160; +pub const HEIGHT: u32 = 120; +const HIDDEN: u32 = 0x0000_1000; +const TWO_SIDED: u32 = 0x0000_2000; +const COLLISION_MASK: u32 = 0x0000_0ff0; +const MAX_RENDER_TRIANGLES: usize = 10_000; +const MAX_RENDER_TEXTURES: usize = 32; +const MAX_RENDER_TEXTURE_PIXELS: u64 = 16 * 1024 * 1024; +const MAX_RASTER_SAMPLES: u64 = WIDTH as u64 * HEIGHT as u64 * 32; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedThumbnail { + pub png: Vec, + pub missing_textures: Vec, +} + +#[derive(Clone)] +struct Vertex { + position: Vec3, + normal: Vec3, + uv: Vec2, + color: Vec4, +} + +struct DrawTriangle { + vertices: [Vertex; 3], + texture: String, + two_sided: bool, +} + +struct Texture { + image: RgbaImage, +} + +pub(crate) fn thumbnail( + file: &W3dFile, + model: &str, + zoom: f32, + mut load_texture: impl FnMut(&str) -> Option>, +) -> Result { + let hierarchy = selected_hierarchy(file, model); + let bones = hierarchy.map(rest_pose).unwrap_or_default(); + let selected = selected_meshes(file, model); + let triangle_count = selected + .iter() + .try_fold(0usize, |total, (mesh, _)| { + total.checked_add(mesh.triangles.len()) + }) + .filter(|total| *total <= MAX_RENDER_TRIANGLES) + .ok_or_else(|| W3dError::new("model triangle budget exceeded"))?; + let mut triangles = Vec::with_capacity(triangle_count); + for (mesh, fallback_bone) in selected { + append_mesh_triangles(mesh, fallback_bone, &bones, &mut triangles); + } + if triangles.is_empty() { + return Err(W3dError::new("model has no visible triangles")); + } + + let mut textures = HashMap::new(); + let mut missing = Vec::new(); + let mut seen = HashSet::new(); + let mut texture_pixels = 0u64; + for name in triangles + .iter() + .map(|triangle| triangle.texture.as_str()) + .filter(|name| !name.is_empty()) + { + let key = name.to_ascii_lowercase(); + if seen.contains(&key) { + continue; + } + if seen.len() == MAX_RENDER_TEXTURES { + return Err(W3dError::new("model texture count budget exceeded")); + } + seen.insert(key.clone()); + let texture = load_texture(name) + .and_then(|bytes| decode_texture(&bytes).ok()) + .unwrap_or_else(|| { + missing.push(name.to_string()); + missing_texture() + }); + texture_pixels = texture_pixels + .checked_add(u64::from(texture.image.width()) * u64::from(texture.image.height())) + .filter(|pixels| *pixels <= MAX_RENDER_TEXTURE_PIXELS) + .ok_or_else(|| W3dError::new("model texture memory budget exceeded"))?; + textures.insert(key, texture); + } + + let mut pixels = checkerboard(); + let mut depth = vec![f32::INFINITY; (WIDTH * HEIGHT) as usize]; + rasterize( + &triangles, + &textures, + zoom.clamp(0.25, 4.0), + &mut pixels, + &mut depth, + )?; + let pixels = DynamicImage::ImageRgba8(pixels).into_rgb8(); + let mut png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut png) + .write_image(pixels.as_raw(), WIDTH, HEIGHT, image::ColorType::Rgb8) + .map_err(|error| W3dError::new(format!("could not encode preview: {error}")))?; + missing.sort_by_key(|name| name.to_ascii_lowercase()); + missing.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); + Ok(RenderedThumbnail { + png, + missing_textures: missing, + }) +} + +fn selected_hierarchy<'a>(file: &'a W3dFile, model: &str) -> Option<&'a Hierarchy> { + let requested = file + .hlods + .iter() + .find(|hlod| hlod.name.eq_ignore_ascii_case(model)) + .or_else(|| file.hlods.first()) + .map(|hlod| hlod.hierarchy.as_str()); + requested + .and_then(|name| { + file.hierarchies + .iter() + .find(|hierarchy| hierarchy.name.eq_ignore_ascii_case(name)) + }) + .or_else(|| file.hierarchies.first()) +} + +fn selected_meshes<'a>(file: &'a W3dFile, model: &str) -> Vec<(&'a Mesh, usize)> { + if let Some(hlod) = file + .hlods + .iter() + .find(|hlod| hlod.name.eq_ignore_ascii_case(model)) + { + let selected = meshes_for_hlod(file, hlod); + if !selected.is_empty() { + return selected; + } + } + + let matching: Vec<_> = file + .meshes + .iter() + .filter(|mesh| visible(mesh) && mesh_matches(mesh, model)) + .map(|mesh| (mesh, 0)) + .collect(); + if !matching.is_empty() { + return matching; + } + file.hlods + .first() + .map(|hlod| meshes_for_hlod(file, hlod)) + .filter(|selected| !selected.is_empty()) + .unwrap_or_else(|| { + file.meshes + .iter() + .filter(|mesh| visible(mesh)) + .map(|mesh| (mesh, 0)) + .collect() + }) +} + +fn meshes_for_hlod<'a>(file: &'a W3dFile, hlod: &'a crate::parse::Hlod) -> Vec<(&'a Mesh, usize)> { + let mut subobjects: Vec<&SubObject> = hlod.aggregates.iter().collect(); + if let Some(lod) = hlod + .lods + .iter() + .max_by(|left, right| left.max_screen_size.total_cmp(&right.max_screen_size)) + { + subobjects.extend(&lod.subobjects); + } + subobjects + .into_iter() + .filter_map(|subobject| { + find_mesh(&file.meshes, &subobject.name).map(|mesh| (mesh, subobject.bone)) + }) + .filter(|(mesh, _)| visible(mesh)) + .collect() +} + +fn visible(mesh: &Mesh) -> bool { + mesh.attributes & (HIDDEN | COLLISION_MASK) == 0 +} + +fn mesh_matches(mesh: &Mesh, name: &str) -> bool { + mesh.name.eq_ignore_ascii_case(name) + || mesh.container.eq_ignore_ascii_case(name) + || format!("{}.{}", mesh.container, mesh.name).eq_ignore_ascii_case(name) +} + +fn find_mesh<'a>(meshes: &'a [Mesh], name: &str) -> Option<&'a Mesh> { + let short = name + .rsplit_once('.') + .map(|(_, short)| short) + .unwrap_or(name); + meshes.iter().find(|mesh| { + mesh_matches(mesh, name) + || mesh.name.eq_ignore_ascii_case(short) + || format!("{}.{}", mesh.container, mesh.name).eq_ignore_ascii_case(name) + }) +} + +fn rest_pose(hierarchy: &Hierarchy) -> Vec { + let mut bones = Vec::with_capacity(hierarchy.pivots.len()); + for pivot in &hierarchy.pivots { + let local = Mat4::from_rotation_translation(pivot.rotation, pivot.translation); + let world = pivot + .parent + .and_then(|parent| bones.get(parent)) + .copied() + .unwrap_or(Mat4::IDENTITY) + * local; + bones.push(world); + } + bones +} + +fn append_mesh_triangles( + mesh: &Mesh, + fallback_bone: usize, + bones: &[Mat4], + out: &mut Vec, +) { + let uv_source = if mesh.uvs.is_empty() { + &mesh.pass.uvs + } else { + &mesh.uvs + }; + for (triangle_index, triangle) in mesh.triangles.iter().enumerate() { + let texture_id = match mesh.pass.texture_ids.as_slice() { + [only] => *only as usize, + many if triangle_index < many.len() => many[triangle_index] as usize, + _ => 0, + }; + let texture = mesh.textures.get(texture_id).cloned().unwrap_or_default(); + let mut vertices = Vec::with_capacity(3); + for corner in 0..3 { + let index = triangle.indices[corner] as usize; + let Some(position) = mesh.vertices.get(index).copied() else { + vertices.clear(); + break; + }; + let normal = mesh.normals.get(index).copied().unwrap_or(Vec3::Z); + let uv_index = mesh + .pass + .per_face_uv_ids + .get(triangle_index * 3 + corner) + .copied() + .map(|value| value as usize) + .unwrap_or(index); + let uv = uv_source.get(uv_index).copied().unwrap_or(Vec2::ZERO); + let color = mesh + .colors + .get(index) + .or_else(|| mesh.pass.colors.get(index)) + .copied() + .unwrap_or(mesh.material_diffuse); + let transform = mesh + .influences + .get(index) + .map(|bone| *bone as usize) + .or(Some(fallback_bone)) + .and_then(|bone| bones.get(bone)) + .copied() + .unwrap_or(Mat4::IDENTITY); + vertices.push(Vertex { + position: transform.transform_point3(position), + normal: transform.transform_vector3(normal).normalize_or_zero(), + uv, + color: Vec4::new( + color[0] as f32 / 255.0, + color[1] as f32 / 255.0, + color[2] as f32 / 255.0, + color[3] as f32 / 255.0, + ), + }); + } + if let Ok(vertices) = as TryInto<[Vertex; 3]>>::try_into(vertices) { + out.push(DrawTriangle { + vertices, + texture, + two_sided: mesh.attributes & TWO_SIDED != 0, + }); + } + } +} + +fn decode_texture(bytes: &[u8]) -> Result { + let format = image::guess_format(bytes).unwrap_or(ImageFormat::Tga); + let mut reader = image::io::Reader::with_format(Cursor::new(bytes), format); + let mut limits = image::io::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let decoded = reader + .decode() + .map_err(|error| W3dError::new(format!("unsupported texture: {error}")))?; + checked_texture(decoded) +} + +fn checked_texture(image: DynamicImage) -> Result { + if image.width() > 4096 + || image.height() > 4096 + || u64::from(image.width()) * u64::from(image.height()) * 4 > 64 * 1024 * 1024 + { + return Err(W3dError::new("texture budget exceeded")); + } + Ok(Texture { + image: image.to_rgba8(), + }) +} + +fn missing_texture() -> Texture { + let mut image = RgbaImage::new(8, 8); + for (x, y, pixel) in image.enumerate_pixels_mut() { + *pixel = if (x / 2 + y / 2) % 2 == 0 { + Rgba([255, 0, 255, 255]) + } else { + Rgba([25, 25, 25, 255]) + }; + } + Texture { image } +} + +fn checkerboard() -> RgbaImage { + let mut image = RgbaImage::new(WIDTH, HEIGHT); + for (x, y, pixel) in image.enumerate_pixels_mut() { + let value = if (x / 12 + y / 12) % 2 == 0 { 96 } else { 108 }; + *pixel = Rgba([value, value, value, 255]); + } + image +} + +fn rasterize( + triangles: &[DrawTriangle], + textures: &HashMap, + zoom: f32, + pixels: &mut RgbaImage, + depth: &mut [f32], +) -> Result<(), W3dError> { + let mut min = Vec3::splat(f32::INFINITY); + let mut max = Vec3::splat(f32::NEG_INFINITY); + for vertex in triangles.iter().flat_map(|triangle| &triangle.vertices) { + min = min.min(vertex.position); + max = max.max(vertex.position); + } + if !min.is_finite() || !max.is_finite() { + return Err(W3dError::new("model contains non-finite geometry")); + } + let center = (min + max) * 0.5; + let radius = triangles + .iter() + .flat_map(|triangle| &triangle.vertices) + .map(|vertex| vertex.position.distance(center)) + .fold(0.0f32, f32::max) + .max(0.01); + let direction = Vec3::new(1.0, -1.0, 0.75).normalize(); + let fov = 35.0f32.to_radians(); + let distance = radius / (fov * 0.5).tan() * 1.15; + let eye = center + direction * distance; + let view = Mat4::look_at_rh(eye, center, Vec3::Z); + let projection = Mat4::perspective_rh_gl( + fov, + WIDTH as f32 / HEIGHT as f32, + (distance - radius * 1.5).max(0.001), + distance + radius * 2.0, + ); + let view_projection = projection * view; + let light = Vec3::new(0.4, -0.7, 1.0).normalize(); + let mut raster_samples = MAX_RASTER_SAMPLES; + + for triangle in triangles { + let face = (triangle.vertices[1].position - triangle.vertices[0].position) + .cross(triangle.vertices[2].position - triangle.vertices[0].position); + if !triangle.two_sided && face.dot(eye - triangle.vertices[0].position) <= 0.0 { + continue; + } + let projected = triangle + .vertices + .clone() + .map(|vertex| project(vertex, view_projection, zoom)); + if projected.iter().any(|vertex| vertex.clip_w <= 0.0) { + continue; + } + draw_triangle( + &projected, + textures.get(&triangle.texture.to_ascii_lowercase()), + light, + pixels, + depth, + &mut raster_samples, + )?; + } + Ok(()) +} + +#[derive(Clone)] +struct Projected { + screen: Vec2, + depth: f32, + inv_w: f32, + uv_over_w: Vec2, + normal_over_w: Vec3, + color_over_w: Vec4, + clip_w: f32, +} + +fn project(vertex: Vertex, view_projection: Mat4, zoom: f32) -> Projected { + let clip = view_projection * vertex.position.extend(1.0); + let inv_w = 1.0 / clip.w; + let ndc = clip.truncate() * inv_w; + Projected { + screen: Vec2::new( + (ndc.x * zoom * 0.5 + 0.5) * (WIDTH - 1) as f32, + (1.0 - (ndc.y * zoom * 0.5 + 0.5)) * (HEIGHT - 1) as f32, + ), + depth: ndc.z, + inv_w, + uv_over_w: vertex.uv * inv_w, + normal_over_w: vertex.normal * inv_w, + color_over_w: vertex.color * inv_w, + clip_w: clip.w, + } +} + +fn draw_triangle( + vertices: &[Projected; 3], + texture: Option<&Texture>, + light: Vec3, + pixels: &mut RgbaImage, + depth: &mut [f32], + raster_samples: &mut u64, +) -> Result<(), W3dError> { + let area = edge(vertices[0].screen, vertices[1].screen, vertices[2].screen); + if area.abs() < 0.0001 { + return Ok(()); + } + let min_x = vertices + .iter() + .map(|vertex| vertex.screen.x) + .fold(f32::INFINITY, f32::min) + .floor() + .clamp(0.0, (WIDTH - 1) as f32) as u32; + let max_x = vertices + .iter() + .map(|vertex| vertex.screen.x) + .fold(f32::NEG_INFINITY, f32::max) + .ceil() + .clamp(0.0, (WIDTH - 1) as f32) as u32; + let min_y = vertices + .iter() + .map(|vertex| vertex.screen.y) + .fold(f32::INFINITY, f32::min) + .floor() + .clamp(0.0, (HEIGHT - 1) as f32) as u32; + let max_y = vertices + .iter() + .map(|vertex| vertex.screen.y) + .fold(f32::NEG_INFINITY, f32::max) + .ceil() + .clamp(0.0, (HEIGHT - 1) as f32) as u32; + if min_x > max_x || min_y > max_y { + return Ok(()); + } + let samples = u64::from(max_x - min_x + 1) * u64::from(max_y - min_y + 1); + *raster_samples = (*raster_samples) + .checked_sub(samples) + .ok_or_else(|| W3dError::new("model raster work budget exceeded"))?; + + for y in min_y..=max_y { + for x in min_x..=max_x { + let point = Vec2::new(x as f32 + 0.5, y as f32 + 0.5); + let bary = [ + edge(vertices[1].screen, vertices[2].screen, point) / area, + edge(vertices[2].screen, vertices[0].screen, point) / area, + edge(vertices[0].screen, vertices[1].screen, point) / area, + ]; + if bary.iter().any(|weight| *weight < -0.0001) { + continue; + } + let z = bary[0] * vertices[0].depth + + bary[1] * vertices[1].depth + + bary[2] * vertices[2].depth; + let offset = (y * WIDTH + x) as usize; + if z >= depth[offset] { + continue; + } + let denominator = bary[0] * vertices[0].inv_w + + bary[1] * vertices[1].inv_w + + bary[2] * vertices[2].inv_w; + if denominator <= 0.0 { + continue; + } + let interpolate_vec2 = |field: fn(&Projected) -> Vec2| { + (field(&vertices[0]) * bary[0] + + field(&vertices[1]) * bary[1] + + field(&vertices[2]) * bary[2]) + / denominator + }; + let interpolate_vec3 = |field: fn(&Projected) -> Vec3| { + (field(&vertices[0]) * bary[0] + + field(&vertices[1]) * bary[1] + + field(&vertices[2]) * bary[2]) + / denominator + }; + let interpolate_vec4 = |field: fn(&Projected) -> Vec4| { + (field(&vertices[0]) * bary[0] + + field(&vertices[1]) * bary[1] + + field(&vertices[2]) * bary[2]) + / denominator + }; + let uv = interpolate_vec2(|vertex| vertex.uv_over_w); + let normal = interpolate_vec3(|vertex| vertex.normal_over_w).normalize_or_zero(); + let vertex_color = interpolate_vec4(|vertex| vertex.color_over_w); + let texel = texture.map_or(Vec4::ONE, |texture| sample(texture, uv)); + let brightness = 0.35 + 0.65 * normal.dot(light).abs(); + let source = Vec4::new( + texel.x * vertex_color.x * brightness, + texel.y * vertex_color.y * brightness, + texel.z * vertex_color.z * brightness, + texel.w * vertex_color.w, + ) + .clamp(Vec4::ZERO, Vec4::ONE); + if source.w < 0.02 { + continue; + } + let destination = pixels.get_pixel(x, y).0; + let destination = Vec4::new( + destination[0] as f32 / 255.0, + destination[1] as f32 / 255.0, + destination[2] as f32 / 255.0, + destination[3] as f32 / 255.0, + ); + let output = source * source.w + destination * (1.0 - source.w); + pixels.put_pixel( + x, + y, + Rgba([ + (output.x * 255.0).round() as u8, + (output.y * 255.0).round() as u8, + (output.z * 255.0).round() as u8, + 255, + ]), + ); + if source.w >= 0.99 { + depth[offset] = z; + } + } + } + Ok(()) +} + +fn edge(a: Vec2, b: Vec2, point: Vec2) -> f32 { + (point.x - a.x) * (b.y - a.y) - (point.y - a.y) * (b.x - a.x) +} + +fn sample(texture: &Texture, uv: Vec2) -> Vec4 { + let x = (uv.x.rem_euclid(1.0) * (texture.image.width() - 1) as f32).round() as u32; + let y = (uv.y.rem_euclid(1.0) * (texture.image.height() - 1) as f32).round() as u32; + let pixel = texture.image.get_pixel(x, y).0; + Vec4::new( + pixel[0] as f32 / 255.0, + pixel[1] as f32 / 255.0, + pixel[2] as f32 / 255.0, + pixel[3] as f32 / 255.0, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse::{MaterialPass, Triangle}; + + #[test] + fn renders_a_bounded_png_and_reports_missing_texture() { + let file = W3dFile { + meshes: vec![Mesh { + name: "Triangle".into(), + vertices: vec![ + Vec3::new(-1.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(0.0, 0.0, 1.0), + ], + normals: vec![Vec3::Y; 3], + uvs: vec![Vec2::ZERO, Vec2::X, Vec2::Y], + triangles: vec![Triangle { indices: [0, 1, 2] }], + textures: vec!["missing.tga".into()], + material_diffuse: [255; 4], + pass: MaterialPass { + texture_ids: vec![0], + ..MaterialPass::default() + }, + ..Mesh::default() + }], + ..W3dFile::default() + }; + let rendered = thumbnail(&file, "Triangle", 1.0, |_| None).unwrap(); + let image = image::load_from_memory(&rendered.png).unwrap(); + assert_eq!((image.width(), image.height()), (WIDTH, HEIGHT)); + assert_eq!(rendered.missing_textures, vec!["missing.tga"]); + + let zoomed_out = thumbnail(&file, "Triangle", 0.5, |_| None).unwrap(); + let zoomed_in = thumbnail(&file, "Triangle", 2.0, |_| None).unwrap(); + let colored_pixels = |png: &[u8]| { + image::load_from_memory(png) + .unwrap() + .to_rgb8() + .pixels() + .filter(|pixel| pixel[0] != pixel[1] || pixel[1] != pixel[2]) + .count() + }; + assert!(colored_pixels(&zoomed_in.png) > colored_pixels(&zoomed_out.png)); + } + + #[test] + fn thumbnail_fits_vscode_markdown_limit_with_noisy_texture() { + let file = W3dFile { + meshes: vec![Mesh { + name: "Square".into(), + vertices: vec![ + Vec3::new(-1.0, 0.0, -1.0), + Vec3::new(1.0, 0.0, -1.0), + Vec3::new(1.0, 0.0, 1.0), + Vec3::new(-1.0, 0.0, 1.0), + ], + normals: vec![Vec3::NEG_Y; 4], + uvs: vec![Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y], + triangles: vec![ + Triangle { indices: [0, 1, 2] }, + Triangle { indices: [0, 2, 3] }, + ], + textures: vec!["noise.tga".into()], + material_diffuse: [255; 4], + pass: MaterialPass { + texture_ids: vec![0], + ..MaterialPass::default() + }, + ..Mesh::default() + }], + ..W3dFile::default() + }; + let mut tga = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 32, 0x20]; + for y in 0..256u32 { + for x in 0..256u32 { + let value = x.wrapping_mul(0x9e37_79b9) ^ y.wrapping_mul(0x85eb_ca6b); + tga.extend_from_slice(&[value as u8, (value >> 8) as u8, (value >> 16) as u8, 255]); + } + } + + let rendered = thumbnail(&file, "Square", 1.0, |_| Some(tga.clone())).unwrap(); + let base64_len = rendered.png.len().div_ceil(3) * 4; + assert!( + base64_len + 2_048 < 100_000, + "{} bytes of PNG becomes {base64_len} bytes of base64", + rendered.png.len() + ); + } + + #[test] + fn thumbnail_rejects_excessive_triangle_and_texture_work() { + let mesh = |triangle_count: usize, texture_count: usize| Mesh { + name: "Budget".into(), + vertices: vec![ + Vec3::new(-1.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(0.0, 0.0, 1.0), + ], + normals: vec![Vec3::NEG_Y; 3], + triangles: vec![Triangle { indices: [0, 1, 2] }; triangle_count], + textures: (0..texture_count) + .map(|index| format!("{index}.tga")) + .collect(), + material_diffuse: [255; 4], + pass: MaterialPass { + texture_ids: (0..triangle_count) + .map(|index| (index % texture_count.max(1)) as u32) + .collect(), + ..MaterialPass::default() + }, + ..Mesh::default() + }; + + let too_many_triangles = W3dFile { + meshes: vec![mesh(10_001, 0)], + ..W3dFile::default() + }; + assert!(thumbnail(&too_many_triangles, "Budget", 1.0, |_| None).is_err()); + + let too_much_raster_work = W3dFile { + meshes: vec![mesh(500, 0)], + ..W3dFile::default() + }; + assert!(thumbnail(&too_much_raster_work, "Budget", 1.0, |_| None).is_err()); + + let too_many_textures = W3dFile { + meshes: vec![mesh(33, 33)], + ..W3dFile::default() + }; + let mut loads = 0; + assert!(thumbnail(&too_many_textures, "Budget", 1.0, |_| { + loads += 1; + None + }) + .is_err()); + assert!(loads <= 32); + } +} diff --git a/docs/language-server.md b/docs/language-server.md index a3dec57..06e90af 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -84,6 +84,7 @@ symbols. ```json { "format": { "enable": false }, + "preview": { "imageWidth": 160, "zoomPercent": 100 }, "schemaPath": "C:/Mods/MyMod/schema.json", "analysis": { "modelMemberStrictness": "compatible", @@ -101,6 +102,12 @@ symbols. - `format.enable` controls document formatting. It defaults to `false` and is dynamically registered when the client supports it. +- `preview.imageWidth` controls the displayed W3D thumbnail width in pixels + (`80`–`640`, default `160`). The client still owns the outer details-pane + bounds. +- `preview.zoomPercent` controls the default W3D camera zoom (`25`–`400`, + default `100`). Both preview settings apply to newly resolved completions + without restarting the server. - `schemaPath` points to a custom schema JSON file. Unreadable or invalid files produce a warning and fall back to the built-in schema. - `analysis.modelMemberStrictness` is `off`, `compatible` (member exists in any @@ -143,6 +150,17 @@ ZeroSyntax supports incremental document sync, diagnostics, completion, hover, go to definition, references, rename, semantic tokens, document and workspace symbols, folding ranges, quick fixes, and optional document formatting. +W3D model completion items support `completionItem/resolve`. Clients that render +Markdown completion documentation can show a lazy textured thumbnail +for the active `Model =` suggestion. The initial completion list contains no +image data. Previews use indexed loose or BIG-contained W3D/TGA/DDS assets and +cover mesh geometry, base materials, HLOD composition, and hierarchy bind pose. +`preview.imageWidth` changes its displayed size and `preview.zoomPercent` +changes the model framing. +Malformed or unsupported assets leave completion functional and show a short +preview-unavailable message. Rebuild the asset index or reload the server after +changing binary assets. + ## Build from source Install Rust 1.75 or newer, clone the repository, and run: diff --git a/editors/vscode/README.md b/editors/vscode/README.md index b2a91ba..20398df 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -33,6 +33,13 @@ WAV/MP3 audio, and TGA/DDS texture checks. Configure every loaded game/mod root; asset warnings activate per kind once any matching asset is indexed. DDS-only textures are offered using the engine-compatible `stem.tga` spelling. +While completing `Model =`, move the active selection with the keyboard or +mouse to see a textured W3D thumbnail in VS Code's suggestion-details pane. +The preview is loaded only for the selected entry. If the pane is collapsed, +press `Ctrl+Space` again or use the suggestion widget's details control. +Use `zerosyntax.preview.imageWidth` to change the thumbnail/pane content width +and `zerosyntax.preview.zoomPercent` to change the model framing. + ## Settings | Setting | Default | Purpose | @@ -43,6 +50,8 @@ textures are offered using the engine-compatible `stem.tga` spelling. | `zerosyntax.analysis.allowPercentagesWithoutSign` | `false` | Allows engine-compatible percentage values without a trailing `%`; applies immediately. | | `zerosyntax.analysis.mapOrderingDiagnostics` | `true` | Warns about source-proven forward-order problems in `map.ini` and `solo.ini`; applies immediately. | | `zerosyntax.analysis.debounceMs` | `250` | Delay before diagnostics/index refresh after typing; applies to future edits immediately. | +| `zerosyntax.preview.imageWidth` | `160` | Displayed W3D thumbnail width in pixels; applies to newly resolved previews immediately. | +| `zerosyntax.preview.zoomPercent` | `100` | Default W3D preview camera zoom; applies to newly resolved previews immediately. | | `zerosyntax.format.enable` | `false` | Enables indentation formatting immediately when the client supports dynamic registration. | | `zerosyntax.server.path` | empty | Uses a custom `zerosyntax-lsp` binary instead of the bundled one; changing it restarts the server. | | `zerosyntax.trace.server` | `off` | Logs LSP traffic for troubleshooting. | diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 4a303d5..48bd60b 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -77,6 +77,20 @@ "default": false, "markdownDescription": "Enable document formatting (indentation normalization). When off — the default — `#editor.formatOnSave#` will not invoke it for Generals INI files." }, + "zerosyntax.preview.imageWidth": { + "type": "integer", + "default": 160, + "minimum": 80, + "maximum": 640, + "markdownDescription": "Width in pixels of the W3D model image in completion details. VS Code sizes the details pane around the image within the available editor space." + }, + "zerosyntax.preview.zoomPercent": { + "type": "integer", + "default": 100, + "minimum": 25, + "maximum": 400, + "markdownDescription": "Default camera zoom for W3D completion previews, as a percentage. Values above 100 show the model closer; values below 100 add space around it." + }, "zerosyntax.baseIniRoots": { "type": "array", "default": [], diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 2469273..54bedb2 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -42,6 +42,10 @@ export function activate(context: vscode.ExtensionContext) { format: { enable: setting("format.enable", false), }, + preview: { + imageWidth: setting("preview.imageWidth", 160), + zoomPercent: setting("preview.zoomPercent", 100), + }, baseIniRoots: setting("baseIniRoots", []), schemaPath: setting("schema.path", ""), analysis: {