Compare commits
No commits in common. "931f561e7ff1489f897e22ebb3cc7545fcfef702" and "7e99243a78ee99207763ec92e80bcf6dd03f7151" have entirely different histories.
931f561e7f
...
7e99243a78
@ -1,44 +0,0 @@
|
|||||||
# 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.
|
|
||||||
11
README.md
11
README.md
@ -378,7 +378,6 @@ The `.vscode/` folder is preconfigured:
|
|||||||
- [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md)
|
- [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 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 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
|
## Project Layout
|
||||||
|
|
||||||
@ -433,9 +432,6 @@ crates/
|
|||||||
- [x] Deterministic imported-source fingerprints for models, textures, and audio; BLAKE3-backed
|
- [x] Deterministic imported-source fingerprints for models, textures, and audio; BLAKE3-backed
|
||||||
static/animation manifests; byte-preserving equivalent refresh; and read-only validator checkout
|
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))
|
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] 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 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))
|
- [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))
|
||||||
@ -483,11 +479,8 @@ crates/
|
|||||||
- The editor asset browser is filesystem-backed with folder/tree navigation, grid/list views,
|
- 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
|
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
|
untextured models), material sphere thumbnails, search/filter/sort controls, expandable model
|
||||||
subasset shelves, and a staged details pane. **File -> Import Assets**
|
subasset shelves, and a staged details pane. **File → Import Assets**
|
||||||
accepts glTF/GLB and binary **FBX**. FBX import parses and transactionally preserves every safe
|
accepts glTF/GLB and **FBX** (binary; copies sibling `.fbm` texture folders when present).
|
||||||
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
|
Model assets generate normalized model manifests under `assets/meshes/generated/`; drag/drop uses
|
||||||
**Renderable Asset (Auto)**. Unrigged sources use `StaticMeshRenderer` with imported asset refs
|
**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
|
and optional separate static mesh colliders. Skin-bound or animated sources and their subasset placement use
|
||||||
|
|||||||
@ -458,16 +458,12 @@
|
|||||||
lod0_only: true,
|
lod0_only: true,
|
||||||
placement_mode: StaticAsset,
|
placement_mode: StaticAsset,
|
||||||
hierarchy_mode: SingleActor,
|
hierarchy_mode: SingleActor,
|
||||||
material_policy: AuthoringOverride,
|
material_policy: SourceMaterials,
|
||||||
static_mesh_manifest_path: Some("assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron"),
|
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"),
|
animation_manifest_path: Some("assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron"),
|
||||||
default_animation_clip_id: None,
|
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"),
|
id: ("3f63f359-45eb-4cb2-8970-71921cbd7bd0"),
|
||||||
@ -773,4 +769,4 @@
|
|||||||
),
|
),
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -9,11 +9,7 @@
|
|||||||
byte_len: 59964,
|
byte_len: 59964,
|
||||||
content_hash: "b12973a62dcb44589e380ea833eade726ae98a86c81084c842ee3801866d6a46",
|
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: (
|
import: (
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
@ -21,7 +17,7 @@
|
|||||||
lod0_only: true,
|
lod0_only: true,
|
||||||
placement_mode: StaticAsset,
|
placement_mode: StaticAsset,
|
||||||
hierarchy_mode: SingleActor,
|
hierarchy_mode: SingleActor,
|
||||||
material_policy: AuthoringOverride,
|
material_policy: SourceMaterials,
|
||||||
),
|
),
|
||||||
metadata: (
|
metadata: (
|
||||||
mesh_count: 1,
|
mesh_count: 1,
|
||||||
@ -52,4 +48,4 @@
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
)
|
)
|
||||||
@ -1455,7 +1455,7 @@ pub fn import_external_assets(paths: &[PathBuf]) -> Result<usize, String> {
|
|||||||
};
|
};
|
||||||
let dest_dir = import_dir_for_extension(extension);
|
let dest_dir = import_dir_for_extension(extension);
|
||||||
if extension.eq_ignore_ascii_case("fbx") {
|
if extension.eq_ignore_ascii_case("fbx") {
|
||||||
super::import::copy_fbx_bundle(source, Path::new(dest_dir))?;
|
super::import::copy_fbx_with_sidecar(source, Path::new(dest_dir))?;
|
||||||
copied += 1;
|
copied += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,564 +1,71 @@
|
|||||||
//! External asset bundle inspection and transactional import.
|
//! External asset import helpers (FBX sidecar folders, etc.).
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use bevy_ufbx::texture::external_texture_paths;
|
/// 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> {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
let Some(file_name) = source.file_name() else {
|
||||||
pub(crate) struct FbxDependencyInspection {
|
return Err("FBX import path has no file name".into());
|
||||||
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!(
|
|
||||||
"unsafe FBX texture reference(s) in {}: {}",
|
|
||||||
source.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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
copy_bundle_transactionally(&entries, dest_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_bundle_transactionally(
|
|
||||||
entries: &BTreeMap<PathBuf, PathBuf>,
|
|
||||||
dest_dir: &Path,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
fs::create_dir_all(dest_dir)
|
fs::create_dir_all(dest_dir)
|
||||||
.map_err(|error| format!("could not create {}: {error}", dest_dir.display()))?;
|
.map_err(|err| format!("could not create {}: {err}", dest_dir.display()))?;
|
||||||
let canonical_dest_dir = fs::canonicalize(dest_dir).map_err(|error| {
|
let dest = dest_dir.join(file_name);
|
||||||
|
fs::copy(source, &dest).map_err(|err| {
|
||||||
format!(
|
format!(
|
||||||
"could not resolve FBX import destination {}: {error}",
|
"could not copy {} to {}: {err}",
|
||||||
dest_dir.display()
|
source.display(),
|
||||||
|
dest.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
for relative in entries.keys() {
|
|
||||||
validate_bundle_relative_path(relative)?;
|
if let Some(fbm_dir) = fbm_sidecar_dir(source) {
|
||||||
validate_existing_destination_parents(dest_dir, &canonical_dest_dir, relative)?;
|
if fbm_dir.is_dir() {
|
||||||
let target = dest_dir.join(relative);
|
let dest_fbm = dest_dir.join(fbm_dir.file_name().unwrap_or_default());
|
||||||
if target.is_dir() {
|
copy_dir_recursive(&fbm_dir, &dest_fbm)?;
|
||||||
return Err(format!(
|
|
||||||
"FBX import target {} is an existing directory",
|
|
||||||
target.display()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
let stage_root = dest_dir.join(format!(".blacksite-import-{}", uuid::Uuid::new_v4()));
|
fn fbm_sidecar_dir(source: &Path) -> Option<PathBuf> {
|
||||||
let staged_root = stage_root.join("new");
|
let stem = source.file_stem()?.to_str()?;
|
||||||
let backup_root = stage_root.join("backup");
|
let parent = source.parent()?;
|
||||||
let stage_result = (|| {
|
Some(parent.join(format!("{stem}.fbm")))
|
||||||
for (relative, source) in entries {
|
}
|
||||||
let staged = staged_root.join(relative);
|
|
||||||
if let Some(parent) = staged.parent() {
|
fn copy_dir_recursive(source: &Path, dest: &Path) -> Result<(), String> {
|
||||||
fs::create_dir_all(parent).map_err(|error| {
|
fs::create_dir_all(dest)
|
||||||
format!(
|
.map_err(|err| format!("could not create {}: {err}", dest.display()))?;
|
||||||
"could not create import staging {}: {error}",
|
for entry in fs::read_dir(source).map_err(|err| err.to_string())? {
|
||||||
parent.display()
|
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() {
|
||||||
fs::copy(source, &staged).map_err(|error| {
|
copy_dir_recursive(&src_path, &dest_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&src_path, &dest_path).map_err(|err| {
|
||||||
format!(
|
format!(
|
||||||
"could not stage FBX bundle file {} as {}: {error}",
|
"could not copy {} to {}: {err}",
|
||||||
source.display(),
|
src_path.display(),
|
||||||
relative.display()
|
dest_path.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(())
|
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(¤t) {
|
|
||||||
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(¤t).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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::fbm_sidecar_dir;
|
||||||
|
use std::path::Path;
|
||||||
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]
|
#[test]
|
||||||
fn committed_chair_reports_one_stable_missing_dependency_state() {
|
fn fbm_sidecar_path() {
|
||||||
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!(
|
assert_eq!(
|
||||||
fs::read(destination.join("chair.fbx")).unwrap(),
|
fbm_sidecar_dir(Path::new("/tmp/Character.fbx")),
|
||||||
b"original"
|
Some(Path::new("/tmp/Character.fbm").to_path_buf())
|
||||||
);
|
);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ use bevy::gltf::GltfAssetLabel;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_ufbx::label::FbxAssetLabel;
|
use bevy_ufbx::label::FbxAssetLabel;
|
||||||
use bevy_ufbx::mesh::group_faces_by_material;
|
use bevy_ufbx::mesh::group_faces_by_material;
|
||||||
use bevy_ufbx::texture::external_texture_paths;
|
|
||||||
use bevy_ufbx::utils::convert_matrix;
|
use bevy_ufbx::utils::convert_matrix;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -485,7 +484,6 @@ fn build_fbx_manifest(
|
|||||||
ufbx::LoadOpts {
|
ufbx::LoadOpts {
|
||||||
target_unit_meters: 1.0,
|
target_unit_meters: 1.0,
|
||||||
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
|
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
|
||||||
filename: ufbx::StringOpt::Ref(&record.path),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -565,7 +563,7 @@ fn build_fbx_manifest(
|
|||||||
path: record.path.clone(),
|
path: record.path.clone(),
|
||||||
format,
|
format,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
dependencies: fbx_dependencies(&record.path, &scene)?,
|
dependencies: fbx_dependencies(&record.path),
|
||||||
},
|
},
|
||||||
import,
|
import,
|
||||||
metadata: StaticMeshMetadata {
|
metadata: StaticMeshMetadata {
|
||||||
@ -594,26 +592,25 @@ fn fbx_material_label(scene: &ufbx::Scene, element_id: u32) -> Option<String> {
|
|||||||
.map(|index| FbxAssetLabel::Material(index).to_string())
|
.map(|index| FbxAssetLabel::Material(index).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fbx_dependencies(source_path: &str, scene: &ufbx::Scene) -> Result<Vec<String>, String> {
|
fn fbx_dependencies(source_path: &str) -> Vec<String> {
|
||||||
let path = Path::new(source_path);
|
let path = Path::new(source_path);
|
||||||
let parent = path.parent().unwrap_or_else(|| Path::new(""));
|
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
|
||||||
external_texture_paths(scene)
|
return Vec::new();
|
||||||
.map_err(|errors| {
|
};
|
||||||
format!(
|
let Some(parent) = path.parent() else {
|
||||||
"unsafe FBX texture reference(s) in {source_path}: {}",
|
return Vec::new();
|
||||||
errors
|
};
|
||||||
.into_iter()
|
let sidecar = parent.join(format!("{stem}.fbm"));
|
||||||
.map(|error| error.to_string())
|
if !sidecar.is_dir() {
|
||||||
.collect::<Vec<_>>()
|
return Vec::new();
|
||||||
.join("; ")
|
}
|
||||||
)
|
walkdir::WalkDir::new(sidecar)
|
||||||
})
|
.follow_links(false)
|
||||||
.map(|paths| {
|
.into_iter()
|
||||||
paths
|
.filter_map(Result::ok)
|
||||||
.into_iter()
|
.filter(|entry| entry.file_type().is_file())
|
||||||
.map(|relative| parent.join(relative).to_string_lossy().replace('\\', "/"))
|
.map(|entry| entry.path().to_string_lossy().replace('\\', "/"))
|
||||||
.collect()
|
.collect()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_format(path: &str) -> Result<String, String> {
|
fn source_format(path: &str) -> Result<String, String> {
|
||||||
@ -826,30 +823,4 @@ mod tests {
|
|||||||
assert!(manifest.parts.iter().any(|part| part.skinned));
|
assert!(manifest.parts.iter().any(|part| part.skinned));
|
||||||
assert!(renderer.slots.is_empty());
|
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_")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
//! Thumbnail cache for asset browser grid cells.
|
//! Thumbnail cache for asset browser grid cells.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use bevy::asset::LoadState;
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
|
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
|
||||||
use egui_phosphor_icons::icons;
|
use egui_phosphor_icons::icons;
|
||||||
@ -81,9 +79,6 @@ impl AssetThumbnailCache {
|
|||||||
if self.failed.contains_key(&key) {
|
if self.failed.contains_key(&key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !self.texture_source_ready(&key, &path) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let handle: Handle<Image> = asset_server.load(asset_server_path(&path));
|
let handle: Handle<Image> = asset_server.load(asset_server_path(&path));
|
||||||
self.pending.insert(key, handle);
|
self.pending.insert(key, handle);
|
||||||
}
|
}
|
||||||
@ -166,7 +161,7 @@ impl AssetThumbnailCache {
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !self.source_material_ready(&key, &model_path) {
|
if !self.model_source_ready(&key, &model_path) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if studio.enqueue_source(
|
if studio.enqueue_source(
|
||||||
@ -229,32 +224,6 @@ impl AssetThumbnailCache {
|
|||||||
true
|
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(
|
pub(crate) fn complete_studio_thumbnail(
|
||||||
&mut self,
|
&mut self,
|
||||||
key: &str,
|
key: &str,
|
||||||
@ -299,11 +268,6 @@ impl AssetThumbnailCache {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect(),
|
.collect(),
|
||||||
failed_keys: self.failed.keys().cloned().collect(),
|
failed_keys: self.failed.keys().cloned().collect(),
|
||||||
failure_reasons: self
|
|
||||||
.failed
|
|
||||||
.iter()
|
|
||||||
.map(|(key, failure)| (key.clone(), failure.reason.clone()))
|
|
||||||
.collect(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -313,14 +277,6 @@ fn register_loaded_thumbnails(
|
|||||||
mut cache: ResMut<AssetThumbnailCache>,
|
mut cache: ResMut<AssetThumbnailCache>,
|
||||||
mut textures: ResMut<EguiUserTextures>,
|
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
|
let ready: Vec<(String, Handle<Image>)> = cache
|
||||||
.pending
|
.pending
|
||||||
.iter()
|
.iter()
|
||||||
@ -328,10 +284,6 @@ fn register_loaded_thumbnails(
|
|||||||
.map(|(key, handle)| (key.clone(), handle.clone()))
|
.map(|(key, handle)| (key.clone(), handle.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for (key, error) in failed {
|
|
||||||
cache.mark_studio_failed(&key, &format!("texture load failed: {error}"), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (key, handle) in ready {
|
for (key, handle) in ready {
|
||||||
cache.pending.remove(&key);
|
cache.pending.remove(&key);
|
||||||
cache.failed.remove(&key);
|
cache.failed.remove(&key);
|
||||||
@ -445,11 +397,7 @@ pub fn draw_asset_cell_with(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(reason) = failed {
|
response
|
||||||
response.on_hover_text(reason)
|
|
||||||
} else {
|
|
||||||
response
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -457,7 +405,6 @@ pub struct ThumbnailCacheSnapshot {
|
|||||||
pub texture_ids: HashMap<String, egui::TextureId>,
|
pub texture_ids: HashMap<String, egui::TextureId>,
|
||||||
pub pending_keys: Vec<String>,
|
pub pending_keys: Vec<String>,
|
||||||
pub failed_keys: Vec<String>,
|
pub failed_keys: Vec<String>,
|
||||||
pub failure_reasons: HashMap<String, String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ThumbnailCacheSnapshot {
|
impl ThumbnailCacheSnapshot {
|
||||||
@ -473,10 +420,6 @@ impl ThumbnailCacheSnapshot {
|
|||||||
self.failed_keys.iter().any(|failed| failed == key)
|
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> {
|
pub fn texture_for(&self, asset: &EditorAsset) -> Option<egui::TextureId> {
|
||||||
self.texture_ids.get(&asset_cache_key(asset)).copied()
|
self.texture_ids.get(&asset_cache_key(asset)).copied()
|
||||||
}
|
}
|
||||||
@ -492,10 +435,6 @@ impl ThumbnailCacheSnapshot {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|key| key == &asset_cache_key(asset))
|
.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) {
|
pub fn prefetch_folder_thumbnails(world: &mut World, folder: &str) {
|
||||||
@ -595,53 +534,3 @@ pub fn invalidate_on_catalog_refresh(world: &mut World) {
|
|||||||
world.commands().entity(root).despawn();
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -83,72 +83,6 @@ 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 {
|
fn build_mesh(ufbx_mesh: &ufbx::Mesh, indices: &[u32]) -> Mesh {
|
||||||
let mut mesh = Mesh::new(
|
let mut mesh = Mesh::new(
|
||||||
PrimitiveTopology::TriangleList,
|
PrimitiveTopology::TriangleList,
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
mod fbx;
|
mod fbx;
|
||||||
pub(crate) mod gltf;
|
pub(crate) mod gltf;
|
||||||
|
|
||||||
pub(crate) use fbx::spawn_fbx_mesh_subasset_preview;
|
|
||||||
pub use fbx::FbxThumbnailSource;
|
pub use fbx::FbxThumbnailSource;
|
||||||
pub use gltf::{gltf_skinned_primitive_labels, GltfThumbnailSource};
|
pub use gltf::{gltf_skinned_primitive_labels, GltfThumbnailSource};
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ use shared::{material_from_desc, standard_material_asset_path, ModelRef};
|
|||||||
|
|
||||||
use super::cache::AssetThumbnailCache;
|
use super::cache::AssetThumbnailCache;
|
||||||
use super::job::{ThumbnailJob, ThumbnailJobSource};
|
use super::job::{ThumbnailJob, ThumbnailJobSource};
|
||||||
use super::sources::{source_for_extension, spawn_fbx_mesh_subasset_preview, uses_scene_root};
|
use super::sources::{source_for_extension, uses_scene_root};
|
||||||
use crate::assets::asset_server_path;
|
use crate::assets::asset_server_path;
|
||||||
use crate::infra::EditorOnly;
|
use crate::infra::EditorOnly;
|
||||||
|
|
||||||
@ -27,7 +27,6 @@ const SCENE_FRAME_PADDING: f32 = 1.35;
|
|||||||
const MATERIAL_SPHERE_FRAME_PADDING: f32 = 1.28;
|
const MATERIAL_SPHERE_FRAME_PADDING: f32 = 1.28;
|
||||||
const MESH_WARMUP_FRAMES: u8 = 12;
|
const MESH_WARMUP_FRAMES: u8 = 12;
|
||||||
const POST_ATTACH_FRAMES: u8 = 8;
|
const POST_ATTACH_FRAMES: u8 = 8;
|
||||||
const FIRST_RENDER_WARMUP_FRAMES: u8 = 120;
|
|
||||||
const RENDER_FRAMES: u8 = 3;
|
const RENDER_FRAMES: u8 = 3;
|
||||||
const COOLDOWN_FRAMES: u8 = 2;
|
const COOLDOWN_FRAMES: u8 = 2;
|
||||||
const LOAD_TIMEOUT_FRAMES: u32 = 240;
|
const LOAD_TIMEOUT_FRAMES: u32 = 240;
|
||||||
@ -52,7 +51,6 @@ pub struct ThumbnailStudio {
|
|||||||
queue: VecDeque<ThumbnailJob>,
|
queue: VecDeque<ThumbnailJob>,
|
||||||
active: Option<ActiveModelThumbnail>,
|
active: Option<ActiveModelThumbnail>,
|
||||||
cooldown_frames: u8,
|
cooldown_frames: u8,
|
||||||
render_pipeline_warmed: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ActiveModelThumbnail {
|
struct ActiveModelThumbnail {
|
||||||
@ -211,7 +209,6 @@ fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Imag
|
|||||||
queue: VecDeque::new(),
|
queue: VecDeque::new(),
|
||||||
active: None,
|
active: None,
|
||||||
cooldown_frames: 0,
|
cooldown_frames: 0,
|
||||||
render_pipeline_warmed: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,7 +288,7 @@ fn process_thumbnail_studio(
|
|||||||
camera.is_active = true;
|
camera.is_active = true;
|
||||||
}
|
}
|
||||||
active.camera_active = true;
|
active.camera_active = true;
|
||||||
active.frames_remaining = capture_frames(studio.render_pipeline_warmed);
|
active.frames_remaining = RENDER_FRAMES;
|
||||||
}
|
}
|
||||||
studio.active = Some(active);
|
studio.active = Some(active);
|
||||||
return;
|
return;
|
||||||
@ -302,7 +299,6 @@ fn process_thumbnail_studio(
|
|||||||
active.frames_remaining -= 1;
|
active.frames_remaining -= 1;
|
||||||
studio.active = Some(active);
|
studio.active = Some(active);
|
||||||
} else {
|
} else {
|
||||||
studio.render_pipeline_warmed = true;
|
|
||||||
cache.complete_studio_thumbnail(
|
cache.complete_studio_thumbnail(
|
||||||
&active.cache_key,
|
&active.cache_key,
|
||||||
active.render_image.clone(),
|
active.render_image.clone(),
|
||||||
@ -430,15 +426,12 @@ fn spawn_thumbnail_job_content(
|
|||||||
material_label,
|
material_label,
|
||||||
} => spawn_mesh_subasset_preview(
|
} => spawn_mesh_subasset_preview(
|
||||||
commands,
|
commands,
|
||||||
mesh_storage,
|
|
||||||
materials,
|
materials,
|
||||||
asset_server,
|
asset_server,
|
||||||
root,
|
root,
|
||||||
MeshSubassetPreviewSpec {
|
model_path,
|
||||||
model_path,
|
mesh_label,
|
||||||
mesh_label,
|
material_label.as_deref(),
|
||||||
material_label: material_label.as_deref(),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
ThumbnailJobSource::SourceMaterial {
|
ThumbnailJobSource::SourceMaterial {
|
||||||
model_path,
|
model_path,
|
||||||
@ -459,38 +452,18 @@ 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(
|
fn spawn_mesh_subasset_preview(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
mesh_storage: &mut Assets<Mesh>,
|
|
||||||
materials: &mut Assets<StandardMaterial>,
|
materials: &mut Assets<StandardMaterial>,
|
||||||
asset_server: &AssetServer,
|
asset_server: &AssetServer,
|
||||||
root: Entity,
|
root: Entity,
|
||||||
preview: MeshSubassetPreviewSpec<'_>,
|
model_path: &str,
|
||||||
|
mesh_label: &str,
|
||||||
|
material_label: Option<&str>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if preview
|
let source_path = asset_server_path(model_path);
|
||||||
.model_path
|
let mesh: Handle<Mesh> = asset_server.load(labeled_asset_path(&source_path, mesh_label));
|
||||||
.rsplit_once('.')
|
let material = if let Some(material_label) = material_label {
|
||||||
.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))
|
asset_server.load(standard_material_asset_path(&source_path, material_label))
|
||||||
} else {
|
} else {
|
||||||
materials.add(StandardMaterial {
|
materials.add(StandardMaterial {
|
||||||
@ -536,14 +509,6 @@ fn studio_render_image() -> Image {
|
|||||||
Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba16Float, None)
|
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(
|
fn finish_active_thumbnail(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
studio: &mut ThumbnailStudio,
|
studio: &mut ThumbnailStudio,
|
||||||
@ -769,10 +734,4 @@ mod tests {
|
|||||||
assert!(material_distance < scene_distance * 0.6);
|
assert!(material_distance < scene_distance * 0.6);
|
||||||
assert!((0.75..0.82).contains(&projected_radius_fraction));
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,9 +49,7 @@ use crate::assets::{
|
|||||||
ThumbnailCacheSnapshot, ThumbnailStudio,
|
ThumbnailCacheSnapshot, ThumbnailStudio,
|
||||||
};
|
};
|
||||||
use crate::ui::helpers::asset_label;
|
use crate::ui::helpers::asset_label;
|
||||||
use crate::ui::theme::{
|
use crate::ui::theme::{panel_heading, ACCENT, BORDER, ELEVATED_BG, TEXT, TEXT_DIM, WIDGET_BG};
|
||||||
panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, WARNING, WIDGET_BG,
|
|
||||||
};
|
|
||||||
use crate::ui::widgets::{icon_button_small, tool_button};
|
use crate::ui::widgets::{icon_button_small, tool_button};
|
||||||
|
|
||||||
const FOOTER_HEIGHT: f32 = 64.0;
|
const FOOTER_HEIGHT: f32 = 64.0;
|
||||||
@ -377,10 +375,6 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let mut embedded = Vec::new();
|
let mut embedded = Vec::new();
|
||||||
let use_source_materials = matches!(
|
|
||||||
record.import_settings.material_policy,
|
|
||||||
MaterialImportPolicy::SourceMaterials
|
|
||||||
);
|
|
||||||
let static_manifest = record
|
let static_manifest = record
|
||||||
.import_settings
|
.import_settings
|
||||||
.static_mesh_manifest_path
|
.static_mesh_manifest_path
|
||||||
@ -452,11 +446,7 @@ fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec<Embedded
|
|||||||
},
|
},
|
||||||
label,
|
label,
|
||||||
kind: AssetSubAssetKind::Material,
|
kind: AssetSubAssetKind::Material,
|
||||||
detail: if use_source_materials {
|
detail: "Embedded source material".to_string(),
|
||||||
"Embedded source material".to_string()
|
|
||||||
} else {
|
|
||||||
"Source material disabled by Authoring Override".to_string()
|
|
||||||
},
|
|
||||||
texture_path: None,
|
texture_path: None,
|
||||||
thumbnail_key: subasset_thumbnail_key(
|
thumbnail_key: subasset_thumbnail_key(
|
||||||
parent_path,
|
parent_path,
|
||||||
@ -1139,14 +1129,14 @@ fn draw_asset_grid_item(
|
|||||||
let selection = &row.selection;
|
let selection = &row.selection;
|
||||||
let asset = &row.asset;
|
let asset = &row.asset;
|
||||||
let is_selected = selected.as_ref() == Some(selection);
|
let is_selected = selected.as_ref() == Some(selection);
|
||||||
let failure = cache_snapshot.failure_reason(asset);
|
let failed = cache_snapshot.is_failed(asset);
|
||||||
let has_children = has_embedded_assets(world, asset);
|
let has_children = has_embedded_assets(world, asset);
|
||||||
let response = draw_asset_cell_with(
|
let response = draw_asset_cell_with(
|
||||||
ui,
|
ui,
|
||||||
asset,
|
asset,
|
||||||
cache_snapshot.texture_for(asset),
|
cache_snapshot.texture_for(asset),
|
||||||
cache_snapshot.is_pending(asset),
|
cache_snapshot.is_pending(asset),
|
||||||
failure,
|
failed.then_some("failed"),
|
||||||
is_selected,
|
is_selected,
|
||||||
thumbnail_size,
|
thumbnail_size,
|
||||||
);
|
);
|
||||||
@ -1200,7 +1190,7 @@ fn draw_asset_grid_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if failure.is_some() && response.double_clicked() {
|
if failed && response.double_clicked() {
|
||||||
let key = crate::assets::asset_cache_key(asset);
|
let key = crate::assets::asset_cache_key(asset);
|
||||||
world
|
world
|
||||||
.resource_mut::<crate::assets::AssetThumbnailCache>()
|
.resource_mut::<crate::assets::AssetThumbnailCache>()
|
||||||
@ -1359,8 +1349,7 @@ fn draw_embedded_asset_cell(
|
|||||||
.and_then(|path| thumbnail_for_path(world, cache_snapshot, path))
|
.and_then(|path| thumbnail_for_path(world, cache_snapshot, path))
|
||||||
});
|
});
|
||||||
let pending = cache_snapshot.is_pending_key(&embedded.thumbnail_key);
|
let pending = cache_snapshot.is_pending_key(&embedded.thumbnail_key);
|
||||||
let failure = cache_snapshot.failure_reason_for_key(&embedded.thumbnail_key);
|
let failed = cache_snapshot.is_failed_key(&embedded.thumbnail_key);
|
||||||
let failed = failure.is_some();
|
|
||||||
let thumb_size = thumbnail_size.clamp(44.0, 64.0);
|
let thumb_size = thumbnail_size.clamp(44.0, 64.0);
|
||||||
let cell_size = egui::vec2(104.0, thumb_size + 50.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());
|
let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag());
|
||||||
@ -1371,11 +1360,6 @@ fn draw_embedded_asset_cell(
|
|||||||
} else {
|
} else {
|
||||||
response
|
response
|
||||||
};
|
};
|
||||||
let response = if let Some(reason) = failure {
|
|
||||||
response.on_hover_text(reason)
|
|
||||||
} else {
|
|
||||||
response
|
|
||||||
};
|
|
||||||
let fill = if selected {
|
let fill = if selected {
|
||||||
crate::ui::theme::SELECTION_BG_MUTED
|
crate::ui::theme::SELECTION_BG_MUTED
|
||||||
} else if response.hovered() {
|
} else if response.hovered() {
|
||||||
@ -1899,53 +1883,9 @@ pub(crate) fn top_level_asset_details_panel(
|
|||||||
}
|
}
|
||||||
if !record.dependencies.is_empty() {
|
if !record.dependencies.is_empty() {
|
||||||
ui.separator();
|
ui.separator();
|
||||||
let missing_count = record
|
ui.label(panel_heading("Dependencies"));
|
||||||
.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 {
|
for dep in &record.dependencies {
|
||||||
let exists = dependency_path(dep).is_file();
|
ui.add(egui::Label::new(dep).truncate());
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2092,12 +2032,12 @@ fn subasset_details_panel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AssetSubAssetKind::Material => {
|
AssetSubAssetKind::Material => {
|
||||||
let status = if embedded.detail.contains("Authoring Override") {
|
ui.small(
|
||||||
"Authoring Override is active; this source material is not assigned."
|
egui::RichText::new(
|
||||||
} else {
|
"Source materials are assigned through static mesh renderer slots.",
|
||||||
"Source material is assigned through static mesh renderer slots."
|
)
|
||||||
};
|
.color(TEXT_DIM),
|
||||||
ui.small(egui::RichText::new(status).color(TEXT_DIM));
|
);
|
||||||
}
|
}
|
||||||
AssetSubAssetKind::Skeleton => {
|
AssetSubAssetKind::Skeleton => {
|
||||||
ui.small(
|
ui.small(
|
||||||
@ -2132,15 +2072,6 @@ 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(
|
fn asset_action_buttons(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
|
|||||||
@ -410,19 +410,15 @@ fn validate_asset_registry(
|
|||||||
&format!("registry_{}", record.kind_tag.to_lowercase()),
|
&format!("registry_{}", record.kind_tag.to_lowercase()),
|
||||||
&record.path,
|
&record.path,
|
||||||
);
|
);
|
||||||
let generated_static_manifest_owns_dependencies = record.kind_tag == "Model"
|
for dependency in &record.dependencies {
|
||||||
&& record.import_settings.static_mesh_manifest_path.is_some();
|
add_reference(
|
||||||
if !generated_static_manifest_owns_dependencies {
|
project_root,
|
||||||
for dependency in &record.dependencies {
|
report,
|
||||||
add_reference(
|
&record.path,
|
||||||
project_root,
|
None,
|
||||||
report,
|
"import_dependency",
|
||||||
&record.path,
|
dependency,
|
||||||
None,
|
);
|
||||||
"import_dependency",
|
|
||||||
dependency,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(manifest) = record.import_settings.static_mesh_manifest_path.as_deref() {
|
if let Some(manifest) = record.import_settings.static_mesh_manifest_path.as_deref() {
|
||||||
add_reference(
|
add_reference(
|
||||||
@ -795,8 +791,6 @@ struct StaticMeshManifestView {
|
|||||||
asset_id: String,
|
asset_id: String,
|
||||||
source: StaticMeshSourceView,
|
source: StaticMeshSourceView,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
import: StaticMeshImportView,
|
|
||||||
#[serde(default)]
|
|
||||||
warnings: Vec<String>,
|
warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -804,26 +798,11 @@ struct StaticMeshManifestView {
|
|||||||
struct StaticMeshSourceView {
|
struct StaticMeshSourceView {
|
||||||
path: String,
|
path: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
format: String,
|
|
||||||
#[serde(default)]
|
|
||||||
fingerprint: AssetSourceFingerprint,
|
fingerprint: AssetSourceFingerprint,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
dependencies: Vec<String>,
|
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;
|
const STATIC_MESH_MANIFEST_SCHEMA_VERSION: u32 = 4;
|
||||||
|
|
||||||
fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValidationReport) {
|
fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValidationReport) {
|
||||||
@ -876,15 +855,16 @@ fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValid
|
|||||||
"static_mesh.manifest_stale",
|
"static_mesh.manifest_stale",
|
||||||
"static mesh source",
|
"static mesh source",
|
||||||
);
|
);
|
||||||
let optional_source_textures = manifest.source.format.eq_ignore_ascii_case("fbx")
|
for dependency in manifest.source.dependencies {
|
||||||
&& manifest.import.material_policy == StaticMeshMaterialPolicyView::AuthoringOverride;
|
add_reference(
|
||||||
add_import_dependencies(
|
project_root,
|
||||||
project_root,
|
report,
|
||||||
report,
|
&source,
|
||||||
&source,
|
None,
|
||||||
&manifest.source.dependencies,
|
"import_dependency",
|
||||||
optional_source_textures,
|
&dependency,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
for warning in manifest.warnings {
|
for warning in manifest.warnings {
|
||||||
report.findings.push(ProjectValidationFinding {
|
report.findings.push(ProjectValidationFinding {
|
||||||
severity: ValidationSeverity::Warning,
|
severity: ValidationSeverity::Warning,
|
||||||
@ -901,79 +881,6 @@ 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(
|
fn validate_animation_manifests(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
report: &mut ProjectValidationReport,
|
report: &mut ProjectValidationReport,
|
||||||
@ -3285,118 +3192,6 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(root).unwrap();
|
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]
|
#[test]
|
||||||
fn same_size_static_mesh_source_change_invalidates_content_hash() {
|
fn same_size_static_mesh_source_change_invalidates_content_hash() {
|
||||||
let root = fixture_root();
|
let root = fixture_root();
|
||||||
|
|||||||
@ -58,7 +58,6 @@ 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 |
|
| [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 |
|
| [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 |
|
| [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
|
## Editor framework
|
||||||
|
|
||||||
@ -100,7 +99,6 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
|||||||
| [editor/evaluations/guarded-shutdown-savepoints/](editor/evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
|
| [editor/evaluations/guarded-shutdown-savepoints/](editor/evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
|
||||||
| [editor/evaluations/scoped-ui-actions/](editor/evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
|
| [editor/evaluations/scoped-ui-actions/](editor/evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
|
||||||
| [editor/evaluations/deterministic-asset-fingerprints/](editor/evaluations/deterministic-asset-fingerprints/) | Source, fresh-checkout, hash-stability, and native acceptance evidence for imported-source fingerprints |
|
| [editor/evaluations/deterministic-asset-fingerprints/](editor/evaluations/deterministic-asset-fingerprints/) | Source, fresh-checkout, hash-stability, and native acceptance evidence for imported-source fingerprints |
|
||||||
| [editor/evaluations/fbx-external-texture-dependencies/](editor/evaluations/fbx-external-texture-dependencies/) | Source, validation, and native acceptance evidence for sandboxed FBX texture dependencies and override states |
|
|
||||||
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
||||||
|
|
||||||
## Working plans (not canonical long-term)
|
## Working plans (not canonical long-term)
|
||||||
@ -127,7 +125,6 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
|
|||||||
| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance |
|
| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance |
|
||||||
| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance |
|
| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance |
|
||||||
| `guarded_shutdown_savepoints_*.plan.md` | Native close coordination, asynchronous Save All, and canonical history clean points |
|
| `guarded_shutdown_savepoints_*.plan.md` | Native close coordination, asynchronous Save All, and canonical history clean points |
|
||||||
| `fbx_external_texture_dependencies_*.plan.md` | Sandboxed FBX sidecar discovery, import transactions, validation, loader behavior, and Asset Browser states |
|
|
||||||
|
|
||||||
## Crate responsibilities (quick reference)
|
## Crate responsibilities (quick reference)
|
||||||
|
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -45,7 +45,6 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
|||||||
| [evaluations/guarded-shutdown-savepoints/](evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
|
| [evaluations/guarded-shutdown-savepoints/](evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
|
||||||
| [evaluations/scoped-ui-actions/](evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
|
| [evaluations/scoped-ui-actions/](evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
|
||||||
| [evaluations/deterministic-asset-fingerprints/](evaluations/deterministic-asset-fingerprints/) | Source, fresh-checkout, hash-stability, and native acceptance evidence for imported-source fingerprints |
|
| [evaluations/deterministic-asset-fingerprints/](evaluations/deterministic-asset-fingerprints/) | Source, fresh-checkout, hash-stability, and native acceptance evidence for imported-source fingerprints |
|
||||||
| [evaluations/fbx-external-texture-dependencies/](evaluations/fbx-external-texture-dependencies/) | Source, validation, and native acceptance evidence for sandboxed FBX sidecar dependencies and override states |
|
|
||||||
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
||||||
|
|
||||||
## Subsystems (code → doc)
|
## Subsystems (code → doc)
|
||||||
@ -66,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 |
|
| `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 |
|
| `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/fingerprint.rs` / `shared::AssetSourceFingerprint` | Content-addressed imported-source identity and byte-preserving registry/manifest publication | ADR 0043, evaluations/deterministic-asset-fingerprints/ |
|
||||||
| `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 |
|
| `assets/` | Catalog, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027 |
|
||||||
| `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 |
|
| `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/` | 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 |
|
| `project/collaboration.rs` | Guarded authored writes, asynchronous Git status, and optional ownership providers | collaborative-file-safety.md, ADR 0037 |
|
||||||
@ -93,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).
|
- **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.
|
- **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.
|
- **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. 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).
|
- **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.
|
||||||
- **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/).
|
- **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.
|
- **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).
|
- **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).
|
||||||
|
|||||||
@ -220,11 +220,7 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
|
|||||||
|
|
||||||
## Model import (glTF + FBX)
|
## Model import (glTF + FBX)
|
||||||
|
|
||||||
- **Import:** File -> Import Assets copies glTF/GLB/FBX into `assets/models/`. An FBX import
|
- **Import:** File → Import Assets copies glTF/GLB/FBX into `assets/models/`. FBX imports also copy a sibling `{name}.fbm/` folder when present (embedded textures).
|
||||||
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
|
- **Processing:** the asset registry generates normalized model manifests under
|
||||||
`assets/meshes/generated/`. The manifests store stable part IDs, glTF/FBX mesh/material subasset
|
`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.
|
labels, whether each part is skin-bound, source metadata, dependencies, and import settings.
|
||||||
@ -233,22 +229,13 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
|
|||||||
equivalent refresh, so checkout mtimes and formatting do not dirty project content. 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
|
[ADR 0043](../adr/0043-content-addressed-import-fingerprints.md). These artifacts are hidden from
|
||||||
the Asset Browser catalog.
|
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
|
- **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
|
manifest. Unrigged mesh subassets place as independent `StaticMeshRenderer` actors. A skinned
|
||||||
subasset places its owning source through `SkinnedMeshRenderer`, retaining joints and inverse bind
|
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
|
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
|
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
|
its own render target before registering it in the cache so later thumbnails cannot overwrite
|
||||||
earlier cache entries. FBX model and mesh previews use neutral direct geometry; source-material
|
earlier cache entries.
|
||||||
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
|
- **Default placement:** **Renderable Asset (Auto)** creates
|
||||||
`ActorKind::StaticMesh + StaticMeshRenderer` for unrigged, non-animated models and
|
`ActorKind::StaticMesh + StaticMeshRenderer` for unrigged, non-animated models and
|
||||||
`ActorKind::SkinnedMesh + SkinnedMeshRenderer` when any part is skin-bound or the source contains
|
`ActorKind::SkinnedMesh + SkinnedMeshRenderer` when any part is skin-bound or the source contains
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
# Editor Evaluation Evidence
|
# Editor Evaluation Evidence
|
||||||
|
|
||||||
Each subdirectory keeps the canonical, versioned acceptance record for an editor feature. Record
|
Each subdirectory keeps the canonical, versioned acceptance record for an editor feature. Screenshots
|
||||||
visual evidence with its attachment ID, dimensions, and checksum. A repository image copy is
|
remain in the repository beside that record and are intentionally stored through Git LFS.
|
||||||
optional; the committed Markdown record, not a raw image URL, is the source of truth.
|
|
||||||
|
|
||||||
## Publishing To Gitea
|
## Publishing To Gitea
|
||||||
|
|
||||||
Do not embed a repository `/raw/branch/...` or `/raw/commit/...` PNG URL in an issue or pull-request
|
Do not embed a repository `/raw/branch/...` or `/raw/commit/...` PNG URL in an issue or pull-request
|
||||||
comment. Gitea can serve the LFS pointer document at that URL, which renders as text instead of an
|
comment. Gitea can serve the LFS pointer document at that URL, which renders as text instead of an
|
||||||
image. Upload the PNG to the relevant issue as a native attachment and use the returned
|
image. Upload the PNG to the relevant issue as a native attachment and use the returned
|
||||||
`/attachments/<uuid>` URL for the Markdown image. Keep the committed evaluation link next to the
|
`/attachments/<uuid>` URL for the Markdown image. Keep the repository evaluation link next to the
|
||||||
attachment so the evidence remains traceable to its tested source and exact commit.
|
attachment so the evidence remains traceable to its committed source.
|
||||||
|
|
||||||
Native attachments are the presentation copies used by issues and pull requests. Do not commit a
|
Native attachments are presentation copies. The committed evaluation directory remains the source of
|
||||||
duplicate image solely to publish it; retain verification notes and attachment metadata in the
|
truth and should retain the original image and verification notes.
|
||||||
evaluation record.
|
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
# FBX External Texture Dependency Evaluation
|
|
||||||
|
|
||||||
**Date:** 2026-07-13
|
|
||||||
|
|
||||||
**Issue:** [Gitea #58](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/58)
|
|
||||||
|
|
||||||
**Implementation commit:** `3e30c61c71fc942c33635aae3d922b70401fe7c7`
|
|
||||||
|
|
||||||
**Release-candidate commit:** Not nominated
|
|
||||||
|
|
||||||
**Source validation:** **Pass**
|
|
||||||
|
|
||||||
**Native acceptance:** **Pass**
|
|
||||||
|
|
||||||
This record covers path-safe FBX texture discovery, transactional bundle import, labeled runtime
|
|
||||||
images, static-mesh dependency publication, read-only project validation, and stable Asset Browser
|
|
||||||
states. The permanent contract is owned by
|
|
||||||
[ADR 0044](../../../adr/0044-sandboxed-fbx-external-texture-dependencies.md).
|
|
||||||
|
|
||||||
## Automated Verification
|
|
||||||
|
|
||||||
| Command or suite | Result | Evidence |
|
|
||||||
|------------------|--------|----------|
|
|
||||||
| `cargo fmt --all -- --check` | Pass | Formatting completed without changes. |
|
|
||||||
| `git diff --check` | Pass | No whitespace errors. |
|
|
||||||
| `cargo clippy --workspace --all-targets -- -D warnings` | Pass | Every workspace target completed with warnings denied. |
|
|
||||||
| `cargo test --workspace --all-targets` | Pass | All workspace targets passed. Editor reported 309 passing tests; scene reported 103 passing tests and one ignored manual migration test. |
|
|
||||||
| FBX dependency/import regressions | Pass | Six focused tests cover the committed chair, sibling `textures/`, `.fbm/`, missing bundles, parent traversal, and destination symlink containment. Thumbnail regressions cover terminal missing-texture state and first-render pipeline warmup. |
|
|
||||||
| `cargo validate-levels --project .` | Pass | 84 dependencies, six known nonblocking findings, zero blocking errors. The chair contributes one informational Authoring Override finding. |
|
|
||||||
| `cargo validate-samples --project .` | Pass | Five samples, 89 dependencies, the same six nonblocking findings, zero blocking errors. |
|
|
||||||
| Controlled Source Materials validation | Pass | Changing only the chair registry and static-manifest policies on top of `3e30c61` produced one `import.external_texture_missing` blocker for all three paths, then restoration returned to zero blockers. |
|
|
||||||
| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed. |
|
|
||||||
|
|
||||||
## Native Acceptance
|
|
||||||
|
|
||||||
The exact debug binary built from `3e30c61` was launched as
|
|
||||||
`target/debug/editor --project .` under native Hyprland Wayland.
|
|
||||||
|
|
||||||
| Workflow | Result | Evidence |
|
|
||||||
|----------|--------|----------|
|
|
||||||
| First model-folder visit | Pass | The painted-chair model rendered on the first visit without manual thumbnail regeneration; the first offscreen PBR capture remained active through render-pipeline warmup. |
|
|
||||||
| Deliberately normalized chair | Pass | Selecting the committed Authoring Override chair showed one `3 missing \| override` dependency state and all three paths. Its mesh preview rendered neutrally, while source-material and missing-texture subassets settled to terminal warning icons instead of indefinite spinners. |
|
|
||||||
| Runtime log | Pass | The exact committed run emitted no stdout/stderr output, including no `bevy_asset`, `bevy_ufbx`, or path-not-found errors, and exited with code 0. |
|
|
||||||
| Checkout stability | Pass | Native startup, model browsing, thumbnail generation, and exit left `git status --short` empty. |
|
|
||||||
| Controlled broken fixture | Pass | On top of `3e30c61`, only the two chair `material_policy` fields were changed to `SourceMaterials`. The editor showed `Source Materials`, one red `3 missing` state, and terminal warning subassets. No asset/FBX/path error appeared; only two unrelated egui multi-pass layout warnings occurred at startup. |
|
|
||||||
| Fixture restoration | Pass | The two policy fields were restored to Authoring Override, `git status --short` returned empty, and `validate-levels` returned to zero blockers. |
|
|
||||||
|
|
||||||
## Native Evidence
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
- Gitea attachment asset 43: `blacksite-issue58-chair-normalized-3e30c61.png`, 1426x3384
|
|
||||||
RGB PNG, 1,802,863 bytes.
|
|
||||||
- SHA-256: `e492082f7eb3314bed6c97d424bc2281b125849552997f12931013a8e50630f2`.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
- Gitea attachment asset 44: `blacksite-issue58-chair-broken-source-materials-3e30c61.png`,
|
|
||||||
1426x3384 RGB PNG, 2,042,528 bytes.
|
|
||||||
- SHA-256: `759297276287a05bf678ea72e26159d5cb4613d917db447a1cbc1efa562fc831`.
|
|
||||||
|
|
||||||
Both images are ordinary Gitea issue attachments. This record does not use repository raw or Git
|
|
||||||
LFS pointer URLs. A direct header check returned HTTP 200, `Content-Type: image/png`, inline content
|
|
||||||
disposition, and the recorded byte length for each attachment.
|
|
||||||
|
|
||||||
## Release Use
|
|
||||||
|
|
||||||
The #58 source and native feature-level acceptance record is complete. A future production
|
|
||||||
candidate must rerun representative sibling-folder and `.fbm` imports, the deliberate override
|
|
||||||
case, and the broken Source Materials case from the exact nominated commit. This record does not
|
|
||||||
nominate a candidate or establish production readiness.
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# Production-Readiness Acceptance Matrix
|
# Production-Readiness Acceptance Matrix
|
||||||
|
|
||||||
**Matrix version:** 0.7
|
**Matrix version:** 0.6
|
||||||
|
|
||||||
**Last audit:** 2026-07-13
|
**Last audit:** 2026-07-13
|
||||||
|
|
||||||
@ -30,14 +30,14 @@ another commit, a dirty worktree, or an older package do not transfer to the can
|
|||||||
|
|
||||||
| ID | Requirement | State | Current evidence and gap |
|
| ID | Requirement | State | Current evidence and gap |
|
||||||
|----|-------------|-------|--------------------------|
|
|----|-------------|-------|--------------------------|
|
||||||
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49`, non-blocking native dialogs `#52`, guarded shutdown/savepoints `#55`, scoped UI action safety `#57`, deterministic asset fingerprints `#56`, and [external FBX dependencies](../fbx-external-texture-dependencies/) `#58` have feature-level source/native acceptance. #56 additionally passed a fresh LFS checkout, both validators, mtime-only drift, and artifact hash stability at `cbd380a`; #58 passed both override and blocking dependency states at `3e30c61`. Candidate-specific end-to-end reruns remain. |
|
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49`, non-blocking native dialogs `#52`, guarded shutdown/savepoints `#55`, scoped UI action safety `#57`, and deterministic asset fingerprints `#56` have feature-level source/native acceptance. #56 additionally passed a fresh LFS checkout, both validators, mtime-only drift, and artifact hash stability at `cbd380a`. Candidate-specific end-to-end reruns remain. |
|
||||||
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Partial | The [five-area sample pack](../sample-regression-pack/) passed source and native acceptance on exact implementation commit `d52cc2e`, including Brush, Material, Terrain, Physics Placement, and Rendering. No release candidate is nominated, brush acceptance `#37` remains open, and combined animation/audio/navigation/PIE/build evidence is incomplete. Packaged testing is owner-deferred. |
|
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Partial | The [five-area sample pack](../sample-regression-pack/) passed source and native acceptance on exact implementation commit `d52cc2e`, including Brush, Material, Terrain, Physics Placement, and Rendering. No release candidate is nominated, brush acceptance `#37` remains open, and combined animation/audio/navigation/PIE/build evidence is incomplete. Packaged testing is owner-deferred. |
|
||||||
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | Source implementation and focused evidence are complete in [operator regression testing](../../operator-regression-testing.md) and the [operator-invariants evaluation](../operator-invariants/); Gitea `#33` closed at `c55f347`. A clean, exact-candidate rerun is still required for `Pass`. |
|
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | Source implementation and focused evidence are complete in [operator regression testing](../../operator-regression-testing.md) and the [operator-invariants evaluation](../operator-invariants/); Gitea `#33` closed at `c55f347`. A clean, exact-candidate rerun is still required for `Pass`. |
|
||||||
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
|
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
|
||||||
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
|
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
|
||||||
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) hydrates LFS, runs both project/sample content gates, and asserts a pristine checkout. The [#56 feature run](../deterministic-asset-fingerprints/) passed native startup and both validators from a fresh LFS-hydrated worktree at `cbd380a`; [#58](../fbx-external-texture-dependencies/) passed both validators and clean native startup at `3e30c61`. No release candidate is nominated and no candidate CI run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
|
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) hydrates LFS, runs both project/sample content gates, and asserts a pristine checkout. The [#56 feature run](../deterministic-asset-fingerprints/) passed native startup and both validators from a fresh LFS-hydrated worktree at `cbd380a`, but no release candidate is nominated and no candidate CI run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
|
||||||
| G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. |
|
| G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. |
|
||||||
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Partial | Sample-pack gap `#32`, guarded shutdown/savepoints `#55`, scoped UI action safety `#57`, deterministic imported-asset fingerprints `#56`, and external FBX dependency handling `#58` have complete feature-level acceptance; no known P0 implementation blocker remains. A consolidated candidate limitations ledger and exact-candidate rerun are still missing. Property-block application `#53` remains P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
|
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Partial | Sample-pack gap `#32`, guarded shutdown/savepoints `#55`, scoped UI action safety `#57`, and deterministic imported-asset fingerprints `#56` have complete feature-level acceptance; no known P0 implementation blocker remains. A consolidated candidate limitations ledger and exact-candidate rerun are still missing. Property-block application `#53` and external FBX dependency handling `#58` are P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
|
||||||
|
|
||||||
## Deliverables
|
## Deliverables
|
||||||
|
|
||||||
@ -72,7 +72,6 @@ another commit, a dirty worktree, or an older package do not transfer to the can
|
|||||||
| Guarded shutdown and clean savepoints | Implemented; `#55` source validation and all seven native workflows passed at source implementation `4b33f32` | Feature-level acceptance complete; not rerun as one candidate |
|
| Guarded shutdown and clean savepoints | Implemented; `#55` source validation and all seven native workflows passed at source implementation `4b33f32` | Feature-level acceptance complete; not rerun as one candidate |
|
||||||
| Scoped UI action safety | Implemented; `#57` source validation, six focused regressions, selection precedence, and native toolbar/Inspector/diagnostics workflows passed at `9e23ae7` | Feature-level acceptance complete; not rerun as one candidate |
|
| Scoped UI action safety | Implemented; `#57` source validation, six focused regressions, selection precedence, and native toolbar/Inspector/diagnostics workflows passed at `9e23ae7` | Feature-level acceptance complete; not rerun as one candidate |
|
||||||
| Imported asset identity and generated artifacts | Implemented; `#56` uses shared BLAKE3 fingerprints, byte-preserving semantic publication, and normalized registry order | Fresh LFS checkout, native startup, mtime-only drift, validators, and artifact hashes passed at `cbd380a`; not rerun as one candidate |
|
| Imported asset identity and generated artifacts | Implemented; `#56` uses shared BLAKE3 fingerprints, byte-preserving semantic publication, and normalized registry order | Fresh LFS checkout, native startup, mtime-only drift, validators, and artifact hashes passed at `cbd380a`; not rerun as one candidate |
|
||||||
| FBX external texture dependencies | Implemented; `#58` uses one sandboxed resolver, transactional bundle import, labeled loader images, authoritative manifests, and explicit Source Materials/Authoring Override semantics | Sibling/`.fbm`/missing/traversal/symlink tests, both validators, exact-implementation native chair QA, and a controlled broken fixture passed at `3e30c61`; not rerun as one candidate |
|
|
||||||
|
|
||||||
## Candidate Validation Commands
|
## Candidate Validation Commands
|
||||||
|
|
||||||
|
|||||||
@ -120,8 +120,8 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
|
|||||||
| Item | Status | Notes |
|
| 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 |
|
| `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 + binary FBX; sandboxed `textures/`/`.fbm/` discovery and transactional referenced-bundle copy |
|
| Model import formats | Done | glTF/GLB + FBX (binary); `.fbm` sidecar copy on import |
|
||||||
| Thumbnails | Done | Textures via asset load; glTF albedo fast-path; direct neutral FBX geometry; preflighted source-material dependency state |
|
| Thumbnails | Done | Textures via asset load; glTF albedo fast-path; FBX/untextured models via offscreen studio |
|
||||||
| Import settings per asset | Done | Scale/collider/LOD in registry + details pane |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
1
third_party/bevy_ufbx/src/lib.rs
vendored
1
third_party/bevy_ufbx/src/lib.rs
vendored
@ -25,7 +25,6 @@ pub mod material;
|
|||||||
pub mod mesh;
|
pub mod mesh;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod texture;
|
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
|
|||||||
184
third_party/bevy_ufbx/src/loader.rs
vendored
184
third_party/bevy_ufbx/src/loader.rs
vendored
@ -5,12 +5,11 @@ use crate::material::process_materials;
|
|||||||
use crate::mesh::process_meshes;
|
use crate::mesh::process_meshes;
|
||||||
use crate::node::{process_nodes, process_skins};
|
use crate::node::{process_nodes, process_skins};
|
||||||
use crate::scene::build_scene;
|
use crate::scene::build_scene;
|
||||||
use crate::texture::external_texture_path_report;
|
|
||||||
use crate::types::{Fbx, FbxAxisSystem, FbxMeta, Handedness};
|
use crate::types::{Fbx, FbxAxisSystem, FbxMeta, Handedness};
|
||||||
use bevy::asset::{io::Reader, AssetLoader, LoadContext, RenderAssetUsages};
|
use bevy::asset::{io::Reader, AssetLoader, LoadContext, RenderAssetUsages};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Settings for FBX file loading.
|
/// Settings for FBX file loading.
|
||||||
///
|
///
|
||||||
@ -75,128 +74,77 @@ impl AssetLoader for FbxLoader {
|
|||||||
return Err(FbxError::InvalidData("FBX file too small".to_string()));
|
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 filename_hint = load_context.path().path().to_string_lossy().to_string();
|
||||||
let root = parse_fbx(&bytes, &filename_hint)?;
|
let root = ufbx::load_memory(
|
||||||
let texture_report = external_texture_path_report(&root);
|
&bytes,
|
||||||
if texture_report.paths.is_empty() {
|
ufbx::LoadOpts {
|
||||||
if !texture_report.errors.is_empty() {
|
target_unit_meters: 1.0,
|
||||||
warn_texture_failures(
|
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
|
||||||
load_context.path(),
|
filename: ufbx::StringOpt::Ref(&filename_hint),
|
||||||
texture_report.errors.iter().map(ToString::to_string),
|
..Default::default()
|
||||||
);
|
},
|
||||||
}
|
)
|
||||||
return process_scene(&root, settings, load_context, &HashMap::new());
|
.map_err(|e| FbxError::UfbxError(format!("{:?}", e)))?;
|
||||||
}
|
let scene: &ufbx::Scene = &*root;
|
||||||
|
|
||||||
// `ufbx::Scene` is not Send, so finish discovery and drop it before awaiting asset I/O.
|
// Process meshes
|
||||||
drop(root);
|
let (meshes, named_meshes, mesh_transforms, mesh_material_info) =
|
||||||
let mut failures = texture_report
|
process_meshes(scene, settings, load_context)?;
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
let root = parse_fbx(&bytes, &filename_hint)?;
|
// Process materials and textures
|
||||||
process_scene(&root, settings, load_context, &external_texture_bytes)
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extensions(&self) -> &[&str] {
|
fn extensions(&self) -> &[&str] {
|
||||||
&["fbx"]
|
&["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("; ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
139
third_party/bevy_ufbx/src/material.rs
vendored
139
third_party/bevy_ufbx/src/material.rs
vendored
@ -3,21 +3,19 @@
|
|||||||
use crate::error::FbxError;
|
use crate::error::FbxError;
|
||||||
use crate::label::FbxAssetLabel;
|
use crate::label::FbxAssetLabel;
|
||||||
use crate::loader::FbxLoaderSettings;
|
use crate::loader::FbxLoaderSettings;
|
||||||
use crate::texture::external_texture_reference;
|
|
||||||
use crate::utils::convert_texture_uv_transform;
|
use crate::utils::convert_texture_uv_transform;
|
||||||
use bevy::asset::{Handle, LoadContext};
|
use bevy::asset::{Handle, LoadContext};
|
||||||
use bevy::pbr::StandardMaterial;
|
use bevy::pbr::StandardMaterial;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_material::AlphaMode;
|
use bevy_material::AlphaMode;
|
||||||
use bevy_image::{CompressedImageFormats, ImageSampler, ImageType};
|
use bevy_image::{CompressedImageFormats, ImageSampler, ImageType};
|
||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Process all materials from the FBX scene.
|
/// Process all materials from the FBX scene.
|
||||||
pub fn process_materials(
|
pub fn process_materials(
|
||||||
scene: &ufbx::Scene,
|
scene: &ufbx::Scene,
|
||||||
settings: &FbxLoaderSettings,
|
settings: &FbxLoaderSettings,
|
||||||
load_context: &mut LoadContext<'_>,
|
load_context: &mut LoadContext,
|
||||||
external_texture_bytes: &HashMap<String, Vec<u8>>,
|
|
||||||
) -> Result<
|
) -> Result<
|
||||||
(
|
(
|
||||||
Vec<Handle<StandardMaterial>>,
|
Vec<Handle<StandardMaterial>>,
|
||||||
@ -27,12 +25,7 @@ pub fn process_materials(
|
|||||||
> {
|
> {
|
||||||
let mut materials = Vec::new();
|
let mut materials = Vec::new();
|
||||||
let mut named_materials = HashMap::new();
|
let mut named_materials = HashMap::new();
|
||||||
let texture_handles = process_textures(
|
let texture_handles = process_textures(scene, settings, load_context)?;
|
||||||
scene,
|
|
||||||
settings,
|
|
||||||
load_context,
|
|
||||||
external_texture_bytes,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
for (index, ufbx_material) in scene.materials.as_ref().iter().enumerate() {
|
for (index, ufbx_material) in scene.materials.as_ref().iter().enumerate() {
|
||||||
if ufbx_material.element.element_id == 0 {
|
if ufbx_material.element.element_id == 0 {
|
||||||
@ -67,15 +60,18 @@ pub fn process_materials(
|
|||||||
pub fn process_textures(
|
pub fn process_textures(
|
||||||
scene: &ufbx::Scene,
|
scene: &ufbx::Scene,
|
||||||
settings: &FbxLoaderSettings,
|
settings: &FbxLoaderSettings,
|
||||||
load_context: &mut LoadContext<'_>,
|
load_context: &mut LoadContext,
|
||||||
external_texture_bytes: &HashMap<String, Vec<u8>>,
|
|
||||||
) -> Result<HashMap<u32, Handle<Image>>, FbxError> {
|
) -> Result<HashMap<u32, Handle<Image>>, FbxError> {
|
||||||
let mut texture_handles = HashMap::new();
|
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() {
|
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
|
// Priority 1: Embedded texture data
|
||||||
if !texture.content.is_empty() {
|
if !texture.content.is_empty() {
|
||||||
let ext = extract_extension(&texture.filename).unwrap_or("png");
|
let ext = extract_extension(&texture.filename).unwrap_or("png");
|
||||||
@ -95,62 +91,39 @@ pub fn process_textures(
|
|||||||
texture_handles.insert(texture.element.element_id, handle);
|
texture_handles.insert(texture.element.element_id, handle);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(e) => {
|
||||||
warn!(
|
eprintln!("bevy_ufbx: failed to load embedded texture {index}: {e}");
|
||||||
"bevy_ufbx: skipped embedded texture {index} for {asset_path}: {error}"
|
// Fall through to try external paths
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let reference = match external_texture_reference(index, texture) {
|
// Priority 2: relative_filename from ufbx (relative to FBX file)
|
||||||
Ok(Some(reference)) => reference,
|
if !texture.relative_filename.is_empty() {
|
||||||
Ok(None) => continue,
|
let rel = texture.relative_filename.as_ref();
|
||||||
Err(error) => {
|
let is_absolute = rel.starts_with('/')
|
||||||
unavailable.insert(error.to_string());
|
|| (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);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
if let Some(handle) = external_handles.get(&reference.relative_path) {
|
|
||||||
texture_handles.insert(reference.element_id, handle.clone());
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(bytes) = external_texture_bytes.get(&reference.relative_path) else {
|
// Priority 3: Extract relative path from filename / absolute_filename
|
||||||
continue;
|
// Preserves .fbm folder structure if present (e.g., "model.fbm/texture.jpg")
|
||||||
};
|
let relative_path = extract_relative_texture_path(texture);
|
||||||
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 !unavailable.is_empty() {
|
if !relative_path.is_empty() {
|
||||||
warn!(
|
let texture_path = fbx_dir
|
||||||
"bevy_ufbx: skipped {} unavailable external texture reference(s) for {}: {}",
|
.join(relative_path)
|
||||||
unavailable.len(),
|
.to_string_lossy()
|
||||||
asset_path,
|
.to_string();
|
||||||
unavailable.into_iter().collect::<Vec<_>>().join("; ")
|
|
||||||
);
|
let image_handle = load_context.load(texture_path);
|
||||||
|
texture_handles.insert(texture.element.element_id, image_handle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(texture_handles)
|
Ok(texture_handles)
|
||||||
@ -167,6 +140,48 @@ fn extract_extension(filename: &ufbx::String) -> Option<&str> {
|
|||||||
.filter(|ext| !ext.contains('/') && !ext.contains('\\'))
|
.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.
|
/// Create a StandardMaterial from ufbx material.
|
||||||
pub fn create_standard_material(
|
pub fn create_standard_material(
|
||||||
ufbx_material: &ufbx::Material,
|
ufbx_material: &ufbx::Material,
|
||||||
|
|||||||
211
third_party/bevy_ufbx/src/texture.rs
vendored
211
third_party/bevy_ufbx/src/texture.rs
vendored
@ -1,211 +0,0 @@
|
|||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user