Resolve FBX external texture dependencies

This commit is contained in:
Rbanh 2026-07-13 13:41:23 -04:00
parent 7e99243a78
commit 3e30c61c71
22 changed files with 1670 additions and 265 deletions

View File

@ -0,0 +1,44 @@
# FBX External Texture Dependencies
**Issue:** Gitea #58 (`BS-JD-210`)
## Goal
Make FBX external texture discovery deterministic from import through runtime loading. Missing or
unsafe references must be reported before the Asset Browser queues Bevy loads, while an explicit
Authoring Override remains a supported untextured workflow.
## Scope
1. Add one path-safe FBX texture-reference resolver shared by the local `bevy_ufbx` loader and the
editor asset pipeline. Normalize separators, deduplicate references, retain relative `textures/`
and `.fbm/` layouts, and reject absolute or parent-traversing references.
2. Decode external images through the FBX `LoadContext`, reuse one handle per normalized path, and
consolidate unavailable dependency logging so duplicate ufbx texture records cannot create
repeated asset-server failures.
3. Generate static-mesh dependencies from parsed FBX texture references. Treat them as required for
Source Materials and as declared-but-optional for Authoring Override during read-only project
validation.
4. Replace the FBX-only file copy with a staged bundle transaction that preflights every referenced
file, preserves relative sidecar layout, backs up overwritten destinations, and rolls back a
partial commit.
5. Preflight source-material thumbnails, retain neutral direct FBX mesh/model previews, and expose a
stable failure reason plus missing dependency status in the Asset Browser.
6. Deliberately normalize the committed painted-chair fixture to Authoring Override rather than add
large absent 2K textures.
## Verification
- Unit tests for sibling `textures/`, `.fbm/`, duplicate references, missing files, path traversal,
destination symlink containment, transactional preservation, consolidated project findings, and
read-only validation.
- `cargo fmt`, focused crate tests, workspace Clippy, workspace tests, `validate-levels`, and
`validate-samples`. Packaged tests remain deferred by project-owner direction.
- Native editor QA for the painted chair and an intentionally broken Source Materials variant,
including log inspection and screenshots published as ordinary Gitea attachments.
## Documentation
- Record the cross-crate dependency/override contract in ADR 0044.
- Update editor architecture, Asset Browser workflow, root controls/checklist, documentation maps,
and an issue-specific evaluation record.

View File

@ -378,6 +378,7 @@ The `.vscode/` folder is preconfigured:
- [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md)
- [ADR 0037: Collaborative Authored-File Safety](docs/adr/0037-collaborative-authored-file-safety.md)
- [ADR 0043: Content-Addressed Import Fingerprints](docs/adr/0043-content-addressed-import-fingerprints.md)
- [ADR 0044: Sandboxed FBX External Texture Dependencies](docs/adr/0044-sandboxed-fbx-external-texture-dependencies.md)
## Project Layout
@ -432,6 +433,9 @@ crates/
- [x] Deterministic imported-source fingerprints for models, textures, and audio; BLAKE3-backed
static/animation manifests; byte-preserving equivalent refresh; and read-only validator checkout
assertions ([ADR 0043](docs/adr/0043-content-addressed-import-fingerprints.md), [evaluation](docs/editor/evaluations/deterministic-asset-fingerprints/), [Gitea #56](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/56))
- [x] Sandboxed FBX external-texture discovery for sibling and `.fbm/` layouts, transactional
referenced-bundle import, manifest-first consolidated validation, deduplicated loader reads, and
stable Asset Browser dependency status ([ADR 0044](docs/adr/0044-sandboxed-fbx-external-texture-dependencies.md), [Gitea #58](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/58))
- [x] Asset Browser expandable model subasset shelves, independent mesh/material/texture thumbnails, staged import/material details with shader-schema parameters, context actions, and trash-first file removal
- [x] Audio clip catalog/import foundation for Ogg, WAV, MP3, and FLAC with dedicated filtering, file details, and stable runtime-resolvable asset references
- [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47))
@ -479,8 +483,11 @@ crates/
- The editor asset browser is filesystem-backed with folder/tree navigation, grid/list views,
texture and model thumbnails (glTF albedo fast-path; offscreen render studio for FBX and
untextured models), material sphere thumbnails, search/filter/sort controls, expandable model
subasset shelves, and a staged details pane. **File → Import Assets**
accepts glTF/GLB and **FBX** (binary; copies sibling `.fbm` texture folders when present).
subasset shelves, and a staged details pane. **File -> Import Assets**
accepts glTF/GLB and binary **FBX**. FBX import parses and transactionally preserves every safe
referenced sibling `textures/` or `.fbm/` file while rejecting traversal and external absolute
paths before project content changes. Missing source textures appear once in validation and Asset
Browser dependency status; **Authoring Override** deliberately permits an untextured model.
Model assets generate normalized model manifests under `assets/meshes/generated/`; drag/drop uses
**Renderable Asset (Auto)**. Unrigged sources use `StaticMeshRenderer` with imported asset refs
and optional separate static mesh colliders. Skin-bound or animated sources and their subasset placement use

View File

@ -458,12 +458,16 @@
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
material_policy: AuthoringOverride,
static_mesh_manifest_path: Some("assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron"),
animation_manifest_path: Some("assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron"),
default_animation_clip_id: None,
),
dependencies: [],
dependencies: [
"assets/models/textures/painted_wooden_chair_02_diff_2k.jpg",
"assets/models/textures/painted_wooden_chair_02_nor_gl_2k.exr",
"assets/models/textures/painted_wooden_chair_02_rough_2k.exr",
],
),
(
id: ("3f63f359-45eb-4cb2-8970-71921cbd7bd0"),
@ -769,4 +773,4 @@
),
dependencies: [],
),
]
]

View File

@ -9,7 +9,11 @@
byte_len: 59964,
content_hash: "b12973a62dcb44589e380ea833eade726ae98a86c81084c842ee3801866d6a46",
),
dependencies: [],
dependencies: [
"assets/models/textures/painted_wooden_chair_02_diff_2k.jpg",
"assets/models/textures/painted_wooden_chair_02_nor_gl_2k.exr",
"assets/models/textures/painted_wooden_chair_02_rough_2k.exr",
],
),
import: (
scale: 1.0,
@ -17,7 +21,7 @@
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
material_policy: AuthoringOverride,
),
metadata: (
mesh_count: 1,
@ -48,4 +52,4 @@
),
],
warnings: [],
)
)

View File

@ -1455,7 +1455,7 @@ pub fn import_external_assets(paths: &[PathBuf]) -> Result<usize, String> {
};
let dest_dir = import_dir_for_extension(extension);
if extension.eq_ignore_ascii_case("fbx") {
super::import::copy_fbx_with_sidecar(source, Path::new(dest_dir))?;
super::import::copy_fbx_bundle(source, Path::new(dest_dir))?;
copied += 1;
continue;
}

View File

@ -1,71 +1,564 @@
//! External asset import helpers (FBX sidecar folders, etc.).
//! External asset bundle inspection and transactional import.
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
/// Copies an FBX file and its sibling `.fbm` embedded-texture folder when present.
pub fn copy_fbx_with_sidecar(source: &Path, dest_dir: &Path) -> Result<(), String> {
let Some(file_name) = source.file_name() else {
return Err("FBX import path has no file name".into());
};
fs::create_dir_all(dest_dir)
.map_err(|err| format!("could not create {}: {err}", dest_dir.display()))?;
let dest = dest_dir.join(file_name);
fs::copy(source, &dest).map_err(|err| {
use bevy_ufbx::texture::external_texture_paths;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FbxDependencyInspection {
pub relative_paths: Vec<String>,
pub resolved_paths: Vec<PathBuf>,
pub missing_paths: Vec<PathBuf>,
}
/// Parses every external FBX texture reference without loading it through Bevy.
pub(crate) fn inspect_fbx_dependencies(source: &Path) -> Result<FbxDependencyInspection, String> {
let bytes = fs::read(source)
.map_err(|error| format!("could not read FBX {}: {error}", source.display()))?;
let filename_hint = source.to_string_lossy();
let scene = ufbx::load_memory(
&bytes,
ufbx::LoadOpts {
target_unit_meters: 1.0,
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
filename: ufbx::StringOpt::Ref(&filename_hint),
..Default::default()
},
)
.map_err(|error| format!("could not parse FBX {}: {error:?}", source.display()))?;
let relative_paths = external_texture_paths(&scene).map_err(|errors| {
format!(
"could not copy {} to {}: {err}",
"unsafe FBX texture reference(s) in {}: {}",
source.display(),
dest.display()
errors
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("; ")
)
})?;
let source_root = source.parent().unwrap_or_else(|| Path::new(""));
let canonical_root = fs::canonicalize(source_root).map_err(|error| {
format!(
"could not resolve FBX source directory {}: {error}",
source_root.display()
)
})?;
let mut resolved_paths = Vec::new();
let mut missing_paths = Vec::new();
for relative_path in &relative_paths {
let resolved = source_root.join(relative_path);
if !resolved.is_file() {
missing_paths.push(resolved.clone());
resolved_paths.push(resolved);
continue;
}
let canonical = fs::canonicalize(&resolved).map_err(|error| {
format!(
"could not resolve FBX dependency {}: {error}",
resolved.display()
)
})?;
if !canonical.starts_with(&canonical_root) {
return Err(format!(
"FBX dependency {} resolves outside source directory {}",
resolved.display(),
source_root.display()
));
}
resolved_paths.push(resolved);
}
missing_paths.sort();
Ok(FbxDependencyInspection {
relative_paths,
resolved_paths,
missing_paths,
})
}
if let Some(fbm_dir) = fbm_sidecar_dir(source) {
if fbm_dir.is_dir() {
let dest_fbm = dest_dir.join(fbm_dir.file_name().unwrap_or_default());
copy_dir_recursive(&fbm_dir, &dest_fbm)?;
/// Returns one stable browser-facing error for all missing FBX source textures.
pub(crate) fn validate_fbx_dependencies(source: &Path) -> Result<(), String> {
let inspection = inspect_fbx_dependencies(source)?;
if inspection.missing_paths.is_empty() {
return Ok(());
}
Err(format!(
"missing {} FBX source texture(s): {}",
inspection.missing_paths.len(),
inspection
.missing_paths
.iter()
.map(|path| path.to_string_lossy().replace('\\', "/"))
.collect::<Vec<_>>()
.join(", ")
))
}
/// Copies an FBX and every referenced external texture as one staged filesystem transaction.
pub fn copy_fbx_bundle(source: &Path, dest_dir: &Path) -> Result<(), String> {
let inspection = inspect_fbx_dependencies(source)?;
if !inspection.missing_paths.is_empty() {
return Err(format!(
"FBX import is missing {} required texture(s): {}",
inspection.missing_paths.len(),
inspection
.missing_paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
let file_name = source
.file_name()
.ok_or_else(|| "FBX import path has no file name".to_string())?;
let mut entries = BTreeMap::new();
entries.insert(PathBuf::from(file_name), source.to_path_buf());
for (relative, resolved) in inspection
.relative_paths
.iter()
.zip(inspection.resolved_paths.iter())
{
let relative = PathBuf::from(relative);
if let Some(existing) = entries.insert(relative.clone(), resolved.clone()) {
if existing != *resolved {
return Err(format!(
"FBX import maps multiple source files to {}",
relative.display()
));
}
}
}
Ok(())
copy_bundle_transactionally(&entries, dest_dir)
}
fn fbm_sidecar_dir(source: &Path) -> Option<PathBuf> {
let stem = source.file_stem()?.to_str()?;
let parent = source.parent()?;
Some(parent.join(format!("{stem}.fbm")))
}
fn copy_bundle_transactionally(
entries: &BTreeMap<PathBuf, PathBuf>,
dest_dir: &Path,
) -> Result<(), String> {
fs::create_dir_all(dest_dir)
.map_err(|error| format!("could not create {}: {error}", dest_dir.display()))?;
let canonical_dest_dir = fs::canonicalize(dest_dir).map_err(|error| {
format!(
"could not resolve FBX import destination {}: {error}",
dest_dir.display()
)
})?;
for relative in entries.keys() {
validate_bundle_relative_path(relative)?;
validate_existing_destination_parents(dest_dir, &canonical_dest_dir, relative)?;
let target = dest_dir.join(relative);
if target.is_dir() {
return Err(format!(
"FBX import target {} is an existing directory",
target.display()
));
}
}
fn copy_dir_recursive(source: &Path, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest)
.map_err(|err| format!("could not create {}: {err}", dest.display()))?;
for entry in fs::read_dir(source).map_err(|err| err.to_string())? {
let entry = entry.map_err(|err| err.to_string())?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dest_path)?;
} else {
fs::copy(&src_path, &dest_path).map_err(|err| {
let stage_root = dest_dir.join(format!(".blacksite-import-{}", uuid::Uuid::new_v4()));
let staged_root = stage_root.join("new");
let backup_root = stage_root.join("backup");
let stage_result = (|| {
for (relative, source) in entries {
let staged = staged_root.join(relative);
if let Some(parent) = staged.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"could not create import staging {}: {error}",
parent.display()
)
})?;
}
fs::copy(source, &staged).map_err(|error| {
format!(
"could not copy {} to {}: {err}",
src_path.display(),
dest_path.display()
"could not stage FBX bundle file {} as {}: {error}",
source.display(),
relative.display()
)
})?;
}
commit_staged_bundle(
entries,
dest_dir,
&canonical_dest_dir,
&staged_root,
&backup_root,
)
})();
let cleanup_result = fs::remove_dir_all(&stage_root);
match (stage_result, cleanup_result) {
(Err(error), _) => Err(error),
(Ok(()), Ok(())) => Ok(()),
(Ok(()), Err(error)) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
(Ok(()), Err(error)) => {
bevy::log::warn!(
"FBX bundle imported but staging cleanup failed for {}: {error}",
stage_root.display()
);
Ok(())
}
}
}
fn validate_bundle_relative_path(relative: &Path) -> Result<(), String> {
if relative.as_os_str().is_empty()
|| !relative
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return Err(format!(
"FBX import target path {} is not a safe relative path",
relative.display()
));
}
Ok(())
}
fn validate_existing_destination_parents(
dest_dir: &Path,
canonical_dest_dir: &Path,
relative: &Path,
) -> Result<(), String> {
let mut current = dest_dir.to_path_buf();
let Some(parent) = relative.parent() else {
return Ok(());
};
for component in parent.components() {
let Component::Normal(segment) = component else {
return Err(format!(
"FBX import target path {} is not a safe relative path",
relative.display()
));
};
current.push(segment);
let metadata = match fs::symlink_metadata(&current) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
Err(error) => {
return Err(format!(
"could not inspect FBX import target {}: {error}",
current.display()
));
}
};
if metadata.file_type().is_symlink() {
return Err(format!(
"FBX import target parent {} is a symbolic link",
current.display()
));
}
if !metadata.is_dir() {
return Err(format!(
"FBX import target parent {} is not a directory",
current.display()
));
}
let canonical = fs::canonicalize(&current).map_err(|error| {
format!(
"could not resolve FBX import target parent {}: {error}",
current.display()
)
})?;
if !canonical.starts_with(canonical_dest_dir) {
return Err(format!(
"FBX import target parent {} resolves outside destination {}",
current.display(),
dest_dir.display()
));
}
}
Ok(())
}
fn commit_staged_bundle(
entries: &BTreeMap<PathBuf, PathBuf>,
dest_dir: &Path,
canonical_dest_dir: &Path,
staged_root: &Path,
backup_root: &Path,
) -> Result<(), String> {
let mut installed = Vec::new();
let mut backups = Vec::new();
for relative in entries.keys() {
let target = dest_dir.join(relative);
let staged = staged_root.join(relative);
if let Some(parent) = target.parent() {
if let Err(error) = fs::create_dir_all(parent) {
return rollback_bundle(
&installed,
&backups,
format!(
"could not create import target {}: {error}",
parent.display()
),
);
}
let canonical_parent = match fs::canonicalize(parent) {
Ok(canonical_parent) => canonical_parent,
Err(error) => {
return rollback_bundle(
&installed,
&backups,
format!(
"could not resolve import target {}: {error}",
parent.display()
),
);
}
};
if !canonical_parent.starts_with(canonical_dest_dir) {
return rollback_bundle(
&installed,
&backups,
format!(
"FBX import target {} resolves outside destination {}",
parent.display(),
dest_dir.display()
),
);
}
}
if target.exists() {
let backup = backup_root.join(relative);
if let Some(parent) = backup.parent() {
if let Err(error) = fs::create_dir_all(parent) {
return rollback_bundle(
&installed,
&backups,
format!(
"could not create import backup {}: {error}",
parent.display()
),
);
}
}
if let Err(error) = fs::rename(&target, &backup) {
return rollback_bundle(
&installed,
&backups,
format!(
"could not back up import target {}: {error}",
target.display()
),
);
}
backups.push((backup, target.clone()));
}
if let Err(error) = fs::rename(&staged, &target) {
return rollback_bundle(
&installed,
&backups,
format!(
"could not publish import target {}: {error}",
target.display()
),
);
}
installed.push(target);
}
Ok(())
}
fn rollback_bundle(
installed: &[PathBuf],
backups: &[(PathBuf, PathBuf)],
cause: String,
) -> Result<(), String> {
let mut rollback_errors = Vec::new();
for target in installed.iter().rev() {
if let Err(error) = fs::remove_file(target) {
if error.kind() != std::io::ErrorKind::NotFound {
rollback_errors.push(format!("remove {}: {error}", target.display()));
}
}
}
for (backup, target) in backups.iter().rev() {
if let Err(error) = fs::rename(backup, target) {
rollback_errors.push(format!(
"restore {} to {}: {error}",
backup.display(),
target.display()
));
}
}
if rollback_errors.is_empty() {
Err(cause)
} else {
Err(format!(
"{cause}; rollback also failed: {}",
rollback_errors.join("; ")
))
}
}
#[cfg(test)]
mod tests {
use super::fbm_sidecar_dir;
use std::path::Path;
use super::*;
fn temp_root(label: &str) -> PathBuf {
std::env::temp_dir().join(format!("blacksite-fbx-{label}-{}", uuid::Uuid::new_v4()))
}
fn committed_chair_bytes() -> Vec<u8> {
fs::read(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets/models/painted_wooden_chair_02_2k.fbx"),
)
.unwrap()
}
fn replace_equal_length(bytes: &mut [u8], from: &[u8], to: &[u8]) -> usize {
assert_eq!(from.len(), to.len());
let mut count = 0;
let mut offset = 0;
while let Some(index) = bytes[offset..]
.windows(from.len())
.position(|window| window == from)
{
let start = offset + index;
bytes[start..start + from.len()].copy_from_slice(to);
offset = start + from.len();
count += 1;
}
count
}
fn write_textures(root: &Path, folder: &str) {
let folder = root.join(folder);
fs::create_dir_all(&folder).unwrap();
for name in [
"painted_wooden_chair_02_diff_2k.jpg",
"painted_wooden_chair_02_nor_gl_2k.exr",
"painted_wooden_chair_02_rough_2k.exr",
] {
fs::write(folder.join(name), name.as_bytes()).unwrap();
}
}
#[test]
fn fbm_sidecar_path() {
fn committed_chair_reports_one_stable_missing_dependency_state() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets/models/painted_wooden_chair_02_2k.fbx");
let error = validate_fbx_dependencies(&path).unwrap_err();
assert!(error.starts_with("missing 3 FBX source texture(s):"));
assert_eq!(error.matches("painted_wooden_chair_02_").count(), 3);
}
#[test]
fn sibling_texture_bundle_is_copied_with_relative_layout() {
let root = temp_root("sibling");
let source_root = root.join("source");
let destination = root.join("destination");
fs::create_dir_all(&source_root).unwrap();
let source = source_root.join("chair.fbx");
fs::write(&source, committed_chair_bytes()).unwrap();
write_textures(&source_root, "textures");
copy_fbx_bundle(&source, &destination).unwrap();
assert!(destination.join("chair.fbx").is_file());
assert!(destination
.join("textures/painted_wooden_chair_02_diff_2k.jpg")
.is_file());
assert!(!destination.read_dir().unwrap().any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".blacksite-import-")));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn fbm_texture_bundle_is_copied_with_relative_layout() {
let root = temp_root("fbm");
let source_root = root.join("source");
let destination = root.join("destination");
fs::create_dir_all(&source_root).unwrap();
let source = source_root.join("chair.fbx");
let mut bytes = committed_chair_bytes();
assert!(replace_equal_length(&mut bytes, b"textures/", b"test.fbm/") > 0);
fs::write(&source, bytes).unwrap();
write_textures(&source_root, "test.fbm");
copy_fbx_bundle(&source, &destination).unwrap();
assert!(destination
.join("test.fbm/painted_wooden_chair_02_diff_2k.jpg")
.is_file());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn traversal_reference_is_rejected_before_destination_changes() {
let root = temp_root("traversal");
let source_root = root.join("source");
let destination = root.join("destination");
fs::create_dir_all(&source_root).unwrap();
fs::create_dir_all(&destination).unwrap();
let source = source_root.join("chair.fbx");
let mut bytes = committed_chair_bytes();
assert!(replace_equal_length(&mut bytes, b"textures/", b"../evil//") > 0);
fs::write(&source, bytes).unwrap();
fs::write(destination.join("chair.fbx"), b"original").unwrap();
let error = copy_fbx_bundle(&source, &destination).unwrap_err();
assert!(error.contains("parent traversal"));
assert_eq!(
fbm_sidecar_dir(Path::new("/tmp/Character.fbx")),
Some(Path::new("/tmp/Character.fbm").to_path_buf())
fs::read(destination.join("chair.fbx")).unwrap(),
b"original"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn missing_bundle_preserves_existing_destination() {
let root = temp_root("missing");
let source_root = root.join("source");
let destination = root.join("destination");
fs::create_dir_all(&source_root).unwrap();
fs::create_dir_all(&destination).unwrap();
let source = source_root.join("chair.fbx");
fs::write(&source, committed_chair_bytes()).unwrap();
fs::write(destination.join("chair.fbx"), b"original").unwrap();
let error = copy_fbx_bundle(&source, &destination).unwrap_err();
assert!(error.contains("missing 3 required texture(s)"));
assert_eq!(
fs::read(destination.join("chair.fbx")).unwrap(),
b"original"
);
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn destination_symlink_escape_is_rejected_before_external_changes() {
use std::os::unix::fs::symlink;
let root = temp_root("destination-symlink");
let source_root = root.join("source");
let destination = root.join("destination");
let outside = root.join("outside");
fs::create_dir_all(&source_root).unwrap();
fs::create_dir_all(&destination).unwrap();
fs::create_dir_all(&outside).unwrap();
let source = source_root.join("chair.fbx");
fs::write(&source, committed_chair_bytes()).unwrap();
write_textures(&source_root, "textures");
symlink(&outside, destination.join("textures")).unwrap();
let error = copy_fbx_bundle(&source, &destination).unwrap_err();
assert!(error.contains("symbolic link"));
assert!(!destination.join("chair.fbx").exists());
assert!(!outside.join("painted_wooden_chair_02_diff_2k.jpg").exists());
fs::remove_dir_all(root).unwrap();
}
}

View File

@ -7,6 +7,7 @@ use bevy::gltf::GltfAssetLabel;
use bevy::prelude::*;
use bevy_ufbx::label::FbxAssetLabel;
use bevy_ufbx::mesh::group_faces_by_material;
use bevy_ufbx::texture::external_texture_paths;
use bevy_ufbx::utils::convert_matrix;
use serde::{Deserialize, Serialize};
@ -484,6 +485,7 @@ fn build_fbx_manifest(
ufbx::LoadOpts {
target_unit_meters: 1.0,
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
filename: ufbx::StringOpt::Ref(&record.path),
..Default::default()
},
)
@ -563,7 +565,7 @@ fn build_fbx_manifest(
path: record.path.clone(),
format,
fingerprint,
dependencies: fbx_dependencies(&record.path),
dependencies: fbx_dependencies(&record.path, &scene)?,
},
import,
metadata: StaticMeshMetadata {
@ -592,25 +594,26 @@ fn fbx_material_label(scene: &ufbx::Scene, element_id: u32) -> Option<String> {
.map(|index| FbxAssetLabel::Material(index).to_string())
}
fn fbx_dependencies(source_path: &str) -> Vec<String> {
fn fbx_dependencies(source_path: &str, scene: &ufbx::Scene) -> Result<Vec<String>, String> {
let path = Path::new(source_path);
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
return Vec::new();
};
let Some(parent) = path.parent() else {
return Vec::new();
};
let sidecar = parent.join(format!("{stem}.fbm"));
if !sidecar.is_dir() {
return Vec::new();
}
walkdir::WalkDir::new(sidecar)
.follow_links(false)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(|entry| entry.path().to_string_lossy().replace('\\', "/"))
.collect()
let parent = path.parent().unwrap_or_else(|| Path::new(""));
external_texture_paths(scene)
.map_err(|errors| {
format!(
"unsafe FBX texture reference(s) in {source_path}: {}",
errors
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("; ")
)
})
.map(|paths| {
paths
.into_iter()
.map(|relative| parent.join(relative).to_string_lossy().replace('\\', "/"))
.collect()
})
}
fn source_format(path: &str) -> Result<String, String> {
@ -823,4 +826,30 @@ mod tests {
assert!(manifest.parts.iter().any(|part| part.skinned));
assert!(renderer.slots.is_empty());
}
#[test]
fn committed_fbx_records_sibling_texture_dependencies() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets/models/painted_wooden_chair_02_2k.fbx")
.to_string_lossy()
.into_owned();
let record = AssetRecord {
id: AssetId::new(),
path,
label: "Painted Chair".into(),
kind_tag: "Model".into(),
source_fingerprint: None,
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
};
let manifest = build_static_mesh_manifest(&record).unwrap();
assert_eq!(manifest.source.dependencies.len(), 3);
assert!(manifest
.source
.dependencies
.iter()
.all(|path| path.contains("assets/models/textures/painted_wooden_chair_02_")));
}
}

View File

@ -1,7 +1,9 @@
//! Thumbnail cache for asset browser grid cells.
use std::collections::{HashMap, HashSet};
use std::path::Path;
use bevy::asset::LoadState;
use bevy::prelude::*;
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
use egui_phosphor_icons::icons;
@ -79,6 +81,9 @@ impl AssetThumbnailCache {
if self.failed.contains_key(&key) {
return;
}
if !self.texture_source_ready(&key, &path) {
return;
}
let handle: Handle<Image> = asset_server.load(asset_server_path(&path));
self.pending.insert(key, handle);
}
@ -161,7 +166,7 @@ impl AssetThumbnailCache {
{
return;
}
if !self.model_source_ready(&key, &model_path) {
if !self.source_material_ready(&key, &model_path) {
return;
}
if studio.enqueue_source(
@ -224,6 +229,32 @@ impl AssetThumbnailCache {
true
}
fn texture_source_ready(&mut self, key: &str, texture_path: &str) -> bool {
if Path::new(texture_path).is_file() {
return true;
}
self.mark_studio_failed(key, &format!("missing texture: {texture_path}"), false);
false
}
fn source_material_ready(&mut self, key: &str, model_path: &str) -> bool {
if !self.model_source_ready(key, model_path) {
return false;
}
if model_path
.rsplit_once('.')
.is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("fbx"))
{
if let Err(reason) =
crate::assets::import::validate_fbx_dependencies(std::path::Path::new(model_path))
{
self.mark_studio_failed(key, &reason, false);
return false;
}
}
true
}
pub(crate) fn complete_studio_thumbnail(
&mut self,
key: &str,
@ -268,6 +299,11 @@ impl AssetThumbnailCache {
.cloned()
.collect(),
failed_keys: self.failed.keys().cloned().collect(),
failure_reasons: self
.failed
.iter()
.map(|(key, failure)| (key.clone(), failure.reason.clone()))
.collect(),
}
}
}
@ -277,6 +313,14 @@ fn register_loaded_thumbnails(
mut cache: ResMut<AssetThumbnailCache>,
mut textures: ResMut<EguiUserTextures>,
) {
let failed: Vec<(String, String)> = cache
.pending
.iter()
.filter_map(|(key, handle)| match asset_server.load_state(handle.id()) {
LoadState::Failed(error) => Some((key.clone(), error.to_string())),
_ => None,
})
.collect();
let ready: Vec<(String, Handle<Image>)> = cache
.pending
.iter()
@ -284,6 +328,10 @@ fn register_loaded_thumbnails(
.map(|(key, handle)| (key.clone(), handle.clone()))
.collect();
for (key, error) in failed {
cache.mark_studio_failed(&key, &format!("texture load failed: {error}"), false);
}
for (key, handle) in ready {
cache.pending.remove(&key);
cache.failed.remove(&key);
@ -397,7 +445,11 @@ pub fn draw_asset_cell_with(
},
);
response
if let Some(reason) = failed {
response.on_hover_text(reason)
} else {
response
}
}
#[derive(Clone)]
@ -405,6 +457,7 @@ pub struct ThumbnailCacheSnapshot {
pub texture_ids: HashMap<String, egui::TextureId>,
pub pending_keys: Vec<String>,
pub failed_keys: Vec<String>,
pub failure_reasons: HashMap<String, String>,
}
impl ThumbnailCacheSnapshot {
@ -420,6 +473,10 @@ impl ThumbnailCacheSnapshot {
self.failed_keys.iter().any(|failed| failed == key)
}
pub fn failure_reason_for_key(&self, key: &str) -> Option<&str> {
self.failure_reasons.get(key).map(String::as_str)
}
pub fn texture_for(&self, asset: &EditorAsset) -> Option<egui::TextureId> {
self.texture_ids.get(&asset_cache_key(asset)).copied()
}
@ -435,6 +492,10 @@ impl ThumbnailCacheSnapshot {
.iter()
.any(|key| key == &asset_cache_key(asset))
}
pub fn failure_reason(&self, asset: &EditorAsset) -> Option<&str> {
self.failure_reason_for_key(&asset_cache_key(asset))
}
}
pub fn prefetch_folder_thumbnails(world: &mut World, folder: &str) {
@ -534,3 +595,53 @@ pub fn invalidate_on_catalog_refresh(world: &mut World) {
world.commands().entity(root).despawn();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_fbx_material_dependencies_are_one_stable_non_retryable_state() {
let model_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets/models/painted_wooden_chair_02_2k.fbx")
.to_string_lossy()
.into_owned();
let mut cache = AssetThumbnailCache::default();
assert!(cache.model_source_ready("model", &model_path));
assert!(!cache.source_material_ready("material", &model_path));
let first = cache.state("material");
assert!(!cache.source_material_ready("material", &model_path));
assert_eq!(cache.state("material"), first);
let Some(ThumbnailState::Failed { reason, retryable }) = first else {
panic!("missing FBX textures should produce one stable failure");
};
assert!(!retryable);
assert!(reason.starts_with("missing 3 FBX source texture(s):"));
assert!(cache.studio_pending.is_empty());
}
#[test]
fn missing_texture_is_one_stable_non_retryable_state() {
let path = std::env::temp_dir().join(format!(
"blacksite-missing-thumbnail-{}-{}.png",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let path = path.to_string_lossy().into_owned();
let mut cache = AssetThumbnailCache::default();
assert!(!cache.texture_source_ready("texture", &path));
let first = cache.state("texture");
assert!(!cache.texture_source_ready("texture", &path));
assert_eq!(cache.state("texture"), first);
let Some(ThumbnailState::Failed { reason, retryable }) = first else {
panic!("missing texture should produce one stable failure");
};
assert!(!retryable);
assert_eq!(reason, format!("missing texture: {path}"));
assert!(cache.pending.is_empty());
}
}

View File

@ -83,6 +83,72 @@ impl ThumbnailModelSource for FbxThumbnailSource {
}
}
pub(crate) fn spawn_fbx_mesh_subasset_preview(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
root: Entity,
catalog_path: &str,
mesh_label: &str,
) -> bool {
let Some(encoded_index) = mesh_label
.strip_prefix("Mesh")
.and_then(|index| index.parse::<usize>().ok())
else {
warn!("FBX thumbnail: unsupported mesh label {mesh_label}");
return false;
};
let node_index = encoded_index / 1000;
let material_index = encoded_index % 1000;
let Ok(bytes) = std::fs::read(catalog_path) else {
warn!("FBX thumbnail: could not read {catalog_path}");
return false;
};
let Ok(scene) = ufbx::load_memory(
&bytes,
ufbx::LoadOpts {
target_unit_meters: 1.0,
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
..Default::default()
},
) else {
warn!("FBX thumbnail: failed to parse {catalog_path}");
return false;
};
let Some(node) = scene.nodes.as_ref().get(node_index) else {
warn!("FBX thumbnail: mesh label {mesh_label} has no source node");
return false;
};
let Some(mesh_ref) = node.mesh.as_ref() else {
warn!("FBX thumbnail: mesh label {mesh_label} has no source mesh");
return false;
};
let Some(indices) = group_faces_by_material(mesh_ref.as_ref()).remove(&material_index) else {
warn!("FBX thumbnail: mesh label {mesh_label} has no material group");
return false;
};
if indices.is_empty() {
return false;
}
let mesh = meshes.add(build_mesh(mesh_ref.as_ref(), &indices));
let material = materials.add(StandardMaterial {
base_color: Color::srgb(0.72, 0.72, 0.75),
perceptual_roughness: 0.65,
..default()
});
let transform = Transform::from_matrix(convert_matrix(&node.geometry_to_world));
commands.entity(root).with_children(|parent| {
parent.spawn((
RenderLayers::layer(THUMBNAIL_LAYER),
Mesh3d(mesh),
MeshMaterial3d(material),
transform,
));
});
true
}
fn build_mesh(ufbx_mesh: &ufbx::Mesh, indices: &[u32]) -> Mesh {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,

View File

@ -3,6 +3,7 @@
mod fbx;
pub(crate) mod gltf;
pub(crate) use fbx::spawn_fbx_mesh_subasset_preview;
pub use fbx::FbxThumbnailSource;
pub use gltf::{gltf_skinned_primitive_labels, GltfThumbnailSource};

View File

@ -15,7 +15,7 @@ use shared::{material_from_desc, standard_material_asset_path, ModelRef};
use super::cache::AssetThumbnailCache;
use super::job::{ThumbnailJob, ThumbnailJobSource};
use super::sources::{source_for_extension, uses_scene_root};
use super::sources::{source_for_extension, spawn_fbx_mesh_subasset_preview, uses_scene_root};
use crate::assets::asset_server_path;
use crate::infra::EditorOnly;
@ -27,6 +27,7 @@ const SCENE_FRAME_PADDING: f32 = 1.35;
const MATERIAL_SPHERE_FRAME_PADDING: f32 = 1.28;
const MESH_WARMUP_FRAMES: u8 = 12;
const POST_ATTACH_FRAMES: u8 = 8;
const FIRST_RENDER_WARMUP_FRAMES: u8 = 120;
const RENDER_FRAMES: u8 = 3;
const COOLDOWN_FRAMES: u8 = 2;
const LOAD_TIMEOUT_FRAMES: u32 = 240;
@ -51,6 +52,7 @@ pub struct ThumbnailStudio {
queue: VecDeque<ThumbnailJob>,
active: Option<ActiveModelThumbnail>,
cooldown_frames: u8,
render_pipeline_warmed: bool,
}
struct ActiveModelThumbnail {
@ -209,6 +211,7 @@ fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Imag
queue: VecDeque::new(),
active: None,
cooldown_frames: 0,
render_pipeline_warmed: false,
});
}
@ -288,7 +291,7 @@ fn process_thumbnail_studio(
camera.is_active = true;
}
active.camera_active = true;
active.frames_remaining = RENDER_FRAMES;
active.frames_remaining = capture_frames(studio.render_pipeline_warmed);
}
studio.active = Some(active);
return;
@ -299,6 +302,7 @@ fn process_thumbnail_studio(
active.frames_remaining -= 1;
studio.active = Some(active);
} else {
studio.render_pipeline_warmed = true;
cache.complete_studio_thumbnail(
&active.cache_key,
active.render_image.clone(),
@ -426,12 +430,15 @@ fn spawn_thumbnail_job_content(
material_label,
} => spawn_mesh_subasset_preview(
commands,
mesh_storage,
materials,
asset_server,
root,
model_path,
mesh_label,
material_label.as_deref(),
MeshSubassetPreviewSpec {
model_path,
mesh_label,
material_label: material_label.as_deref(),
},
),
ThumbnailJobSource::SourceMaterial {
model_path,
@ -452,18 +459,38 @@ fn spawn_thumbnail_job_content(
}
}
struct MeshSubassetPreviewSpec<'a> {
model_path: &'a str,
mesh_label: &'a str,
material_label: Option<&'a str>,
}
fn spawn_mesh_subasset_preview(
commands: &mut Commands,
mesh_storage: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
asset_server: &AssetServer,
root: Entity,
model_path: &str,
mesh_label: &str,
material_label: Option<&str>,
preview: MeshSubassetPreviewSpec<'_>,
) -> bool {
let source_path = asset_server_path(model_path);
let mesh: Handle<Mesh> = asset_server.load(labeled_asset_path(&source_path, mesh_label));
let material = if let Some(material_label) = material_label {
if preview
.model_path
.rsplit_once('.')
.is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("fbx"))
{
return spawn_fbx_mesh_subasset_preview(
commands,
mesh_storage,
materials,
root,
preview.model_path,
preview.mesh_label,
);
}
let source_path = asset_server_path(preview.model_path);
let mesh: Handle<Mesh> =
asset_server.load(labeled_asset_path(&source_path, preview.mesh_label));
let material = if let Some(material_label) = preview.material_label {
asset_server.load(standard_material_asset_path(&source_path, material_label))
} else {
materials.add(StandardMaterial {
@ -509,6 +536,14 @@ fn studio_render_image() -> Image {
Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba16Float, None)
}
fn capture_frames(render_pipeline_warmed: bool) -> u8 {
if render_pipeline_warmed {
RENDER_FRAMES
} else {
FIRST_RENDER_WARMUP_FRAMES
}
}
fn finish_active_thumbnail(
commands: &mut Commands,
studio: &mut ThumbnailStudio,
@ -734,4 +769,10 @@ mod tests {
assert!(material_distance < scene_distance * 0.6);
assert!((0.75..0.82).contains(&projected_radius_fraction));
}
#[test]
fn first_capture_keeps_camera_active_for_render_pipeline_warmup() {
assert_eq!(capture_frames(true), RENDER_FRAMES);
assert!(capture_frames(false) > capture_frames(true));
}
}

View File

@ -49,7 +49,9 @@ use crate::assets::{
ThumbnailCacheSnapshot, ThumbnailStudio,
};
use crate::ui::helpers::asset_label;
use crate::ui::theme::{panel_heading, ACCENT, BORDER, ELEVATED_BG, TEXT, TEXT_DIM, WIDGET_BG};
use crate::ui::theme::{
panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, WARNING, WIDGET_BG,
};
use crate::ui::widgets::{icon_button_small, tool_button};
const FOOTER_HEIGHT: f32 = 64.0;
@ -375,6 +377,10 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
return Vec::new();
};
let mut embedded = Vec::new();
let use_source_materials = matches!(
record.import_settings.material_policy,
MaterialImportPolicy::SourceMaterials
);
let static_manifest = record
.import_settings
.static_mesh_manifest_path
@ -446,7 +452,11 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
},
label,
kind: AssetSubAssetKind::Material,
detail: "Embedded source material".to_string(),
detail: if use_source_materials {
"Embedded source material".to_string()
} else {
"Source material disabled by Authoring Override".to_string()
},
texture_path: None,
thumbnail_key: subasset_thumbnail_key(
parent_path,
@ -1129,14 +1139,14 @@ fn draw_asset_grid_item(
let selection = &row.selection;
let asset = &row.asset;
let is_selected = selected.as_ref() == Some(selection);
let failed = cache_snapshot.is_failed(asset);
let failure = cache_snapshot.failure_reason(asset);
let has_children = has_embedded_assets(world, asset);
let response = draw_asset_cell_with(
ui,
asset,
cache_snapshot.texture_for(asset),
cache_snapshot.is_pending(asset),
failed.then_some("failed"),
failure,
is_selected,
thumbnail_size,
);
@ -1190,7 +1200,7 @@ fn draw_asset_grid_item(
}
}
if failed && response.double_clicked() {
if failure.is_some() && response.double_clicked() {
let key = crate::assets::asset_cache_key(asset);
world
.resource_mut::<crate::assets::AssetThumbnailCache>()
@ -1349,7 +1359,8 @@ fn draw_embedded_asset_cell(
.and_then(|path| thumbnail_for_path(world, cache_snapshot, path))
});
let pending = cache_snapshot.is_pending_key(&embedded.thumbnail_key);
let failed = cache_snapshot.is_failed_key(&embedded.thumbnail_key);
let failure = cache_snapshot.failure_reason_for_key(&embedded.thumbnail_key);
let failed = failure.is_some();
let thumb_size = thumbnail_size.clamp(44.0, 64.0);
let cell_size = egui::vec2(104.0, thumb_size + 50.0);
let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag());
@ -1360,6 +1371,11 @@ fn draw_embedded_asset_cell(
} else {
response
};
let response = if let Some(reason) = failure {
response.on_hover_text(reason)
} else {
response
};
let fill = if selected {
crate::ui::theme::SELECTION_BG_MUTED
} else if response.hovered() {
@ -1883,9 +1899,53 @@ pub(crate) fn top_level_asset_details_panel(
}
if !record.dependencies.is_empty() {
ui.separator();
ui.label(panel_heading("Dependencies"));
let missing_count = record
.dependencies
.iter()
.filter(|dependency| !dependency_path(dependency).is_file())
.count();
ui.horizontal_wrapped(|ui| {
ui.label(panel_heading("Dependencies"));
if missing_count > 0 {
let optional = matches!(
record.import_settings.material_policy,
MaterialImportPolicy::AuthoringOverride
);
ui.small(
egui::RichText::new(if optional {
format!("{missing_count} missing | override")
} else {
format!("{missing_count} missing")
})
.color(if optional {
WARNING
} else {
ERROR
}),
);
}
});
for dep in &record.dependencies {
ui.add(egui::Label::new(dep).truncate());
let exists = dependency_path(dep).is_file();
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(if exists {
icons::CHECK_CIRCLE.as_str()
} else {
icons::WARNING_CIRCLE.as_str()
})
.font(egui::FontId::new(
12.0,
egui::FontFamily::Name(PHOSPHOR.into()),
))
.color(if exists {
SUCCESS
} else {
WARNING
}),
);
ui.add(egui::Label::new(dep).truncate()).on_hover_text(dep);
});
}
}
}
@ -2032,12 +2092,12 @@ fn subasset_details_panel(
}
}
AssetSubAssetKind::Material => {
ui.small(
egui::RichText::new(
"Source materials are assigned through static mesh renderer slots.",
)
.color(TEXT_DIM),
);
let status = if embedded.detail.contains("Authoring Override") {
"Authoring Override is active; this source material is not assigned."
} else {
"Source material is assigned through static mesh renderer slots."
};
ui.small(egui::RichText::new(status).color(TEXT_DIM));
}
AssetSubAssetKind::Skeleton => {
ui.small(
@ -2072,6 +2132,15 @@ fn subasset_details_panel(
}
}
fn dependency_path(reference: &str) -> PathBuf {
let path = Path::new(reference);
if path.starts_with("assets") {
path.to_path_buf()
} else {
Path::new("assets").join(path)
}
}
fn asset_action_buttons(
world: &mut World,
ui: &mut egui::Ui,

View File

@ -410,15 +410,19 @@ fn validate_asset_registry(
&format!("registry_{}", record.kind_tag.to_lowercase()),
&record.path,
);
for dependency in &record.dependencies {
add_reference(
project_root,
report,
&record.path,
None,
"import_dependency",
dependency,
);
let generated_static_manifest_owns_dependencies = record.kind_tag == "Model"
&& record.import_settings.static_mesh_manifest_path.is_some();
if !generated_static_manifest_owns_dependencies {
for dependency in &record.dependencies {
add_reference(
project_root,
report,
&record.path,
None,
"import_dependency",
dependency,
);
}
}
if let Some(manifest) = record.import_settings.static_mesh_manifest_path.as_deref() {
add_reference(
@ -791,6 +795,8 @@ struct StaticMeshManifestView {
asset_id: String,
source: StaticMeshSourceView,
#[serde(default)]
import: StaticMeshImportView,
#[serde(default)]
warnings: Vec<String>,
}
@ -798,11 +804,26 @@ struct StaticMeshManifestView {
struct StaticMeshSourceView {
path: String,
#[serde(default)]
format: String,
#[serde(default)]
fingerprint: AssetSourceFingerprint,
#[serde(default)]
dependencies: Vec<String>,
}
#[derive(Default, Deserialize)]
struct StaticMeshImportView {
#[serde(default)]
material_policy: StaticMeshMaterialPolicyView,
}
#[derive(Default, Deserialize, PartialEq, Eq)]
enum StaticMeshMaterialPolicyView {
#[default]
SourceMaterials,
AuthoringOverride,
}
const STATIC_MESH_MANIFEST_SCHEMA_VERSION: u32 = 4;
fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValidationReport) {
@ -855,16 +876,15 @@ fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValid
"static_mesh.manifest_stale",
"static mesh source",
);
for dependency in manifest.source.dependencies {
add_reference(
project_root,
report,
&source,
None,
"import_dependency",
&dependency,
);
}
let optional_source_textures = manifest.source.format.eq_ignore_ascii_case("fbx")
&& manifest.import.material_policy == StaticMeshMaterialPolicyView::AuthoringOverride;
add_import_dependencies(
project_root,
report,
&source,
&manifest.source.dependencies,
optional_source_textures,
);
for warning in manifest.warnings {
report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Warning,
@ -881,6 +901,79 @@ fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValid
}
}
fn add_import_dependencies(
project_root: &Path,
report: &mut ProjectValidationReport,
owner_path: &str,
dependencies: &[String],
optional_source_textures: bool,
) {
let mut missing = Vec::new();
for dependency in dependencies {
match resolve_reference(project_root, dependency) {
Some(path) if path.is_file() => add_reference(
project_root,
report,
owner_path,
None,
"import_dependency",
dependency,
),
Some(_) => {
report.dependencies.push(ProjectDependency {
owner_path: owner_path.to_string(),
owner_actor_id: None,
kind: "import_dependency".into(),
reference: dependency.clone(),
});
missing.push(dependency.clone());
}
None => add_reference(
project_root,
report,
owner_path,
None,
"import_dependency",
dependency,
),
}
}
if missing.is_empty() {
return;
}
missing.sort();
missing.dedup();
report.findings.push(ProjectValidationFinding {
severity: if optional_source_textures {
ValidationSeverity::Info
} else {
ValidationSeverity::Error
},
code: "import.external_texture_missing".into(),
source_path: owner_path.to_string(),
owner_actor_id: None,
reference: Some(missing.join(", ")),
message: if optional_source_textures {
format!(
"{} FBX source texture(s) are unavailable and intentionally ignored by Authoring Override",
missing.len()
)
} else {
format!(
"{} required imported texture dependency file(s) do not exist",
missing.len()
)
},
repair: if optional_source_textures {
"No action is required while Authoring Override remains active; restore the texture bundle before selecting Source Materials."
.into()
} else {
"Restore the referenced texture bundle and reimport, or select Authoring Override for a deliberately untextured asset."
.into()
},
});
}
fn validate_animation_manifests(
project_root: &Path,
report: &mut ProjectValidationReport,
@ -3192,6 +3285,118 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
fn write_fbx_dependency_fixture(
root: &Path,
material_policy: &str,
dependencies: &[&str],
) -> PathBuf {
let source_path = "assets/models/chair.fbx";
std::fs::create_dir_all(root.join("assets/models")).unwrap();
std::fs::write(root.join(source_path), b"model").unwrap();
std::fs::write(
root.join("assets/levels/main.scn.ron"),
"(schema_version:4,resources:{},entities:{})",
)
.unwrap();
let artifact = root.join("assets/meshes/generated/chair.static_mesh.ron");
std::fs::create_dir_all(artifact.parent().unwrap()).unwrap();
let dependencies = dependencies
.iter()
.map(|path| format!("{path:?}"))
.collect::<Vec<_>>()
.join(",");
std::fs::write(
&artifact,
format!(
"(schema_version:{STATIC_MESH_MANIFEST_SCHEMA_VERSION},asset_id:\"chair\",source:(path:\"{source_path}\",format:\"fbx\",fingerprint:(byte_len:5,content_hash:\"{}\"),dependencies:[{dependencies}]),import:(material_policy:{material_policy}),warnings:[])\n",
blake3::hash(b"model").to_hex()
),
)
.unwrap();
artifact
}
#[test]
fn source_materials_consolidate_missing_fbx_textures_as_one_blocker() {
let root = fixture_root();
write_fbx_dependency_fixture(
&root,
"SourceMaterials",
&[
"assets/models/textures/chair_diff.jpg",
"assets/models/textures/chair_normal.exr",
"assets/models/textures/chair_rough.exr",
],
);
let report = validate_project(&root);
let findings = report
.findings
.iter()
.filter(|finding| finding.code == "import.external_texture_missing")
.collect::<Vec<_>>();
assert_eq!(findings.len(), 1, "{:?}", report.findings);
assert_eq!(findings[0].severity, ValidationSeverity::Error);
assert!(findings[0].message.starts_with("3 required"));
assert_eq!(
report
.dependencies
.iter()
.filter(|dependency| dependency.kind == "import_dependency")
.count(),
3
);
assert!(!report.is_release_ready());
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn authoring_override_reports_missing_fbx_textures_without_blocking_or_writing() {
let root = fixture_root();
let artifact = write_fbx_dependency_fixture(
&root,
"AuthoringOverride",
&[
"assets/models/textures/chair_diff.jpg",
"assets/models/textures/chair_normal.exr",
"assets/models/textures/chair_rough.exr",
],
);
let before = std::fs::read(&artifact).unwrap();
let report = validate_project(&root);
let finding = report
.findings
.iter()
.find(|finding| finding.code == "import.external_texture_missing")
.expect("Authoring Override should retain one actionable dependency state");
assert_eq!(finding.severity, ValidationSeverity::Info);
assert!(finding.message.contains("Authoring Override"));
assert!(report.is_release_ready(), "{:?}", report.findings);
assert_eq!(std::fs::read(&artifact).unwrap(), before);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn authoring_override_does_not_downgrade_unsafe_fbx_texture_paths() {
let root = fixture_root();
write_fbx_dependency_fixture(
&root,
"AuthoringOverride",
&["assets/models/../outside/chair.jpg"],
);
let report = validate_project(&root);
assert!(report.findings.iter().any(|finding| {
finding.code == "asset.unsafe_path" && finding.severity == ValidationSeverity::Error
}));
assert!(!report.is_release_ready());
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn same_size_static_mesh_source_change_invalidates_content_hash() {
let root = fixture_root();

View File

@ -58,6 +58,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0041](adr/0041-transactional-editor-physics-placement.md) | Paused editor physics ownership and transactional gravity placement |
| [0042](adr/0042-guarded-editor-shutdown-and-document-savepoints.md) | Guarded native editor exit and canonical per-document clean checkpoints |
| [0043](adr/0043-content-addressed-import-fingerprints.md) | Content-addressed imported-source identity and byte-preserving artifact publication |
| [0044](adr/0044-sandboxed-fbx-external-texture-dependencies.md) | Sandboxed FBX texture bundles, validation policy, and deduplicated loading |
## Editor framework

View File

@ -0,0 +1,68 @@
# ADR 0044: Sandboxed FBX External Texture Dependencies
## Status
Accepted
## Context
FBX texture references may use sibling folders such as `textures/`, exporter-created `.fbm/`
folders, absolute workstation paths, Windows separators, or repeated ufbx texture elements that
resolve to the same file. Blacksite previously discovered only files already present in a sibling
`{model}.fbm/` directory. The committed painted-chair FBX instead declares three files under
`textures/`; none were committed, so source-material thumbnails queued the same missing Bevy image
paths more than once.
Import, manifest generation, project validation, editor previews, and the runtime loader must agree
on one dependency graph. That graph also crosses a security boundary: an imported source must not
read or copy a parent-traversing path or an arbitrary absolute path outside its bundle. A missing
source texture is required when Source Materials is active, but it can be an intentional authoring
condition when an asset explicitly uses Authoring Override.
## Decision
The local `bevy_ufbx` compatibility crate owns FBX external-texture path discovery and
normalization. It converts separators to `/`, preserves safe sibling and `.fbm/` layouts,
deduplicates normalized paths, and rejects parent traversal, asset-path syntax, and absolute paths.
An absolute exporter path may be portably rebased only when it contains a `.fbm/` suffix; only that
sidecar-relative suffix is retained.
The FBX loader performs a discovery parse, drops the non-`Send` ufbx scene, reads every unique safe
dependency through Bevy's `LoadContext`, and then performs normal synchronous scene processing.
External images become FBX labeled assets from those bytes. Repeated texture elements share one
image handle. Missing, rejected, or undecodable references are skipped with one consolidated
loader warning instead of deferred asset-server requests.
Static-mesh manifests are the authoritative model import dependency graph. Their existing
`source.dependencies` list records every parsed FBX external texture, including unavailable files.
Project validation checks that list without loading native assets. Missing FBX textures are a
blocking consolidated finding under Source Materials and an informational consolidated finding
under Authoring Override. Unsafe paths remain blocking under either policy. Model registry records
mirror the list for editor details, but validation does not duplicate it when a generated static
manifest exists.
File -> Import Assets treats an FBX plus its referenced textures as one bundle. The importer
preflights every source path and canonical containment before touching the project, rejects
destination paths with symlinked ancestors, stages only the referenced files, preserves their
normalized relative layout, backs up overwritten destinations, and rolls back a partial publish.
Unreferenced `.fbm` contents are not copied.
The Asset Browser renders FBX model and mesh thumbnails through the neutral direct mesh path.
Source-material thumbnails preflight the authoritative dependency resolver first and cache one
non-retryable actionable failure when required textures are unavailable. Dependency rows expose
present/missing state. The committed painted-chair fixture uses Authoring Override deliberately and
remains untextured; its three source paths stay visible in manifests, validation, and asset details.
## Consequences
- FBX sibling `textures/` and `.fbm/` bundles import and validate with identical normalized paths.
- Missing texture files are visible before native loading and cannot create repeated Bevy
asset-server errors.
- Source Materials fails release validation when its FBX textures are absent. Authoring Override
remains release-valid but retains an informational dependency finding.
- Absolute exporter paths, traversal attempts, and destination symlink redirects cannot read or
copy arbitrary host files.
- FBX files with external textures are parsed twice during loading so no non-`Send` ufbx scene
crosses an async read. Files without external textures retain a single parse.
- External images are labeled children of the FBX and reload the owning FBX through loader
dependency tracking. Per-image import metadata is not yet exposed.

View File

@ -65,7 +65,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| `assets/materials.rs` / `ui/material_library.rs` / `viewport/material_drop.rs` / `shared::renderer_material` | Shared material discovery, docked catalog/usage UI, exact reversible surface drops, stable renderer slots, and draw binding | material-system.md, ADR 0035 |
| `blacksite_surface` / `game_hot::rendering::solari` / `third_party/bevy_solari` | Surface ABI packing, raster composition, Solari evaluator dispatch, and deformation eligibility | material-system.md, rendering.md, ADR 0036 |
| `assets/fingerprint.rs` / `shared::AssetSourceFingerprint` | Content-addressed imported-source identity and byte-preserving registry/manifest publication | ADR 0043, evaluations/deterministic-asset-fingerprints/ |
| `assets/` | Catalog, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027 |
| `assets/` | Catalog, transactional import bundles, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027, ADR 0044 |
| `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 |
| `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 |
| `project/collaboration.rs` | Guarded authored writes, asynchronous Git status, and optional ownership providers | collaborative-file-safety.md, ADR 0037 |
@ -92,7 +92,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **Dedicated egui `Camera2d`** at full window — never attach `PrimaryEguiContext` to a viewport-cropped 3D camera (NaN layout panic).
- **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping.
- **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera.
- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions; narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. **Materials** folder scans `assets/materials/*.ron`, renders material thumbnails on a sphere using `MaterialDesc`, and exposes shader-schema-driven parameters/textures in the details editor; **Shaders** holds shader schema RON files. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips under `assets/audio/`, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references that retain a runtime source path. glTF/GLB/FBX rows can expand into a shelf of normalized embedded mesh, material, and texture subassets with independent generated thumbnails. Mesh subassets can be selected, dragged into the viewport, or placed from details/context menus; material subassets render source-material spheres; texture subassets can be applied to the selected actor. Model import settings are staged with **Apply** / **Revert**, asset context menus can regenerate thumbnails, material asset details edit shared `MaterialAsset` fields, and file asset deletion moves sources/generated artifacts into `assets/.trash/`. Before thumbnail loading, glTF sources preflight local external buffers/images and show a stable non-retryable failure state when a dependency is missing instead of repeatedly invoking the asset loader.
- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions; narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. **Materials** folder scans `assets/materials/*.ron`, renders material thumbnails on a sphere using `MaterialDesc`, and exposes shader-schema-driven parameters/textures in the details editor; **Shaders** holds shader schema RON files. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips under `assets/audio/`, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references that retain a runtime source path. glTF/GLB/FBX rows can expand into a shelf of normalized embedded mesh, material, and texture subassets with independent generated thumbnails. Mesh subassets can be selected, dragged into the viewport, or placed from details/context menus; material subassets render source-material spheres; texture subassets can be applied to the selected actor. Model import settings are staged with **Apply** / **Revert**, asset context menus can regenerate thumbnails, material asset details edit shared `MaterialAsset` fields, and file asset deletion moves sources/generated artifacts into `assets/.trash/`. Before thumbnail loading, glTF sources preflight local external buffers/images. FBX source-material previews use the same sandboxed resolver as import and validation, expose one cached hover-visible dependency failure, and never enqueue known-missing texture paths; neutral model/mesh previews remain available. Asset details mark present/missing dependencies and identify missing textures intentionally ignored by Authoring Override. FBX bundle import preserves referenced sibling or `.fbm/` layout transactionally. See [ADR 0044](../adr/0044-sandboxed-fbx-external-texture-dependencies.md).
- **Imported-source fingerprints** use exact byte length plus lowercase BLAKE3 for model, texture, and audio registry records and for generated static-mesh/animation manifests. Filesystem timestamps are scan hints only; equivalent refresh preserves the exact committed RON bytes and registry publication order is normalized by project path. See [ADR 0043](../adr/0043-content-addressed-import-fingerprints.md) and the [acceptance record](evaluations/deterministic-asset-fingerprints/).
- **Material Library** is a dockable bottom-panel catalog for cross-folder Material and direct-base Material Instance authoring. It provides search, type and scene-usage filters, grid/list thumbnails, dependency health, usage counts, creation, guarded details editing, and first-class drag sources. Viewport drops resolve an exact renderer slot, primitive, or brush face under the pointer, preview transiently, reject incompatible/read-only targets explicitly, restore on target change/cancel, and commit one typed undo step on release.
- **Static/skinned renderer split** — model drag/drop uses normalized artifacts under `assets/meshes/generated/`. Unrigged, non-animated sources create `ActorKind::StaticMesh + StaticMeshRenderer`; skin-bound or animated sources create `ActorKind::SkinnedMesh + SkinnedMeshRenderer` and preserve the imported hierarchy. Static slots never contain marked skinned primitives or geometry from animated sources. `SceneInstance` placement keeps `ImportedModel + ModelRef` for generic full-source scenes. See [ADR 0033](../adr/0033-dedicated-skinned-mesh-renderer.md).

View File

@ -220,7 +220,11 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
## Model import (glTF + FBX)
- **Import:** File → Import Assets copies glTF/GLB/FBX into `assets/models/`. FBX imports also copy a sibling `{name}.fbm/` folder when present (embedded textures).
- **Import:** File -> Import Assets copies glTF/GLB/FBX into `assets/models/`. An FBX import
parses every safe external texture reference, including sibling `textures/` and `.fbm/` layouts,
then stages and publishes the complete referenced bundle transactionally. Absolute non-sidecar
paths, parent traversal, and dependencies that canonically escape the source folder are rejected
before project files change; unreferenced sidecar contents are not copied.
- **Processing:** the asset registry generates normalized model manifests under
`assets/meshes/generated/`. The manifests store stable part IDs, glTF/FBX mesh/material subasset
labels, whether each part is skin-bound, source metadata, dependencies, and import settings.
@ -229,13 +233,22 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
equivalent refresh, so checkout mtimes and formatting do not dirty project content. See
[ADR 0043](../adr/0043-content-addressed-import-fingerprints.md). These artifacts are hidden from
the Asset Browser catalog.
- **FBX dependencies:** the local `bevy_ufbx` resolver owns separator normalization, safe `.fbm/`
rebasing, deduplication, and sandbox rejection. Static-mesh manifests record every declared
external texture even when it is absent. Project validation reports one blocking finding for
missing Source Materials textures, or one informational finding when Authoring Override
deliberately leaves the model untextured. The runtime loader reads unique dependencies through
`LoadContext` and creates labeled images directly, so missing paths cannot fan out into repeated
asset-server errors. See [ADR 0044](../adr/0044-sandboxed-fbx-external-texture-dependencies.md).
- **Browser subassets:** model rows can expand into a content shelf backed by the generated
manifest. Unrigged mesh subassets place as independent `StaticMeshRenderer` actors. A skinned
subasset places its owning source through `SkinnedMeshRenderer`, retaining joints and inverse bind
poses. Material subassets expose source defaults, and texture dependencies can be applied to
selected actors. The thumbnail studio renders each generated model/subasset/material preview into
its own render target before registering it in the cache so later thumbnails cannot overwrite
earlier cache entries.
earlier cache entries. FBX model and mesh previews use neutral direct geometry; source-material
previews preflight external textures and retain one stable hover-visible failure reason instead
of enqueueing a known-missing path.
- **Default placement:** **Renderable Asset (Auto)** creates
`ActorKind::StaticMesh + StaticMeshRenderer` for unrigged, non-animated models and
`ActorKind::SkinnedMesh + SkinnedMeshRenderer` when any part is skin-bound or the source contains

View File

@ -120,8 +120,8 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
| Item | Status | Notes |
|------|--------|-------|
| `asset_db.rs` registry | Done | Stable UUIDs by path/content, deterministic model/texture/audio BLAKE3 fingerprints, normalized publication order, and import settings in details |
| Model import formats | Done | glTF/GLB + FBX (binary); `.fbm` sidecar copy on import |
| Thumbnails | Done | Textures via asset load; glTF albedo fast-path; FBX/untextured models via offscreen studio |
| Model import formats | Done | glTF/GLB + binary FBX; sandboxed `textures/`/`.fbm/` discovery and transactional referenced-bundle copy |
| Thumbnails | Done | Textures via asset load; glTF albedo fast-path; direct neutral FBX geometry; preflighted source-material dependency state |
| Import settings per asset | Done | Scale/collider/LOD in registry + details pane |
| Prefab workflow | Done | Shared stable nested property/component/structural overrides, recursive validation, committed variant fixtures, transactional source Apply, conflict recovery, and undoable outer unpack/recursive local conversion passed headless, packaged-runtime, and live editor acceptance in Gitea #43 |
| Curated editor regression samples | Done | Versioned five-area manifest, dedicated brush/material labs, strengthened terrain/physics/rendering fixtures, **File > Open Sample**, typed scene coverage, and `validate-samples` release gate in Gitea #32 |

View File

@ -25,6 +25,7 @@ pub mod material;
pub mod mesh;
pub mod node;
pub mod scene;
pub mod texture;
pub mod types;
pub mod utils;

View File

@ -5,11 +5,12 @@ use crate::material::process_materials;
use crate::mesh::process_meshes;
use crate::node::{process_nodes, process_skins};
use crate::scene::build_scene;
use crate::texture::external_texture_path_report;
use crate::types::{Fbx, FbxAxisSystem, FbxMeta, Handedness};
use bevy::asset::{io::Reader, AssetLoader, LoadContext, RenderAssetUsages};
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
/// Settings for FBX file loading.
///
@ -74,77 +75,128 @@ impl AssetLoader for FbxLoader {
return Err(FbxError::InvalidData("FBX file too small".to_string()));
}
// Parse with ufbx
// Provide the asset path as a filename hint so ufbx can compute relative_filename
let filename_hint = load_context.path().path().to_string_lossy().to_string();
let root = ufbx::load_memory(
&bytes,
ufbx::LoadOpts {
target_unit_meters: 1.0,
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
filename: ufbx::StringOpt::Ref(&filename_hint),
..Default::default()
},
)
.map_err(|e| FbxError::UfbxError(format!("{:?}", e)))?;
let scene: &ufbx::Scene = &*root;
let root = parse_fbx(&bytes, &filename_hint)?;
let texture_report = external_texture_path_report(&root);
if texture_report.paths.is_empty() {
if !texture_report.errors.is_empty() {
warn_texture_failures(
load_context.path(),
texture_report.errors.iter().map(ToString::to_string),
);
}
return process_scene(&root, settings, load_context, &HashMap::new());
}
// Process meshes
let (meshes, named_meshes, mesh_transforms, mesh_material_info) =
process_meshes(scene, settings, load_context)?;
// `ufbx::Scene` is not Send, so finish discovery and drop it before awaiting asset I/O.
drop(root);
let mut failures = texture_report
.errors
.iter()
.map(ToString::to_string)
.collect::<BTreeSet<_>>();
let mut external_texture_bytes = HashMap::new();
let asset_path = load_context.path().clone();
for relative_path in texture_report.paths {
let resolved_path = match asset_path.resolve_embed_str(&relative_path) {
Ok(path) => path,
Err(error) => {
failures.insert(format!("{relative_path} ({error})"));
continue;
}
};
match load_context.read_asset_bytes(resolved_path.clone()).await {
Ok(bytes) => {
external_texture_bytes.insert(relative_path, bytes);
}
Err(error) => {
failures.insert(format!("{resolved_path} ({error})"));
}
}
}
if !failures.is_empty() {
warn_texture_failures(load_context.path(), failures);
}
// Process materials and textures
let (materials, named_materials) = if !settings.load_materials.is_empty() {
process_materials(scene, settings, load_context)?
} else {
(Vec::new(), HashMap::new())
};
// Process nodes and hierarchy
let (nodes, named_nodes, node_map) = process_nodes(scene, &meshes, load_context)?;
// Process skins
let (skins, named_skins) = process_skins(scene, &node_map, load_context)?;
// Build scene
let scene_handle = build_scene(
scene,
&meshes,
&materials,
&named_materials,
&mesh_transforms,
&mesh_material_info,
settings,
load_context,
)?;
// Extract metadata
let metadata = FbxMeta::default();
// Build final FBX asset
Ok(Fbx {
scenes: vec![scene_handle.clone()],
named_scenes: HashMap::new(),
meshes,
named_meshes,
materials,
named_materials,
nodes,
named_nodes,
skins,
named_skins,
default_scene: Some(scene_handle),
axis_system: FbxAxisSystem {
up: Vec3::Y,
front: Vec3::Z,
handedness: Handedness::Right,
},
unit_scale: 1.0,
metadata,
})
let root = parse_fbx(&bytes, &filename_hint)?;
process_scene(&root, settings, load_context, &external_texture_bytes)
}
fn extensions(&self) -> &[&str] {
&["fbx"]
}
}
fn parse_fbx(bytes: &[u8], filename_hint: &str) -> Result<ufbx::SceneRoot, FbxError> {
ufbx::load_memory(
bytes,
ufbx::LoadOpts {
target_unit_meters: 1.0,
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
filename: ufbx::StringOpt::Ref(filename_hint),
..Default::default()
},
)
.map_err(|error| FbxError::UfbxError(format!("{error:?}")))
}
fn process_scene(
scene: &ufbx::Scene,
settings: &FbxLoaderSettings,
load_context: &mut LoadContext<'_>,
external_texture_bytes: &HashMap<String, Vec<u8>>,
) -> Result<Fbx, FbxError> {
let (meshes, named_meshes, mesh_transforms, mesh_material_info) =
process_meshes(scene, settings, load_context)?;
let (materials, named_materials) = if !settings.load_materials.is_empty() {
process_materials(scene, settings, load_context, external_texture_bytes)?
} else {
(Vec::new(), HashMap::new())
};
let (nodes, named_nodes, node_map) = process_nodes(scene, &meshes, load_context)?;
let (skins, named_skins) = process_skins(scene, &node_map, load_context)?;
let scene_handle = build_scene(
scene,
&meshes,
&materials,
&named_materials,
&mesh_transforms,
&mesh_material_info,
settings,
load_context,
)?;
Ok(Fbx {
scenes: vec![scene_handle.clone()],
named_scenes: HashMap::new(),
meshes,
named_meshes,
materials,
named_materials,
nodes,
named_nodes,
skins,
named_skins,
default_scene: Some(scene_handle),
axis_system: FbxAxisSystem {
up: Vec3::Y,
front: Vec3::Z,
handedness: Handedness::Right,
},
unit_scale: 1.0,
metadata: FbxMeta::default(),
})
}
fn warn_texture_failures(
asset_path: &bevy::asset::AssetPath<'_>,
failures: impl IntoIterator<Item = String>,
) {
let failures = failures.into_iter().collect::<Vec<_>>();
warn!(
"bevy_ufbx: skipped {} unavailable external texture reference(s) for {}: {}",
failures.len(),
asset_path,
failures.join("; ")
);
}

View File

@ -3,19 +3,21 @@
use crate::error::FbxError;
use crate::label::FbxAssetLabel;
use crate::loader::FbxLoaderSettings;
use crate::texture::external_texture_reference;
use crate::utils::convert_texture_uv_transform;
use bevy::asset::{Handle, LoadContext};
use bevy::pbr::StandardMaterial;
use bevy::prelude::*;
use bevy_material::AlphaMode;
use bevy_image::{CompressedImageFormats, ImageSampler, ImageType};
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
/// Process all materials from the FBX scene.
pub fn process_materials(
scene: &ufbx::Scene,
settings: &FbxLoaderSettings,
load_context: &mut LoadContext,
load_context: &mut LoadContext<'_>,
external_texture_bytes: &HashMap<String, Vec<u8>>,
) -> Result<
(
Vec<Handle<StandardMaterial>>,
@ -25,7 +27,12 @@ pub fn process_materials(
> {
let mut materials = Vec::new();
let mut named_materials = HashMap::new();
let texture_handles = process_textures(scene, settings, load_context)?;
let texture_handles = process_textures(
scene,
settings,
load_context,
external_texture_bytes,
)?;
for (index, ufbx_material) in scene.materials.as_ref().iter().enumerate() {
if ufbx_material.element.element_id == 0 {
@ -60,18 +67,15 @@ pub fn process_materials(
pub fn process_textures(
scene: &ufbx::Scene,
settings: &FbxLoaderSettings,
load_context: &mut LoadContext,
load_context: &mut LoadContext<'_>,
external_texture_bytes: &HashMap<String, Vec<u8>>,
) -> Result<HashMap<u32, Handle<Image>>, FbxError> {
let mut texture_handles = HashMap::new();
let mut external_handles = HashMap::<String, Handle<Image>>::new();
let mut unavailable = BTreeSet::new();
let asset_path = load_context.path().clone();
for (index, texture) in scene.textures.as_ref().iter().enumerate() {
let asset_path = load_context.path().clone();
let fbx_dir = asset_path
.path()
.parent()
.unwrap_or_else(|| std::path::Path::new(""))
.to_path_buf();
// Priority 1: Embedded texture data
if !texture.content.is_empty() {
let ext = extract_extension(&texture.filename).unwrap_or("png");
@ -91,39 +95,62 @@ pub fn process_textures(
texture_handles.insert(texture.element.element_id, handle);
continue;
}
Err(e) => {
eprintln!("bevy_ufbx: failed to load embedded texture {index}: {e}");
// Fall through to try external paths
Err(error) => {
warn!(
"bevy_ufbx: skipped embedded texture {index} for {asset_path}: {error}"
);
continue;
}
}
}
// Priority 2: relative_filename from ufbx (relative to FBX file)
if !texture.relative_filename.is_empty() {
let rel = texture.relative_filename.as_ref();
let is_absolute = rel.starts_with('/')
|| (rel.len() >= 3 && rel.chars().nth(1) == Some(':'));
if !is_absolute {
let texture_path = fbx_dir.join(rel).to_string_lossy().to_string();
let image_handle = load_context.load(texture_path);
texture_handles.insert(texture.element.element_id, image_handle);
let reference = match external_texture_reference(index, texture) {
Ok(Some(reference)) => reference,
Ok(None) => continue,
Err(error) => {
unavailable.insert(error.to_string());
continue;
}
};
if let Some(handle) = external_handles.get(&reference.relative_path) {
texture_handles.insert(reference.element_id, handle.clone());
continue;
}
// Priority 3: Extract relative path from filename / absolute_filename
// Preserves .fbm folder structure if present (e.g., "model.fbm/texture.jpg")
let relative_path = extract_relative_texture_path(texture);
let Some(bytes) = external_texture_bytes.get(&reference.relative_path) else {
continue;
};
let extension = std::path::Path::new(&reference.relative_path)
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("png");
let image = match Image::from_buffer(
&bytes,
ImageType::Extension(extension),
CompressedImageFormats::NONE,
true,
ImageSampler::Default,
settings.load_materials,
) {
Ok(image) => image,
Err(error) => {
unavailable.insert(format!("{} ({error})", reference.relative_path));
continue;
}
};
let handle = load_context
.add_labeled_asset(FbxAssetLabel::Texture(index).to_string(), image);
external_handles.insert(reference.relative_path, handle.clone());
texture_handles.insert(reference.element_id, handle);
}
if !relative_path.is_empty() {
let texture_path = fbx_dir
.join(relative_path)
.to_string_lossy()
.to_string();
let image_handle = load_context.load(texture_path);
texture_handles.insert(texture.element.element_id, image_handle);
}
if !unavailable.is_empty() {
warn!(
"bevy_ufbx: skipped {} unavailable external texture reference(s) for {}: {}",
unavailable.len(),
asset_path,
unavailable.into_iter().collect::<Vec<_>>().join("; ")
);
}
Ok(texture_handles)
@ -140,48 +167,6 @@ fn extract_extension(filename: &ufbx::String) -> Option<&str> {
.filter(|ext| !ext.contains('/') && !ext.contains('\\'))
}
/// Extract a relative texture path from a ufbx Texture's filename or absolute_filename.
///
/// Handles absolute paths by looking for `.fbm` folders (FBX's standard embedded texture
/// directory) and extracting from there, or falling back to just the filename.
fn extract_relative_texture_path<'a>(texture: &'a ufbx::Texture) -> &'a str {
if !texture.filename.is_empty() {
let filename = texture.filename.as_ref();
let is_absolute = filename.starts_with('/')
|| (filename.len() >= 3 && filename.chars().nth(1) == Some(':'));
if is_absolute {
extract_from_absolute(filename)
} else {
filename
}
} else if !texture.absolute_filename.is_empty() {
extract_from_absolute(texture.absolute_filename.as_ref())
} else {
""
}
}
/// Extract a relative path from an absolute path, preserving `.fbm` folder structure.
fn extract_from_absolute(path: &str) -> &str {
if let Some(fbm_pos) = path.rfind(".fbm/").or_else(|| path.rfind(".fbm\\")) {
let before_fbm = &path[..fbm_pos];
let folder_start = before_fbm
.rfind(&['/', '\\'][..])
.map(|p| p + 1)
.unwrap_or(0);
&path[folder_start..]
} else {
let last_slash = path.rfind(&['/', '\\'][..]);
if let Some(pos) = last_slash {
&path[pos + 1..]
} else {
path
}
}
}
/// Create a StandardMaterial from ufbx material.
pub fn create_standard_material(
ufbx_material: &ufbx::Material,

211
third_party/bevy_ufbx/src/texture.rs vendored Normal file
View File

@ -0,0 +1,211 @@
//! External FBX texture reference discovery and path normalization.
use std::collections::BTreeSet;
use std::fmt;
use std::path::{Component, Path};
/// One safe external texture reference declared by an FBX texture element.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FbxExternalTextureReference {
pub texture_index: usize,
pub element_id: u32,
pub relative_path: String,
}
/// A texture reference that cannot be resolved inside the FBX asset source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FbxTexturePathError {
pub texture_index: usize,
pub raw_path: String,
pub reason: String,
}
/// Deterministic external texture discovery result, including safe and rejected references.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FbxExternalTexturePathReport {
pub paths: Vec<String>,
pub errors: Vec<FbxTexturePathError>,
}
impl fmt::Display for FbxTexturePathError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"texture {} path `{}` is unsafe: {}",
self.texture_index, self.raw_path, self.reason
)
}
}
/// Returns the normalized external path for one texture, or `None` for embedded/no-file textures.
pub fn external_texture_reference(
texture_index: usize,
texture: &ufbx::Texture,
) -> Result<Option<FbxExternalTextureReference>, FbxTexturePathError> {
if !texture.content.is_empty() {
return Ok(None);
}
let Some(raw_path) = texture_path_candidate(texture) else {
return Ok(None);
};
let relative_path = normalize_external_texture_path(raw_path).map_err(|reason| {
FbxTexturePathError {
texture_index,
raw_path: raw_path.to_string(),
reason,
}
})?;
Ok(Some(FbxExternalTextureReference {
texture_index,
element_id: texture.element.element_id,
relative_path,
}))
}
/// Returns every distinct safe external texture path in deterministic order.
pub fn external_texture_paths(
scene: &ufbx::Scene,
) -> Result<Vec<String>, Vec<FbxTexturePathError>> {
let report = external_texture_path_report(scene);
if report.errors.is_empty() {
Ok(report.paths)
} else {
Err(report.errors)
}
}
/// Discovers safe and rejected references without discarding either side of the result.
pub fn external_texture_path_report(scene: &ufbx::Scene) -> FbxExternalTexturePathReport {
let mut paths = BTreeSet::new();
let mut errors = Vec::new();
for (texture_index, texture) in scene.textures.as_ref().iter().enumerate() {
match external_texture_reference(texture_index, texture) {
Ok(Some(reference)) => {
paths.insert(reference.relative_path);
}
Ok(None) => {}
Err(error) => errors.push(error),
}
}
errors.sort_by(|left, right| {
(left.texture_index, &left.raw_path).cmp(&(right.texture_index, &right.raw_path))
});
errors.dedup();
FbxExternalTexturePathReport {
paths: paths.into_iter().collect(),
errors,
}
}
fn texture_path_candidate(texture: &ufbx::Texture) -> Option<&str> {
if !texture.relative_filename.is_empty() {
return Some(texture.relative_filename.as_ref());
}
if !texture.filename.is_empty() {
return Some(texture.filename.as_ref());
}
if !texture.absolute_filename.is_empty() {
return Some(texture.absolute_filename.as_ref());
}
None
}
fn normalize_external_texture_path(raw_path: &str) -> Result<String, String> {
let normalized = raw_path.trim().replace('\\', "/");
if normalized.is_empty() {
return Err("path is empty".into());
}
if normalized.contains('\0') {
return Err("path contains a NUL byte".into());
}
if normalized.contains('#') || normalized.contains('?') || normalized.contains("://") {
return Err("path contains asset-source, label, or query syntax".into());
}
let normalized = if is_absolute_like(&normalized) {
extract_fbm_suffix(&normalized).ok_or_else(|| {
"absolute paths are rejected unless they identify a portable .fbm sidecar".to_string()
})?
} else {
normalized
};
let mut components = Vec::new();
for component in Path::new(&normalized).components() {
match component {
Component::Normal(segment) => {
let segment = segment.to_str().ok_or_else(|| "path is not UTF-8".to_string())?;
if segment.contains(':') {
return Err("path contains a platform or asset-source prefix".into());
}
components.push(segment);
}
Component::CurDir => {}
Component::ParentDir => return Err("parent traversal is not allowed".into()),
Component::RootDir | Component::Prefix(_) => {
return Err("absolute paths are not allowed".into());
}
}
}
if components.is_empty() {
return Err("path does not identify a file".into());
}
Ok(components.join("/"))
}
fn is_absolute_like(path: &str) -> bool {
path.starts_with('/')
|| path.starts_with("//")
|| path.as_bytes().get(1).is_some_and(|byte| *byte == b':')
}
fn extract_fbm_suffix(path: &str) -> Option<String> {
let lower = path.to_ascii_lowercase();
let fbm_end = lower.rfind(".fbm/")? + ".fbm/".len();
let fbm_start = path[..fbm_end - 1]
.rfind('/')
.map(|index| index + 1)
.unwrap_or(0);
Some(path[fbm_start..].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sibling_texture_folder_is_preserved() {
assert_eq!(
normalize_external_texture_path("textures\\chair_diff.jpg").unwrap(),
"textures/chair_diff.jpg"
);
}
#[test]
fn absolute_fbm_path_is_portably_rebased() {
assert_eq!(
normalize_external_texture_path("C:\\Build\\Chair.fbm\\chair_diff.jpg").unwrap(),
"Chair.fbm/chair_diff.jpg"
);
}
#[test]
fn absolute_non_sidecar_path_is_rejected() {
let error = normalize_external_texture_path("C:\\Build\\textures\\chair_diff.jpg")
.unwrap_err();
assert!(error.contains("absolute paths are rejected"));
}
#[test]
fn parent_traversal_is_rejected() {
let error = normalize_external_texture_path("../shared/chair_diff.jpg").unwrap_err();
assert!(error.contains("parent traversal"));
}
#[test]
fn asset_path_syntax_is_rejected() {
assert!(normalize_external_texture_path("remote://chair_diff.jpg").is_err());
assert!(normalize_external_texture_path("textures/chair.jpg#Image0").is_err());
}
}